terp-cli 0.1.0__py3-none-any.whl

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/scaffold.py ADDED
@@ -0,0 +1,343 @@
1
+ """``terp new module`` — scaffold a canonical secure-by-default module (Tier-C).
2
+
3
+ Emits the five fixed slots (``models`` / ``schemas`` / ``service`` / ``router`` /
4
+ ``module``) plus the package ``__init__`` into ``<app>/modules/<name>/``. The output
5
+ is deliberately the *canonical* shape: it passes every ``terp.arch`` rule out of the
6
+ box, so the only remaining step before a green gate is the model's first migration
7
+ (``terp migrate make <name>``) — the 10-minute path of design §13.
8
+
9
+ The generated code is yours to edit (Level-1 sugar, never a runtime black box):
10
+ inherit ``BaseTable``, declare DTOs, set ``model`` on a ``BaseService``, keep the
11
+ router thin. There is no magic to remove.
12
+
13
+ ``--profile`` picks a :mod:`permission profile <terp.cli.profiles>` — a preset that
14
+ only decides which existing primitives the slots compose (``Policy``, model traits,
15
+ service base); the output stays canonical and gate-checked either way.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import keyword
21
+ import pathlib
22
+ from collections.abc import Sequence
23
+
24
+ from terp.cli.profiles import DEFAULT_PROFILE, ModuleProfile, get_profile
25
+
26
+
27
+ def _singular(name: str) -> str:
28
+ """``invoices`` -> ``invoice``; ``billing`` -> ``billing`` (drop a trailing ``s``)."""
29
+ return name[:-1] if name.endswith("s") else name
30
+
31
+
32
+ def _model_name(name: str) -> str:
33
+ """``invoices`` -> ``Invoice``; ``billing`` -> ``Billing`` (PascalCase, singular)."""
34
+ singular = _singular(name)
35
+ return singular[:1].upper() + singular[1:]
36
+
37
+
38
+ def _pascal(name: str) -> str:
39
+ """``invoices`` -> ``Invoices`` (PascalCase, plurality unchanged) for view/nav names."""
40
+ return name[:1].upper() + name[1:]
41
+
42
+
43
+ def _validate_name(name: str) -> str:
44
+ """A module name must be a lowercase, importable identifier (no dots / spaces)."""
45
+ if not name.isidentifier() or keyword.iskeyword(name):
46
+ raise SystemExit(
47
+ f"invalid module name {name!r}: use a lowercase Python identifier "
48
+ "(e.g. 'invoices')"
49
+ )
50
+ return name
51
+
52
+
53
+ def _models_py(model: str, name: str, profile: ModuleProfile) -> str:
54
+ core_imports = ", ".join(("BaseTable", *sorted(profile.core_model_mixins)))
55
+ extra_imports = "".join(f"{line}\n" for line in profile.model_import_lines)
56
+ bases = ", ".join(("BaseTable", *profile.model_mixins))
57
+ return f'''\
58
+ """``{name}`` table model — composes the kernel ``BaseTable`` (UUID + timestamps + OCC)."""
59
+
60
+ from __future__ import annotations
61
+
62
+ from sqlmodel import Field
63
+
64
+ {extra_imports}from terp.core import {core_imports}
65
+
66
+
67
+ class {model}({bases}, table=True):
68
+ """``id`` / ``created_at`` / ``updated_at`` / ``version`` are inherited — never redeclared."""
69
+
70
+ __tablename__ = "{_singular(name)}"
71
+
72
+ name: str = Field(max_length=200, index=True)
73
+ '''
74
+
75
+
76
+ def _schemas_py(model: str) -> str:
77
+ return f'''\
78
+ """``{model}`` DTOs — compose the kernel schema bases; cap every input string."""
79
+
80
+ from __future__ import annotations
81
+
82
+ import datetime
83
+ import uuid
84
+
85
+ from sqlmodel import Field
86
+
87
+ from terp.core import BaseSchema, BaseUpdateSchema
88
+
89
+
90
+ class {model}Create(BaseSchema):
91
+ name: str = Field(max_length=200)
92
+
93
+
94
+ class {model}Update(BaseUpdateSchema):
95
+ name: str | None = Field(default=None, max_length=200)
96
+ # `version: int` is inherited and required (optimistic concurrency).
97
+
98
+
99
+ class {model}Read(BaseSchema):
100
+ id: uuid.UUID
101
+ name: str
102
+ version: int
103
+ created_at: datetime.datetime
104
+ updated_at: datetime.datetime
105
+ '''
106
+
107
+
108
+ def _service_py(model: str, name: str, package: str, profile: ModuleProfile) -> str:
109
+ base = profile.service_base
110
+ return f'''\
111
+ """``{name}`` service — CRUD is inherited from ``{base}`` (audited, OCC, scoped)."""
112
+
113
+ from __future__ import annotations
114
+
115
+ {profile.service_import_line}
116
+
117
+ from {package}.modules.{name}.models import {model}
118
+ from {package}.modules.{name}.schemas import {model}Create, {model}Update
119
+
120
+
121
+ class {model}Service({base}[{model}, {model}Create, {model}Update]):
122
+ model = {model}
123
+ '''
124
+
125
+
126
+ def _router_py(model: str, name: str, package: str) -> str:
127
+ return f'''\
128
+ """``{name}`` router — thin CRUD over :class:`{model}Service` using kernel seams only."""
129
+
130
+ from __future__ import annotations
131
+
132
+ import uuid
133
+
134
+ from fastapi import APIRouter
135
+
136
+ from terp.core import Page, PaginationDep, SessionDep
137
+
138
+ from {package}.modules.{name}.schemas import {model}Create, {model}Read, {model}Update
139
+ from {package}.modules.{name}.service import {model}Service
140
+
141
+ router = APIRouter(tags=["{name}"])
142
+ _service = {model}Service()
143
+
144
+
145
+ @router.get("/", response_model=Page[{model}Read])
146
+ def list_{name}(session: SessionDep, pagination: PaginationDep) -> Page[{model}Read]:
147
+ rows, total = _service.list(session, skip=pagination.skip, limit=pagination.limit)
148
+ return Page[{model}Read].of(
149
+ [{model}Read.model_validate(row) for row in rows], total, pagination
150
+ )
151
+
152
+
153
+ @router.post("/", response_model={model}Read, status_code=201)
154
+ def create_{model.lower()}(payload: {model}Create, session: SessionDep) -> {model}Read:
155
+ return {model}Read.model_validate(_service.create(session, payload))
156
+
157
+
158
+ @router.get("/{{item_id}}", response_model={model}Read)
159
+ def get_{model.lower()}(item_id: uuid.UUID, session: SessionDep) -> {model}Read:
160
+ return {model}Read.model_validate(_service.get(session, item_id))
161
+
162
+
163
+ @router.patch("/{{item_id}}", response_model={model}Read)
164
+ def update_{model.lower()}(
165
+ item_id: uuid.UUID, payload: {model}Update, session: SessionDep
166
+ ) -> {model}Read:
167
+ return {model}Read.model_validate(_service.update(session, item_id, payload))
168
+
169
+
170
+ @router.delete("/{{item_id}}", status_code=204)
171
+ def delete_{model.lower()}(item_id: uuid.UUID, session: SessionDep) -> None:
172
+ _service.delete(session, item_id)
173
+ '''
174
+
175
+
176
+ def _module_py(name: str, package: str, profile: ModuleProfile) -> str:
177
+ model = _model_name(name)
178
+ core_imports = ", ".join(sorted(profile.policy_imports))
179
+ return f'''\
180
+ """``{name}`` manifest — the entire public surface; profile ``{profile.name}``."""
181
+
182
+ from __future__ import annotations
183
+
184
+ from terp.core import {core_imports}
185
+
186
+ from {package}.modules.{name}.router import router
187
+ from {package}.modules.{name}.service import {model}Service
188
+
189
+ module = ModuleSpec(
190
+ name="{name}",
191
+ router=router,
192
+ policy={profile.policy_expr},
193
+ services=({model}Service,),
194
+ )
195
+ '''
196
+
197
+
198
+ def _files(name: str, package: str, profile: ModuleProfile) -> dict[str, str]:
199
+ model = _model_name(name)
200
+ return {
201
+ "__init__.py": "",
202
+ "models.py": _models_py(model, name, profile),
203
+ "schemas.py": _schemas_py(model),
204
+ "service.py": _service_py(model, name, package, profile),
205
+ "router.py": _router_py(model, name, package),
206
+ "module.py": _module_py(name, package, profile),
207
+ }
208
+
209
+
210
+ _MODULE_TSX = """\
211
+ import { defineModuleManifest } from "@terp/contract";
212
+
213
+ import { __PASCAL__List } from "./__PASCAL__List";
214
+
215
+ // Dropped in and auto-discovered by renderTerpApp's import.meta.glob — no registration.
216
+ export const manifest = defineModuleManifest({
217
+ name: "__NAME__",
218
+ routes: [{ path: "/__NAME__", view: "__PASCAL__List" }],
219
+ nav: [{ label: "__PASCAL__", to: "/__NAME__" }],
220
+ });
221
+
222
+ export const views = { __PASCAL__List };
223
+ """
224
+
225
+ _VIEW_TSX = """\
226
+ // The `__NAME__` module view — a starting point you own (real React, no hidden DSL). It composes
227
+ // the shared <ResourceList> so every module lists + creates the same way, with the write-gate
228
+ // applied for you. Wire it to YOUR endpoint once you've generated the typed client:
229
+ //
230
+ // uv run terp openapi && npm --prefix frontend run generate // -> src/api/schema.d.ts
231
+ //
232
+ // import { useResource, useTerpClient } from "@terp/react-core";
233
+ // import type { paths, components } from "../../api/schema";
234
+ // type __PASCAL__ = components["schemas"]["__PASCAL__Read"];
235
+ // const client = useTerpClient<paths>();
236
+ // const __NAME__ = useResource<__PASCAL__, string>({
237
+ // list: async () => (await client.GET("/api/v1/__NAME__/", {})).data?.items ?? [],
238
+ // create: async (name) => { await client.POST("/api/v1/__NAME__/", { body: { name } }); },
239
+ // });
240
+ // // ...then pass createPlaceholder="New __NAME__" to <ResourceList> so writers can add rows.
241
+ //
242
+ import { OverviewPage, ResourceList, useResource } from "@terp/react-core";
243
+
244
+ export function __PASCAL__List() {
245
+ // Placeholder until you wire the typed client above: an empty, read-only list (compiles pre-codegen).
246
+ const __NAME__ = useResource<{ id: string; name: string }, string>({ list: async () => [] });
247
+ return (
248
+ <OverviewPage title="__PASCAL__">
249
+ <ResourceList
250
+ resource={__NAME__}
251
+ renderItem={(item) => <strong>{item.name}</strong>}
252
+ />
253
+ </OverviewPage>
254
+ );
255
+ }
256
+ """
257
+
258
+
259
+ def _frontend_files(name: str) -> dict[str, str]:
260
+ """The frontend slot: a self-describing ``module.tsx`` + a starter list view."""
261
+ pascal = _pascal(name)
262
+ return {
263
+ "module.tsx": _MODULE_TSX.replace("__PASCAL__", pascal).replace("__NAME__", name),
264
+ f"{pascal}List.tsx": _VIEW_TSX.replace("__PASCAL__", pascal).replace("__NAME__", name),
265
+ }
266
+
267
+
268
+ def new_module(
269
+ name: str,
270
+ *,
271
+ root: str | pathlib.Path = ".",
272
+ package: str = "app",
273
+ frontend: bool = True,
274
+ profile: str = DEFAULT_PROFILE,
275
+ ) -> list[pathlib.Path]:
276
+ """Scaffold the canonical ``<package>/modules/<name>/`` module under *root*.
277
+
278
+ When *frontend* is true and a ``frontend/src/modules`` app exists under *root*, the
279
+ matching frontend slot (``module.tsx`` + a starter ``<Name>List.tsx`` view) is emitted
280
+ too, so the module is full-stack and auto-discovered by ``renderTerpApp`` — no central
281
+ registration. A backend-only repo (no frontend app) silently gets just the backend.
282
+
283
+ Returns the created file paths. Raises :class:`SystemExit` for an invalid name or
284
+ an existing destination, so a partial overwrite never happens.
285
+ """
286
+ name = _validate_name(name)
287
+ module_profile = get_profile(profile)
288
+ root_path = pathlib.Path(root)
289
+ destination = root_path / package / "modules" / name
290
+ if destination.exists():
291
+ raise SystemExit(f"refusing to overwrite existing module directory: {destination}")
292
+
293
+ frontend_modules = root_path / "frontend" / "src" / "modules"
294
+ emit_frontend = frontend and frontend_modules.is_dir()
295
+ frontend_destination = frontend_modules / name
296
+ if emit_frontend and frontend_destination.exists():
297
+ raise SystemExit(
298
+ f"refusing to overwrite existing frontend module directory: {frontend_destination}"
299
+ )
300
+
301
+ destination.mkdir(parents=True)
302
+ created: list[pathlib.Path] = []
303
+ for filename, content in _files(name, package, module_profile).items():
304
+ path = destination / filename
305
+ path.write_text(content, encoding="utf-8")
306
+ created.append(path)
307
+
308
+ if emit_frontend:
309
+ frontend_destination.mkdir(parents=True)
310
+ for filename, content in _frontend_files(name).items():
311
+ path = frontend_destination / filename
312
+ path.write_text(content, encoding="utf-8")
313
+ created.append(path)
314
+
315
+ return created
316
+
317
+
318
+ def new_module_message(
319
+ name: str, paths: Sequence[pathlib.Path], *, profile: str = DEFAULT_PROFILE
320
+ ) -> str:
321
+ """The human/agent-facing next-steps note after scaffolding *name*."""
322
+ module_profile = get_profile(profile)
323
+ listing = "\n".join(f" {path}" for path in paths)
324
+ has_frontend = any(path.suffix == ".tsx" for path in paths)
325
+ steps = [
326
+ f" app/main.py # mount it: from app.modules.{name}.module "
327
+ f"import module as {name}_module; add {name}_module to the modules list",
328
+ f" uv run terp migrate make {name} # first migration (before a green gate)",
329
+ ]
330
+ if has_frontend:
331
+ steps.append(
332
+ f" uv run terp openapi # regenerate the contract so the client sees /api/v1/{name}/"
333
+ )
334
+ steps.append(
335
+ " npm --prefix frontend run generate # openapi.json -> frontend/src/api/schema.d.ts (typed client)"
336
+ )
337
+ steps.append(" uv run terp check # run the architecture gate locally")
338
+ notes = "".join(f"\nNote: {note}" for note in module_profile.notes)
339
+ return (
340
+ f"Scaffolded module {name!r} ({len(paths)} files, profile {module_profile.name!r}):\n"
341
+ f"{listing}\n\n"
342
+ "Next:\n" + "\n".join(steps) + notes
343
+ )
terp/cli/schema.py ADDED
@@ -0,0 +1,317 @@
1
+ """``terp inspect schema`` — the Schema Graph: every table, owned and alarmed.
2
+
3
+ The database schema has one sanctioned source of truth: the shared SQLModel
4
+ metadata, populated by importing every declared **migration tree**'s models
5
+ module (exactly how ``terp migrate`` discovers models). This module projects
6
+ that metadata into one structured report — JSON-first so
7
+ external tooling (Terp Studio) can visualize the data model without importing
8
+ ``terp.*`` — and reconciles it fail-visibly, mirroring the access graph:
9
+
10
+ * **``tables``** — every mapped table, attributed to its owning migration tree
11
+ (``module`` + ``kind``) with its column/foreign-key detail and the enforced
12
+ kernel traits (``BaseTable`` lineage, soft-delete / owned / tenant-scoped /
13
+ actor-stamped).
14
+ * **``unowned_tables``** — mapped tables whose model module NO migration tree
15
+ owns: ``terp migrate`` will never manage their schema (drift risk). Alarmed,
16
+ never silently mislabeled.
17
+ * **``non_canonical_models``** — mapped classes that do not inherit
18
+ ``BaseTable``: they bypass the kernel's id/timestamps/OCC contract (the
19
+ ``table_models_use_base_table`` gate rule is the build-time half; this is the
20
+ inspection's fail-visible view of the same drift).
21
+ * **``unmapped_tables``** — raw ``Table`` objects on the metadata with no
22
+ mapped class (hand-rolled DDL; the ``no_manual_table_schema`` rule's
23
+ build-time territory) — reported, never dropped.
24
+ * **``unimported_models``** — ``table=True`` models found by a SOURCE SCAN of
25
+ the app tree that never landed on the metadata: a model defined outside the
26
+ sanctioned ``models.py`` import path would otherwise be invisible to every
27
+ runtime view. The scan is the reconciliation ground truth the metadata is
28
+ held against, exactly like ``app.openapi()`` for routes.
29
+
30
+ This is a **view, never a second source of truth** (ADR 0011).
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import ast
36
+ import importlib
37
+ import json
38
+ import pathlib
39
+ from collections.abc import Iterable, Sequence
40
+
41
+ from sqlmodel import SQLModel
42
+
43
+ from terp.core import ActorStampedMixin, BaseTable, OwnedMixin, SoftDeleteMixin
44
+ from terp.core.migrations import MigrationTree, resolve_all_migration_trees
45
+
46
+
47
+ def import_declared_models(
48
+ app_root: str | pathlib.Path | None = None, *, package: str = "app"
49
+ ) -> list[MigrationTree]:
50
+ """Import every declared migration tree's models module (the sanctioned loader).
51
+
52
+ Mirrors ``terp migrate``'s model discovery: each tree's ``<import_path>.models``
53
+ is imported into the shared SQLModel metadata. A tree without a models module is
54
+ a route-only package — skipped, exactly like the migration runtime does.
55
+ """
56
+ trees = resolve_all_migration_trees(app_root, package=package)
57
+ for tree in trees:
58
+ module = tree.models_module
59
+ try:
60
+ importlib.import_module(module)
61
+ except ModuleNotFoundError as exc:
62
+ missing = exc.name or ""
63
+ if missing == module or module.startswith(missing + "."):
64
+ continue # route-only package: not a table owner
65
+ raise
66
+ return trees
67
+
68
+
69
+ def _owning_tree(model_module: str, trees: Sequence[MigrationTree]) -> MigrationTree | None:
70
+ """The tree whose ``import_path`` is the longest ownership prefix of *model_module*."""
71
+ best: MigrationTree | None = None
72
+ best_len = -1
73
+ for tree in trees:
74
+ path = tree.import_path
75
+ owns = model_module == path or model_module.startswith(path + ".")
76
+ if owns and len(path) > best_len:
77
+ best, best_len = tree, len(path)
78
+ return best
79
+
80
+
81
+ def _mro_names(model: type) -> set[str]:
82
+ return {klass.__name__ for klass in model.__mro__}
83
+
84
+
85
+ def _column_json(column: object) -> dict[str, object]:
86
+ try:
87
+ type_name = str(column.type) # type: ignore[attr-defined]
88
+ except Exception: # pragma: no cover - exotic custom types only
89
+ type_name = type(column.type).__name__ # type: ignore[attr-defined]
90
+ return {
91
+ "name": column.name, # type: ignore[attr-defined]
92
+ "type": type_name,
93
+ "nullable": bool(column.nullable), # type: ignore[attr-defined]
94
+ "primary_key": bool(column.primary_key), # type: ignore[attr-defined]
95
+ "unique": bool(column.unique), # type: ignore[attr-defined]
96
+ }
97
+
98
+
99
+ def _table_json(table: object) -> dict[str, object]:
100
+ return {
101
+ "name": table.name, # type: ignore[attr-defined]
102
+ "schema": table.schema, # type: ignore[attr-defined]
103
+ "columns": [_column_json(column) for column in table.columns], # type: ignore[attr-defined]
104
+ "foreign_keys": sorted(
105
+ (
106
+ {
107
+ "column": fk.parent.name,
108
+ "references_table": fk.column.table.name,
109
+ "references_column": fk.column.name,
110
+ }
111
+ for fk in table.foreign_keys # type: ignore[attr-defined]
112
+ ),
113
+ key=lambda item: (item["column"], item["references_table"]),
114
+ ),
115
+ }
116
+
117
+
118
+ def _traits_json(model: type) -> dict[str, bool]:
119
+ return {
120
+ "base_table": issubclass(model, BaseTable),
121
+ "soft_delete": issubclass(model, SoftDeleteMixin),
122
+ "owned": issubclass(model, OwnedMixin),
123
+ "tenant_scoped": "TenantScopedMixin" in _mro_names(model),
124
+ "actor_stamped": issubclass(model, ActorStampedMixin),
125
+ }
126
+
127
+
128
+ def scan_declared_table_models(
129
+ app_root: str | pathlib.Path, *, package: str = "app"
130
+ ) -> dict[tuple[str, str], tuple[str, int]]:
131
+ """Source-scan ground truth: every ``table=True`` class in the app tree.
132
+
133
+ Maps ``(class name, dotted module derived from the file path)`` ->
134
+ ``(relative file, line)``. AST-only (nothing is imported), so a model defined
135
+ anywhere in the tree is found even when no sanctioned import path reaches it —
136
+ the reconciliation that makes "never skip a model" checkable. Keying by the
137
+ (class, module) pair means a stray app model cannot hide behind an
138
+ already-mapped capability class of the same name.
139
+ """
140
+ root = pathlib.Path(app_root)
141
+ found: dict[tuple[str, str], tuple[str, int]] = {}
142
+ skip = {"__pycache__", "tests", ".venv", "node_modules", "migrations"}
143
+ for path in sorted(root.rglob("*.py")):
144
+ if any(part in skip for part in path.parts):
145
+ continue
146
+ try:
147
+ tree = ast.parse(path.read_text(encoding="utf-8"))
148
+ except SyntaxError: # pragma: no cover - unparseable files are the gate's job
149
+ continue
150
+ relative = path.relative_to(root).as_posix()
151
+ # app/modules/x/models.py -> app.modules.x.models (app_root sits on sys.path,
152
+ # so this is the module name the class would carry once imported).
153
+ dotted = relative[: -len(".py")].replace("/", ".")
154
+ if dotted.endswith(".__init__"): # pragma: no cover - models never live there
155
+ dotted = dotted[: -len(".__init__")]
156
+ for node in ast.walk(tree):
157
+ if not isinstance(node, ast.ClassDef):
158
+ continue
159
+ table_kw = any(
160
+ keyword.arg == "table"
161
+ and isinstance(keyword.value, ast.Constant)
162
+ and keyword.value.value is True
163
+ for keyword in node.keywords
164
+ )
165
+ if table_kw:
166
+ found[(node.name, dotted)] = (relative, node.lineno)
167
+ return found
168
+
169
+
170
+ def build_schema_graph(
171
+ trees: Sequence[MigrationTree],
172
+ *,
173
+ mappers: Iterable[object] | None = None,
174
+ metadata_tables: Iterable[object] | None = None,
175
+ source_models: dict[tuple[str, str], tuple[str, int]] | None = None,
176
+ ) -> dict[str, object]:
177
+ """The Schema Graph as plain data: tables -> ownership -> traits -> alarms.
178
+
179
+ ``mappers`` / ``metadata_tables`` default to the live SQLModel registry and
180
+ metadata (the runtime truth); they are injectable so alarm shapes are testable
181
+ without polluting the process-global registry. ``source_models`` is the AST
182
+ ground truth from :func:`scan_declared_table_models`; any scanned model whose
183
+ table never reached the metadata is alarmed under ``unimported_models``.
184
+ """
185
+ live_mappers = (
186
+ mappers if mappers is not None else list(SQLModel._sa_registry.mappers)
187
+ )
188
+ live_tables = (
189
+ metadata_tables
190
+ if metadata_tables is not None
191
+ else sorted(SQLModel.metadata.tables.values(), key=lambda table: table.name)
192
+ )
193
+ tables: list[dict[str, object]] = []
194
+ unowned: list[dict[str, object]] = []
195
+ non_canonical: list[dict[str, object]] = []
196
+ mapped_table_names: set[str] = set()
197
+ mapped_models: set[tuple[str, str]] = set()
198
+ for mapper in sorted(
199
+ live_mappers, key=lambda item: item.local_table.name if item.local_table is not None else ""
200
+ ):
201
+ table = mapper.local_table # type: ignore[attr-defined]
202
+ model = mapper.class_ # type: ignore[attr-defined]
203
+ if table is None: # pragma: no cover - non-table mappers only
204
+ continue
205
+ mapped_table_names.add(table.name)
206
+ mapped_models.add((model.__name__, model.__module__))
207
+ tree = _owning_tree(model.__module__, trees)
208
+ entry = {
209
+ **_table_json(table),
210
+ "model": model.__name__,
211
+ "module": tree.label if tree is not None else None,
212
+ "kind": (
213
+ ("capability" if tree.import_path.startswith("terp.") else "app")
214
+ if tree is not None
215
+ else None
216
+ ),
217
+ "traits": _traits_json(model),
218
+ }
219
+ tables.append(entry)
220
+ if tree is None:
221
+ unowned.append(
222
+ {
223
+ "table": table.name,
224
+ "model": model.__name__,
225
+ "model_module": model.__module__,
226
+ "detail": "no migration tree owns this model's module -- "
227
+ "terp migrate will never manage its schema",
228
+ }
229
+ )
230
+ # The BaseTable contract is alarmed for the APP's own models (and unowned
231
+ # strays). A capability's non-BaseTable primitive (e.g. the append-only
232
+ # audit event) is the framework's governed territory -- its escape-hatch
233
+ # budget covers it, and the trait stays visible on the table entry.
234
+ if not issubclass(model, BaseTable) and (
235
+ tree is None or not tree.import_path.startswith("terp.")
236
+ ):
237
+ non_canonical.append(
238
+ {
239
+ "table": table.name,
240
+ "model": model.__name__,
241
+ "detail": "does not inherit terp.core.BaseTable -- it bypasses the "
242
+ "kernel id/timestamps/optimistic-concurrency contract",
243
+ }
244
+ )
245
+ unmapped = [
246
+ {
247
+ "table": table.name, # type: ignore[attr-defined]
248
+ "detail": "raw Table on the shared metadata with no mapped model class "
249
+ "(hand-rolled DDL outside the model layer)",
250
+ }
251
+ for table in live_tables
252
+ if table.name not in mapped_table_names # type: ignore[attr-defined]
253
+ ]
254
+ unimported = [
255
+ {
256
+ "model": name,
257
+ "path": location[0],
258
+ "line": location[1],
259
+ "detail": "declared table=True in source but never imported onto the "
260
+ "shared metadata -- invisible to migrations AND to this schema view "
261
+ "until its module is imported from a models.py the app reaches",
262
+ }
263
+ for (name, module), location in sorted((source_models or {}).items())
264
+ if (name, module) not in mapped_models
265
+ ]
266
+ return {
267
+ "tables": tables,
268
+ "unowned_tables": unowned,
269
+ "non_canonical_models": non_canonical,
270
+ "unmapped_tables": unmapped,
271
+ "unimported_models": unimported,
272
+ }
273
+
274
+
275
+ def _render_schema_text(graph: dict[str, object]) -> str:
276
+ lines = ["Schema graph", ""]
277
+ for table in graph["tables"]: # type: ignore[index, union-attr]
278
+ owner = table["module"] or "<unowned>"
279
+ traits = table["traits"]
280
+ flags = ",".join(
281
+ name
282
+ for name in ("soft_delete", "owned", "tenant_scoped", "actor_stamped")
283
+ if traits[name]
284
+ )
285
+ lines.append(
286
+ f" {table['name']:24} {owner:12} {table['kind'] or '!':10} "
287
+ f"{table['model']:20} {flags or '-'}"
288
+ )
289
+ for key, header in (
290
+ ("unowned_tables", "! UNOWNED tables (no migration tree manages them):"),
291
+ ("non_canonical_models", "! NON-CANONICAL models (no BaseTable lineage):"),
292
+ ("unmapped_tables", "! UNMAPPED raw tables on the metadata:"),
293
+ ("unimported_models", "! UNIMPORTED table models (declared in source, never loaded):"),
294
+ ):
295
+ entries: list = graph[key] # type: ignore[assignment]
296
+ if entries:
297
+ lines.append("")
298
+ lines.append(header)
299
+ for entry in entries:
300
+ subject = entry.get("table") or entry.get("model")
301
+ lines.append(f" {subject}: {entry['detail']}")
302
+ return "\n".join(lines)
303
+
304
+
305
+ def render_schema_graph(graph: dict[str, object], fmt: str = "text") -> str:
306
+ """Render a prebuilt schema *graph* as ``text`` or ``json``."""
307
+ if fmt == "json":
308
+ return json.dumps(graph, indent=2)
309
+ return _render_schema_text(graph)
310
+
311
+
312
+ __all__ = [
313
+ "build_schema_graph",
314
+ "import_declared_models",
315
+ "render_schema_graph",
316
+ "scan_declared_table_models",
317
+ ]