Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,15 @@ Standard mixins in `simple_module_db.mixins`: `AuditMixin`, `SoftDeleteMixin` (b

- **SQLModel is the project-wide standard for every model** — both DB tables (`table=True`) and DTOs (plain `SQLModel` subclasses). Do not use Pydantic `BaseModel` or SQLAlchemy `DeclarativeBase` + `Mapped[...]` in module code.
- **300-line cap** on `.py`/`.ts`/`.tsx` files, enforced by `scripts/check_file_size.py` in CI (exempts vendored shadcn components under `packages/ui/src/components/ui/**`). If you approach the cap, split by responsibility — don't rewrite to squeeze under.
- **Per-module settings**: env-var prefix `SM_<MODULE>_*`, stored on `app.state.<module>_settings` inside `register_settings(app)`. `SM012` warns if `register_settings` is overridden without adding to `app.state`.
- **Per-module settings**: env-var prefix `SM_<MODULE>_*`, stored on `app.state.<module_lower>` inside `register_settings(app)` as a module-owned dataclass. `SM012` warns if `register_settings` is overridden without adding `app.state.<module_lower>`.
- **Framework vs plugin coupling**: `SM009` is an error if `framework/*` directly imports from a plugin module. Framework code must not reach into `modules/`.
- **Zod schemas with translated messages** must be constructed inside a hook (`useT()`) — never at module scope, or they freeze against the first render's locale.
- **Locales**: ship `<package>/locales/<lang>.json` and declare in `ModuleBase.locale_dirs()` with the module's lowercase name as the namespace. `{"browse": {"title": "X"}}` flattens to `<namespace>.browse.title`. Pluralize with CLDR suffixes (`_one`, `_other`, ...).
- **Ty (type checker) false positives** from SQLModel: `unresolved-attribute`, `unsupported-operator`, `unknown-argument`, `no-matching-overload`, `invalid-argument-type` are all globally ignored in `pyproject.toml` because SQLModel declares fields with plain Python types while runtime instruments them as SQLAlchemy attributes. Do not re-enable these rules — real bugs surface in tests.

## Diagnostic codes

Meaningful codes when reading `make doctor` output: `SM001` missing meta (error), `SM003` orphan page / `SM004` phantom render (warn), `SM008` duplicate name (error), `SM009` framework→plugin import (error), `SM010` DB revision behind head (error), `SM011` module table not in migration history (warn), `SM013`–`SM016` locale issues. In production, errors fail boot.
Meaningful codes when reading `make doctor` output: `SM001` missing meta (error), `SM003` orphan page / `SM004` phantom render (warn), `SM007` module overrides no hooks (info), `SM008` duplicate name (error), `SM009` framework→plugin import (error), `SM010` DB revision behind head (error), `SM011` module table not in migration history (warn), `SM012` `register_settings` overridden but nothing on `app.state.<module>` (warn, fires at dev boot only), `SM013`–`SM016` locale issues. In production, errors fail boot.

## Tests & fixtures

Expand Down
2 changes: 1 addition & 1 deletion conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def _ensure_models_imported() -> list:
@lru_cache(maxsize=1)
def _alembic_head() -> str | None:
"""Cached head revision — cannot change within a pytest run."""
from simple_module_hosting._migrations import resolve_head_revision
from simple_module_hosting.migrations import resolve_head_revision

return resolve_head_revision()

Expand Down
30 changes: 17 additions & 13 deletions framework/hosting/simple_module_hosting/_phase_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,27 +129,31 @@ def mount_module_static_dirs(app: FastAPI, modules: list) -> None:
)


def check_settings_registration(app: FastAPI, modules: list) -> None:
def check_settings_registration(app: FastAPI, modules: list) -> list[Diagnostic]:
"""SM012: warn if a module overrides register_settings but added nothing to app.state.

New convention (2026-04-17): modules store their state at
``app.state.<module_lower>`` as a module-owned dataclass.
Must run after Phase 4 (register_settings) and therefore can't join the
Phase 2 diagnostics pass; returning a list lets the caller route it through
the same ``print_diagnostics`` sink.
"""
diagnostics: list[Diagnostic] = []
for mod in modules:
cls = type(mod)
if "register_settings" not in cls.__dict__:
continue
mod_prefix = mod.meta.name.lower()
if hasattr(app.state, mod_prefix):
continue
diag = Diagnostic(
level=DiagnosticLevel.WARNING,
code="SM012",
message="register_settings() was overridden but added nothing to app.state",
module_name=mod.meta.name,
suggestion=(
f"Store your module state on app.state "
f"(e.g., app.state.{mod_prefix} = {mod.meta.name}Services(...))"
),
diagnostics.append(
Diagnostic(
level=DiagnosticLevel.WARNING,
code="SM012",
message="register_settings() was overridden but added nothing to app.state",
module_name=mod.meta.name,
suggestion=(
f"Store your module state on app.state "
f"(e.g., app.state.{mod_prefix} = {mod.meta.name}Services(...))"
),
)
)
logger.warning("%s", diag)
return diagnostics
6 changes: 4 additions & 2 deletions framework/hosting/simple_module_hosting/app_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
from simple_module_db.session import init_db

from simple_module_hosting._inertia_setup import setup_inertia
from simple_module_hosting._migrations import check_migrations
from simple_module_hosting._phase_helpers import (
check_settings_registration,
install_middleware,
Expand All @@ -31,6 +30,7 @@
)
from simple_module_hosting.health import router as health_router
from simple_module_hosting.i18n_manifest import build_i18n_registry, emit_frontend_types
from simple_module_hosting.migrations import check_migrations
from simple_module_hosting.settings import Settings

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -164,7 +164,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
mod.register_settings(app)

if settings.is_development:
check_settings_registration(app, modules)
settings_diagnostics = check_settings_registration(app, modules)
if settings_diagnostics:
print_diagnostics(settings_diagnostics)

# ── Phase 5: Module registrations ──────────────────────
for mod in modules:
Expand Down
2 changes: 1 addition & 1 deletion modules/users/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ async def _setup_app_db(application) -> None:
"""Create all tables and stamp alembic version so migration check passes."""

from simple_module_db.base import all_module_bases
from simple_module_hosting._migrations import resolve_head_revision
from simple_module_hosting.migrations import resolve_head_revision
from sqlalchemy import text

head = resolve_head_revision()
Expand Down
Loading