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
13 changes: 13 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,18 @@ jobs:
- run: make gen-pages
- run: make ci-js-typecheck

file-size-check:
name: File size (300-line cap)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v8.0.0
with:
enable-cache: true
cache-dependency-glob: ${{ env.UV_CACHE_GLOB }}
- run: make install-py
- run: make ci-check-file-size

# Single required status check for branch protection.
# Protect `main` with this one check and every leaf job is required transitively.
pr-checks:
Expand All @@ -104,6 +116,7 @@ jobs:
- python-tests
- js-lint
- js-typecheck
- file-size-check
if: always()
steps:
- if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
Expand Down
9 changes: 7 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: install install-py install-js dev dev-api dev-ui build test lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck
.PHONY: install install-py install-js dev dev-api dev-ui build test lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size

# Install
install:
Expand Down Expand Up @@ -35,7 +35,7 @@ build:
test:
uv run pytest

lint: ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck
lint: ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size

# Kept granular so pr.yml can run them in parallel.
ci-python-lint:
Expand All @@ -51,6 +51,11 @@ ci-js-lint:
ci-js-typecheck:
npx tsc --noEmit -p host/client_app/tsconfig.json

# Enforce a max of 300 lines per .py/.ts/.tsx file.
# Exempts vendored shadcn components under packages/ui/src/components/ui/**.
ci-check-file-size:
uv run python scripts/check_file_size.py

# Diagnostics
doctor:
uv run python -m simple_module_core
Expand Down
22 changes: 22 additions & 0 deletions framework/core/simple_module_core/diagnostics/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Module diagnostics — validates structure and patterns at startup or via CLI.

This is the public surface re-exported from the submodules below.
Callers import from ``simple_module_core.diagnostics`` and should not
need to reach into ``._module`` or ``._migration`` directly.
"""

from __future__ import annotations

from simple_module_core.diagnostics._migration import MigrationDiagnostics
from simple_module_core.diagnostics._module import ModuleDiagnostics
from simple_module_core.diagnostics._runner import print_diagnostics, run_diagnostics
from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel

__all__ = [
"Diagnostic",
"DiagnosticLevel",
"MigrationDiagnostics",
"ModuleDiagnostics",
"print_diagnostics",
"run_diagnostics",
]
45 changes: 45 additions & 0 deletions framework/core/simple_module_core/diagnostics/_migration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Alembic migration-state diagnostics (SM010, SM011)."""

from __future__ import annotations

from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel


class MigrationDiagnostics:
"""Validates database migration state."""

def check_revision_mismatch(
self,
current_revision: str | None,
head_revision: str | None,
) -> list[Diagnostic]:
"""SM010: Error if database is not at the migration head."""
if current_revision == head_revision:
return []
return [
Diagnostic(
level=DiagnosticLevel.ERROR,
code="SM010",
message=(f"Database at revision {current_revision!r}, expected {head_revision!r}"),
module_name="migrations",
suggestion="Run: make migrate",
)
]

def check_table_coverage(
self,
module_tables: set[str],
migrated_tables: set[str],
) -> list[Diagnostic]:
"""SM011: Warning if module tables are missing from migration history."""
missing = module_tables - migrated_tables
return [
Diagnostic(
level=DiagnosticLevel.WARNING,
code="SM011",
message=f"Table '{table}' declared in models but not found in migration history",
module_name="migrations",
suggestion=f'Run: make migration msg="add {table}"',
)
for table in sorted(missing)
]
Original file line number Diff line number Diff line change
@@ -1,48 +1,17 @@
"""Module diagnostics — validates structure and patterns at startup or via CLI."""
"""Structural diagnostics that validate discovered modules against invariants."""

from __future__ import annotations

import ast
import importlib.util
import logging
import sys
from dataclasses import dataclass
from enum import StrEnum
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

logger = logging.getLogger(__name__)


class DiagnosticLevel(StrEnum):
ERROR = "error"
WARNING = "warning"
INFO = "info"


@dataclass
class Diagnostic:
"""A single diagnostic finding."""

level: DiagnosticLevel
code: str
message: str
module_name: str
file: str | None = None
suggestion: str | None = None

def __str__(self) -> str:
prefix = {"error": "\u2717", "warning": "\u26a0", "info": "\u2139"}[self.level]
parts = [f"{prefix} {self.code} [{self.level.upper()}] {self.module_name}: {self.message}"]
if self.file:
parts.append(f" \u21b3 {self.file}")
if self.suggestion:
parts.append(f" \u21b3 Suggestion: {self.suggestion}")
return "\n".join(parts)


class ModuleDiagnostics:
"""Validates module structure and configuration."""
Expand Down Expand Up @@ -162,7 +131,6 @@ def _check_framework_module_coupling(self, modules: list[ModuleBase]) -> list[Di
discovered module's package (e.g. ``auth``, ``products``).
All interaction should go through the ``ModuleBase`` lifecycle hooks.
"""
# Collect top-level package names for every discovered module.
module_packages: dict[str, str] = {} # package -> module name
for mod in modules:
top_pkg = type(mod).__module__.split(".")[0]
Expand All @@ -171,7 +139,6 @@ def _check_framework_module_coupling(self, modules: list[ModuleBase]) -> list[Di
if not module_packages:
return []

# Locate framework package source directories.
framework_dirs: list[tuple[str, Path]] = []
for fw_pkg in self.FRAMEWORK_PACKAGES:
fw_dir = self._find_package_dir(fw_pkg)
Expand Down Expand Up @@ -309,90 +276,3 @@ 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]
return self._find_package_dir(pkg_name)


class MigrationDiagnostics:
"""Validates database migration state."""

def check_revision_mismatch(
self,
current_revision: str | None,
head_revision: str | None,
) -> list[Diagnostic]:
"""SM010: Error if database is not at the migration head."""
if current_revision == head_revision:
return []
return [
Diagnostic(
level=DiagnosticLevel.ERROR,
code="SM010",
message=(f"Database at revision {current_revision!r}, expected {head_revision!r}"),
module_name="migrations",
suggestion="Run: make migrate",
)
]

def check_table_coverage(
self,
module_tables: set[str],
migrated_tables: set[str],
) -> list[Diagnostic]:
"""SM011: Warning if module tables are missing from migration history."""
missing = module_tables - migrated_tables
return [
Diagnostic(
level=DiagnosticLevel.WARNING,
code="SM011",
message=f"Table '{table}' declared in models but not found in migration history",
module_name="migrations",
suggestion=f'Run: make migration msg="add {table}"',
)
for table in sorted(missing)
]


def run_diagnostics(
modules: list[ModuleBase],
*,
migration_state: dict | None = None,
module_tables: set[str] | None = None,
migrated_tables: set[str] | None = None,
) -> list[Diagnostic]:
"""Convenience function to run all diagnostics.

When ``migration_state`` is provided, also runs migration diagnostics.
"""
diagnostics = ModuleDiagnostics().run(modules)

if migration_state is not None:
migration_diag = MigrationDiagnostics()
diagnostics.extend(
migration_diag.check_revision_mismatch(
current_revision=migration_state.get("current_revision"),
head_revision=migration_state.get("head_revision"),
)
)
if module_tables is not None and migrated_tables is not None:
diagnostics.extend(migration_diag.check_table_coverage(module_tables, migrated_tables))

return diagnostics


def print_diagnostics(diagnostics: list[Diagnostic]) -> None:
"""Pretty-print diagnostics to stderr."""
if not diagnostics:
logger.info("\u2713 No module diagnostics issues found")
return

errors = [d for d in diagnostics if d.level == DiagnosticLevel.ERROR]
warnings = [d for d in diagnostics if d.level == DiagnosticLevel.WARNING]
infos = [d for d in diagnostics if d.level == DiagnosticLevel.INFO]

for d in diagnostics:
print(str(d), file=sys.stderr)
print(file=sys.stderr)

print(
f"Results: {len(errors)} error(s), {len(warnings)} warning(s), {len(infos)} info",
file=sys.stderr,
)
63 changes: 63 additions & 0 deletions framework/core/simple_module_core/diagnostics/_runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Entry points that assemble and print diagnostic output."""

from __future__ import annotations

import logging
import sys
from typing import TYPE_CHECKING

from simple_module_core.diagnostics._migration import MigrationDiagnostics
from simple_module_core.diagnostics._module import ModuleDiagnostics
from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel

if TYPE_CHECKING:
from simple_module_core.module import ModuleBase

logger = logging.getLogger(__name__)


def run_diagnostics(
modules: list[ModuleBase],
*,
migration_state: dict | None = None,
module_tables: set[str] | None = None,
migrated_tables: set[str] | None = None,
) -> list[Diagnostic]:
"""Convenience function to run all diagnostics.

When ``migration_state`` is provided, also runs migration diagnostics.
"""
diagnostics = ModuleDiagnostics().run(modules)

if migration_state is not None:
migration_diag = MigrationDiagnostics()
diagnostics.extend(
migration_diag.check_revision_mismatch(
current_revision=migration_state.get("current_revision"),
head_revision=migration_state.get("head_revision"),
)
)
if module_tables is not None and migrated_tables is not None:
diagnostics.extend(migration_diag.check_table_coverage(module_tables, migrated_tables))

return diagnostics


def print_diagnostics(diagnostics: list[Diagnostic]) -> None:
"""Pretty-print diagnostics to stderr."""
if not diagnostics:
logger.info("\u2713 No module diagnostics issues found")
return

errors = [d for d in diagnostics if d.level == DiagnosticLevel.ERROR]
warnings = [d for d in diagnostics if d.level == DiagnosticLevel.WARNING]
infos = [d for d in diagnostics if d.level == DiagnosticLevel.INFO]

for d in diagnostics:
print(str(d), file=sys.stderr)
print(file=sys.stderr)

print(
f"Results: {len(errors)} error(s), {len(warnings)} warning(s), {len(infos)} info",
file=sys.stderr,
)
33 changes: 33 additions & 0 deletions framework/core/simple_module_core/diagnostics/_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Core diagnostic types: level enum and finding dataclass."""

from __future__ import annotations

from dataclasses import dataclass
from enum import StrEnum


class DiagnosticLevel(StrEnum):
ERROR = "error"
WARNING = "warning"
INFO = "info"


@dataclass
class Diagnostic:
"""A single diagnostic finding."""

level: DiagnosticLevel
code: str
message: str
module_name: str
file: str | None = None
suggestion: str | None = None

def __str__(self) -> str:
prefix = {"error": "\u2717", "warning": "\u26a0", "info": "\u2139"}[self.level]
parts = [f"{prefix} {self.code} [{self.level.upper()}] {self.module_name}: {self.message}"]
if self.file:
parts.append(f" \u21b3 {self.file}")
if self.suggestion:
parts.append(f" \u21b3 Suggestion: {self.suggestion}")
return "\n".join(parts)
Loading