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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Standard mixins in `simple_module_db.mixins`: `AuditMixin`, `SoftDeleteMixin` (b

## Diagnostic codes

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, `SM017` module ships `.tsx` pages but is missing `package.json`/`tsconfig.json` (warn), `SM018` Inertia `router.{post,patch,put,delete}()` in a page targets a JSON `/api/*` endpoint (warn — Inertia rejects non-Inertia responses), `SM019` module registers view routes (non-empty `view_prefix` + overrides `register_routes`) but never overrides `register_menu_items` (warn — pages exist but the sidebar can't surface them). 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, `SM017` module ships `.tsx` pages but is missing `package.json`/`tsconfig.json` (warn), `SM018` Inertia `router.{post,patch,put,delete}()` in a page targets a JSON `/api/*` endpoint (warn — Inertia rejects non-Inertia responses), `SM019` module registers view routes (non-empty `view_prefix` + overrides `register_routes`) but overrides neither `register_menu_items` nor `register_permissions` (warn — pages exist with no sidebar entry and no role-editor visibility; admins can't reach them through the UI). Modules whose views are sub-pages of another module typically register permissions to stay discoverable in the role editor without needing their own sidebar entry. In production, errors fail boot.

## Tests & fixtures

Expand Down
146 changes: 25 additions & 121 deletions framework/core/simple_module_core/diagnostics/_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,48 +2,20 @@

from __future__ import annotations

import ast
import importlib.util
from pathlib import Path
from typing import TYPE_CHECKING

from simple_module_core.diagnostics._coupling import check_framework_module_coupling
from simple_module_core.diagnostics._inertia_api import check_inertia_api_calls
from simple_module_core.diagnostics._js_workspace import check_js_workspace_files
from simple_module_core.diagnostics._pages import check_pages, find_render_calls
from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel

if TYPE_CHECKING:
from simple_module_core.module import ModuleBase


def _iter_render_components(tree: ast.Module) -> list[str]:
"""Yield ``X.render(component, ...)`` first-arg values, resolving Name constants."""
consts = {
s.targets[0].id: s.value.value
for s in tree.body
if isinstance(s, ast.Assign)
and len(s.targets) == 1
and isinstance(s.targets[0], ast.Name)
and isinstance(s.value, ast.Constant)
and isinstance(s.value.value, str)
}
found: list[str] = []
for node in ast.walk(tree):
if not (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "render"
and node.args
):
continue
first = node.args[0]
if isinstance(first, ast.Constant) and isinstance(first.value, str):
found.append(first.value)
elif isinstance(first, ast.Name) and first.id in consts:
found.append(consts[first.id])
return found


class ModuleDiagnostics:
"""Validates module structure and configuration."""

Expand All @@ -60,9 +32,8 @@ def run(self, modules: list[ModuleBase]) -> list[Diagnostic]:
for mod in modules:
src_dir = self._find_source_dir(mod)
if src_dir:
rendered_pages = self._find_render_calls(mod, src_dir)
diagnostics.extend(self._check_orphan_pages(mod, src_dir, rendered_pages))
diagnostics.extend(self._check_phantom_renders(mod, src_dir, rendered_pages))
rendered_pages = find_render_calls(mod, src_dir)
diagnostics.extend(check_pages(mod, src_dir, rendered_pages))
diagnostics.extend(check_js_workspace_files(mod, src_dir))
diagnostics.extend(check_inertia_api_calls(mod, src_dir))

Expand Down Expand Up @@ -146,35 +117,46 @@ def _check_empty_modules(self, modules: list[ModuleBase]) -> list[Diagnostic]:
return diags

def _check_views_without_menu(self, modules: list[ModuleBase]) -> list[Diagnostic]:
"""Warn when a module ships view routes but never registers a menu item.
"""Warn when a module ships view routes but is silently invisible.

A module that overrides ``register_routes`` and declares a non-empty
``view_prefix`` produces user-facing pages, but without
``register_menu_items`` those pages have no UI affordance — the
sidebar can't surface them.
``view_prefix`` produces user-facing pages. Without either
``register_menu_items`` (so admins can navigate to it from the sidebar)
or ``register_permissions`` (so admins can see it in the role-permission
editor), the module is silently invisible from the admin UI.

Modules that surface their views as sub-pages of another module (e.g.
deep-link edit forms reached from buttons elsewhere) typically register
permissions even when they don't add a sidebar entry — that suffices to
keep them discoverable through the role editor.
"""
diags: list[Diagnostic] = []
for mod in modules:
cls = type(mod)
meta = getattr(mod, "meta", None)
if meta is None or not getattr(meta, "view_prefix", ""):
continue
if "register_routes" not in cls.__dict__:
continue
if "register_menu_items" in cls.__dict__:
silently_invisible = (
meta is not None
and getattr(meta, "view_prefix", "")
and "register_routes" in cls.__dict__
and "register_menu_items" not in cls.__dict__
and "register_permissions" not in cls.__dict__
)
if not silently_invisible:
continue
diags.append(
Diagnostic(
level=DiagnosticLevel.WARNING,
code="SM019",
message=(
f"Module '{meta.name}' registers view routes "
f"(view_prefix={meta.view_prefix!r}) but no menu items"
f"(view_prefix={meta.view_prefix!r}) but no menu items "
"or permissions"
),
module_name=meta.name,
suggestion=(
"Override register_menu_items() to surface this module "
"in the sidebar, or clear view_prefix if it's API-only"
"in the sidebar, register_permissions() to surface it in "
"the role editor, or clear view_prefix if it's API-only"
),
)
)
Expand Down Expand Up @@ -204,84 +186,6 @@ def _find_package_dir(self, package_name: str) -> Path | None:
return Path(locations[0])
return None

def _collect_tsx_pages(self, pages_dir: Path) -> set[str]:
"""Collect .tsx page identifiers relative to pages_dir, without extension.

Nested files are represented with forward slashes so the set compares
directly against inertia.render("Module/Sub/Page") keys. Subdirectories
whose names start with a lowercase letter (e.g. ``components/``,
``hooks/``) are treated as helper folders — not Inertia page roots —
and skipped, matching the PascalCase convention Inertia uses.
"""
if not pages_dir.exists():
return set()
pages: set[str] = set()
for f in pages_dir.rglob("*.tsx"):
rel = f.relative_to(pages_dir)
if any(part[:1].islower() for part in rel.parts[:-1]):
continue
pages.add(rel.with_suffix("").as_posix())
return pages

def _check_orphan_pages(
self,
mod: ModuleBase,
src_dir: Path,
rendered_pages: set[str],
) -> list[Diagnostic]:
"""Find .tsx pages that aren't referenced by any inertia.render() call."""
pages_dir = src_dir / "pages"
tsx_pages = self._collect_tsx_pages(pages_dir)
orphans = tsx_pages - rendered_pages

return [
Diagnostic(
level=DiagnosticLevel.WARNING,
code="SM003",
message=f"Page '{name}.tsx' exists but no matching inertia.render() found",
module_name=mod.meta.name,
file=str(pages_dir / f"{name}.tsx"),
suggestion=f'Add inertia.render("{mod.meta.name}/{name}", ...) in a view endpoint',
)
for name in orphans
]

def _check_phantom_renders(
self,
mod: ModuleBase,
src_dir: Path,
rendered_pages: set[str],
) -> list[Diagnostic]:
"""Find inertia.render() calls that reference non-existent pages."""
pages_dir = src_dir / "pages"
tsx_pages = self._collect_tsx_pages(pages_dir)
phantoms = rendered_pages - tsx_pages

return [
Diagnostic(
level=DiagnosticLevel.WARNING,
code="SM004",
message=f'inertia.render("{mod.meta.name}/{name}") but no {name}.tsx exists',
module_name=mod.meta.name,
suggestion=f"Create {pages_dir / f'{name}.tsx'}",
)
for name in phantoms
]

def _find_render_calls(self, mod: ModuleBase, src_dir: Path) -> set[str]:
"""Find inertia.render("Module/Page") calls, resolving module-level string consts."""
rendered: set[str] = set()
prefix = f"{mod.meta.name}/"
for py_file in src_dir.rglob("*.py"):
try:
tree = ast.parse(py_file.read_text(), filename=str(py_file))
except SyntaxError:
continue
for component in _iter_render_components(tree):
if component.startswith(prefix):
rendered.add(component[len(prefix) :])
return rendered

def _find_source_dir(self, mod: ModuleBase) -> Path | None:
"""Locate the source directory for a module's package."""
pkg_name = type(mod).__module__.rsplit(".", 1)[0]
Expand Down
121 changes: 121 additions & 0 deletions framework/core/simple_module_core/diagnostics/_pages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""SM003/SM004 page-vs-render diagnostics for Inertia view modules."""

from __future__ import annotations

import ast
from pathlib import Path
from typing import TYPE_CHECKING

from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel

if TYPE_CHECKING:
from simple_module_core.module import ModuleBase


def _module_level_str_consts(tree: ast.Module) -> dict[str, str]:
"""Return ``{name: literal}`` for top-level ``NAME = "string"`` assignments."""
return {
s.targets[0].id: s.value.value
for s in tree.body
if isinstance(s, ast.Assign)
and len(s.targets) == 1
and isinstance(s.targets[0], ast.Name)
and isinstance(s.value, ast.Constant)
and isinstance(s.value.value, str)
}


def _iter_render_components(tree: ast.Module, consts: dict[str, str]) -> list[str]:
"""Yield ``X.render(component, ...)`` first-arg values, resolving Name references."""
found: list[str] = []
for node in ast.walk(tree):
if not (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "render"
and node.args
):
continue
first = node.args[0]
if isinstance(first, ast.Constant) and isinstance(first.value, str):
found.append(first.value)
elif isinstance(first, ast.Name) and first.id in consts:
found.append(consts[first.id])
return found


def collect_tsx_pages(pages_dir: Path) -> set[str]:
"""Collect .tsx page identifiers relative to pages_dir, without extension.

Nested files are represented with forward slashes so the set compares
directly against inertia.render("Module/Sub/Page") keys. Subdirectories
whose names start with a lowercase letter (``components/``, ``hooks/``,
...) are treated as helper folders, not Inertia page roots, matching the
PascalCase convention Inertia uses.
"""
pages: set[str] = set()
for f in pages_dir.rglob("*.tsx"):
rel = f.relative_to(pages_dir)
if any(part[:1].islower() for part in rel.parts[:-1]):
continue
pages.add(rel.with_suffix("").as_posix())
return pages


def find_render_calls(mod: ModuleBase, src_dir: Path) -> set[str]:
"""Find inertia.render("Module/Page") calls in this module's source tree.

Resolves ``inertia.render(NAME)`` where ``NAME`` is a string constant
defined at module scope in any sibling .py file (e.g. ``constants.py``).
Each .py file is parsed once: a first pass collects every module-level
string const, a second pass walks render calls against the merged map.
"""
trees: list[ast.Module] = []
for py_file in src_dir.rglob("*.py"):
try:
trees.append(ast.parse(py_file.read_text(), filename=str(py_file)))
except (SyntaxError, OSError):
continue
consts: dict[str, str] = {}
for tree in trees:
consts.update(_module_level_str_consts(tree))

prefix = f"{mod.meta.name}/"
rendered: set[str] = set()
for tree in trees:
for component in _iter_render_components(tree, consts):
if component.startswith(prefix):
rendered.add(component[len(prefix) :])
return rendered


def check_pages(
mod: ModuleBase,
src_dir: Path,
rendered_pages: set[str],
) -> list[Diagnostic]:
"""Diff .tsx pages against rendered_pages — emits SM003 + SM004 in one pass."""
pages_dir = src_dir / "pages"
tsx_pages = collect_tsx_pages(pages_dir)
diags: list[Diagnostic] = [
Diagnostic(
level=DiagnosticLevel.WARNING,
code="SM003",
message=f"Page '{name}.tsx' exists but no matching inertia.render() found",
module_name=mod.meta.name,
file=str(pages_dir / f"{name}.tsx"),
suggestion=f'Add inertia.render("{mod.meta.name}/{name}", ...) in a view endpoint',
)
for name in tsx_pages - rendered_pages
]
diags.extend(
Diagnostic(
level=DiagnosticLevel.WARNING,
code="SM004",
message=f'inertia.render("{mod.meta.name}/{name}") but no {name}.tsx exists',
module_name=mod.meta.name,
suggestion=f"Create {pages_dir / f'{name}.tsx'}",
)
for name in rendered_pages - tsx_pages
)
return diags
Loading
Loading