simple-module-core 0.0.1__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.
- simple_module_core/__init__.py +76 -0
- simple_module_core/__main__.py +96 -0
- simple_module_core/diagnostics/__init__.py +24 -0
- simple_module_core/diagnostics/_coupling.py +81 -0
- simple_module_core/diagnostics/_i18n.py +121 -0
- simple_module_core/diagnostics/_inertia_api.py +73 -0
- simple_module_core/diagnostics/_js_workspace.py +35 -0
- simple_module_core/diagnostics/_migration.py +45 -0
- simple_module_core/diagnostics/_module.py +252 -0
- simple_module_core/diagnostics/_runner.py +81 -0
- simple_module_core/diagnostics/_types.py +33 -0
- simple_module_core/discovery.py +195 -0
- simple_module_core/dotenv.py +38 -0
- simple_module_core/environments.py +15 -0
- simple_module_core/events.py +91 -0
- simple_module_core/exceptions.py +56 -0
- simple_module_core/feature_flags.py +187 -0
- simple_module_core/health.py +46 -0
- simple_module_core/i18n.py +258 -0
- simple_module_core/menu.py +89 -0
- simple_module_core/module.py +179 -0
- simple_module_core/permissions.py +121 -0
- simple_module_core/py.typed +0 -0
- simple_module_core/services.py +45 -0
- simple_module_core/versioning.py +67 -0
- simple_module_core-0.0.1.dist-info/METADATA +85 -0
- simple_module_core-0.0.1.dist-info/RECORD +29 -0
- simple_module_core-0.0.1.dist-info/WHEEL +4 -0
- simple_module_core-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""SimpleModule Core - Module system, menu, permissions, events, and diagnostics."""
|
|
2
|
+
|
|
3
|
+
from simple_module_core.diagnostics import (
|
|
4
|
+
DiagnosticLevel,
|
|
5
|
+
MigrationDiagnostics,
|
|
6
|
+
print_diagnostics,
|
|
7
|
+
run_diagnostics,
|
|
8
|
+
)
|
|
9
|
+
from simple_module_core.discovery import (
|
|
10
|
+
discover_modules,
|
|
11
|
+
get_module_package_name,
|
|
12
|
+
topological_sort,
|
|
13
|
+
)
|
|
14
|
+
from simple_module_core.events import Event, EventBus
|
|
15
|
+
from simple_module_core.exceptions import (
|
|
16
|
+
CircularDependencyError,
|
|
17
|
+
FrameworkVersionError,
|
|
18
|
+
InvalidModuleError,
|
|
19
|
+
ModuleError,
|
|
20
|
+
NotFoundError,
|
|
21
|
+
ValidationError,
|
|
22
|
+
)
|
|
23
|
+
from simple_module_core.feature_flags import (
|
|
24
|
+
FeatureFlagDefinition,
|
|
25
|
+
FeatureFlagRegistry,
|
|
26
|
+
feature_flag,
|
|
27
|
+
flag_enabled,
|
|
28
|
+
is_flag_enabled,
|
|
29
|
+
require_flag,
|
|
30
|
+
)
|
|
31
|
+
from simple_module_core.health import HealthCheck, HealthCheckResult, HealthRegistry, HealthStatus
|
|
32
|
+
from simple_module_core.i18n import I18nRegistry, Translator
|
|
33
|
+
from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
|
|
34
|
+
from simple_module_core.module import ModuleBase, ModuleMeta
|
|
35
|
+
from simple_module_core.permissions import PermissionRegistry
|
|
36
|
+
from simple_module_core.services import Services
|
|
37
|
+
from simple_module_core.versioning import FRAMEWORK_API_VERSION, check_framework_compatibility
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"FRAMEWORK_API_VERSION",
|
|
41
|
+
"CircularDependencyError",
|
|
42
|
+
"DiagnosticLevel",
|
|
43
|
+
"Event",
|
|
44
|
+
"EventBus",
|
|
45
|
+
"FeatureFlagDefinition",
|
|
46
|
+
"FeatureFlagRegistry",
|
|
47
|
+
"FrameworkVersionError",
|
|
48
|
+
"HealthCheck",
|
|
49
|
+
"HealthCheckResult",
|
|
50
|
+
"HealthRegistry",
|
|
51
|
+
"HealthStatus",
|
|
52
|
+
"I18nRegistry",
|
|
53
|
+
"InvalidModuleError",
|
|
54
|
+
"MenuItem",
|
|
55
|
+
"MenuRegistry",
|
|
56
|
+
"MenuSection",
|
|
57
|
+
"MigrationDiagnostics",
|
|
58
|
+
"ModuleBase",
|
|
59
|
+
"ModuleError",
|
|
60
|
+
"ModuleMeta",
|
|
61
|
+
"NotFoundError",
|
|
62
|
+
"PermissionRegistry",
|
|
63
|
+
"Services",
|
|
64
|
+
"Translator",
|
|
65
|
+
"ValidationError",
|
|
66
|
+
"check_framework_compatibility",
|
|
67
|
+
"discover_modules",
|
|
68
|
+
"feature_flag",
|
|
69
|
+
"flag_enabled",
|
|
70
|
+
"get_module_package_name",
|
|
71
|
+
"is_flag_enabled",
|
|
72
|
+
"print_diagnostics",
|
|
73
|
+
"require_flag",
|
|
74
|
+
"run_diagnostics",
|
|
75
|
+
"topological_sort",
|
|
76
|
+
]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Run module diagnostics from the command line.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
python -m simple_module_core # discover modules, run diagnostics
|
|
6
|
+
make doctor # same thing, wrapped
|
|
7
|
+
|
|
8
|
+
Exits with status 1 if any ERROR-level diagnostics are reported.
|
|
9
|
+
|
|
10
|
+
i18n checks are included when ``SM_I18N_SUPPORTED_LOCALES`` is set in env
|
|
11
|
+
(or ``.env``). Host-level ``host/locales/`` and shared ``packages/ui/locales/``
|
|
12
|
+
are picked up relative to ``SM_PROJECT_ROOT`` (or the current working dir).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from simple_module_core.diagnostics import (
|
|
23
|
+
DiagnosticLevel,
|
|
24
|
+
print_diagnostics,
|
|
25
|
+
run_diagnostics,
|
|
26
|
+
)
|
|
27
|
+
from simple_module_core.discovery import discover_modules, topological_sort
|
|
28
|
+
from simple_module_core.dotenv import parse_dotenv
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_i18n_settings_from_env() -> tuple[list[str], str] | tuple[None, None]:
|
|
32
|
+
"""Return ``(supported_locales, default_locale)`` or ``(None, None)`` if unset.
|
|
33
|
+
|
|
34
|
+
Reads env vars directly to avoid a dependency on ``simple_module_hosting``.
|
|
35
|
+
Honors ``.env`` by merging it into ``os.environ`` if present (pydantic-
|
|
36
|
+
settings isn't imported here).
|
|
37
|
+
"""
|
|
38
|
+
for key, value in parse_dotenv().items():
|
|
39
|
+
os.environ.setdefault(key, value)
|
|
40
|
+
|
|
41
|
+
supported_raw = os.environ.get("SM_I18N_SUPPORTED_LOCALES")
|
|
42
|
+
if not supported_raw:
|
|
43
|
+
return None, None
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
supported = json.loads(supported_raw)
|
|
47
|
+
except json.JSONDecodeError:
|
|
48
|
+
# Also accept comma-separated (e.g. "en,es,de").
|
|
49
|
+
supported = [s.strip() for s in supported_raw.split(",") if s.strip()]
|
|
50
|
+
|
|
51
|
+
if not isinstance(supported, list) or not supported:
|
|
52
|
+
return None, None
|
|
53
|
+
|
|
54
|
+
default = os.environ.get("SM_I18N_DEFAULT_LOCALE", "en")
|
|
55
|
+
return supported, default
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _discover_extra_locale_sources() -> list[tuple[str, str, Path]]:
|
|
59
|
+
"""Return ``[(reporter, namespace, path), ...]`` for host + ui locale dirs."""
|
|
60
|
+
root = Path(os.environ.get("SM_PROJECT_ROOT") or Path.cwd())
|
|
61
|
+
out: list[tuple[str, str, Path]] = []
|
|
62
|
+
host_locales = root / "host" / "locales"
|
|
63
|
+
if host_locales.is_dir():
|
|
64
|
+
out.append(("host", "host", host_locales))
|
|
65
|
+
ui_locales = root / "packages" / "ui" / "locales"
|
|
66
|
+
if ui_locales.is_dir():
|
|
67
|
+
out.append(("packages/ui", "ui", ui_locales))
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main() -> int:
|
|
72
|
+
modules = discover_modules()
|
|
73
|
+
if not modules:
|
|
74
|
+
print("No modules discovered. Is the project installed (`uv sync --all-packages`)?")
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
# Topological sort surfaces CircularDependencyError early.
|
|
78
|
+
modules = topological_sort(modules)
|
|
79
|
+
|
|
80
|
+
supported, default = _load_i18n_settings_from_env()
|
|
81
|
+
extra = _discover_extra_locale_sources()
|
|
82
|
+
|
|
83
|
+
diagnostics = run_diagnostics(
|
|
84
|
+
modules,
|
|
85
|
+
i18n_supported_locales=supported,
|
|
86
|
+
i18n_default_locale=default,
|
|
87
|
+
i18n_extra_sources=extra,
|
|
88
|
+
)
|
|
89
|
+
print_diagnostics(diagnostics)
|
|
90
|
+
|
|
91
|
+
errors = [d for d in diagnostics if d.level == DiagnosticLevel.ERROR]
|
|
92
|
+
return 1 if errors else 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
sys.exit(main())
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Module diagnostics — validates structure and patterns at startup or via CLI.
|
|
2
|
+
|
|
3
|
+
This is the public surface re-exported from the submodules below.
|
|
4
|
+
Callers import from ``simple_module_core.diagnostics`` and should not
|
|
5
|
+
need to reach into ``._module`` or ``._migration`` directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from simple_module_core.diagnostics._i18n import I18nDiagnostics
|
|
11
|
+
from simple_module_core.diagnostics._migration import MigrationDiagnostics
|
|
12
|
+
from simple_module_core.diagnostics._module import ModuleDiagnostics
|
|
13
|
+
from simple_module_core.diagnostics._runner import print_diagnostics, run_diagnostics
|
|
14
|
+
from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Diagnostic",
|
|
18
|
+
"DiagnosticLevel",
|
|
19
|
+
"I18nDiagnostics",
|
|
20
|
+
"MigrationDiagnostics",
|
|
21
|
+
"ModuleDiagnostics",
|
|
22
|
+
"print_diagnostics",
|
|
23
|
+
"run_diagnostics",
|
|
24
|
+
]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""SM009: detect framework packages that import from plugin module packages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import importlib.util
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from simple_module_core.module import ModuleBase
|
|
14
|
+
|
|
15
|
+
FRAMEWORK_PACKAGES = ("simple_module_core", "simple_module_hosting", "simple_module_db")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _find_package_dir(package_name: str) -> Path | None:
|
|
19
|
+
spec = importlib.util.find_spec(package_name)
|
|
20
|
+
if spec and spec.submodule_search_locations:
|
|
21
|
+
locations = list(spec.submodule_search_locations)
|
|
22
|
+
if locations:
|
|
23
|
+
return Path(locations[0])
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _imported_plugin_pkg(node: ast.AST, module_packages: dict[str, str]) -> str | None:
|
|
28
|
+
if isinstance(node, ast.Import):
|
|
29
|
+
for alias in node.names:
|
|
30
|
+
top = alias.name.split(".")[0]
|
|
31
|
+
if top in module_packages:
|
|
32
|
+
return top
|
|
33
|
+
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
34
|
+
top = node.module.split(".")[0]
|
|
35
|
+
if top in module_packages:
|
|
36
|
+
return top
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def check_framework_module_coupling(modules: list[ModuleBase]) -> list[Diagnostic]:
|
|
41
|
+
"""The framework (core, hosting, db) must never import from a plugin module."""
|
|
42
|
+
module_packages: dict[str, str] = {
|
|
43
|
+
type(mod).__module__.split(".")[0]: mod.meta.name for mod in modules
|
|
44
|
+
}
|
|
45
|
+
if not module_packages:
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
framework_dirs: list[tuple[str, Path]] = []
|
|
49
|
+
for fw_pkg in FRAMEWORK_PACKAGES:
|
|
50
|
+
fw_dir = _find_package_dir(fw_pkg)
|
|
51
|
+
if fw_dir:
|
|
52
|
+
framework_dirs.append((fw_pkg, fw_dir))
|
|
53
|
+
|
|
54
|
+
diags: list[Diagnostic] = []
|
|
55
|
+
for fw_pkg, fw_dir in framework_dirs:
|
|
56
|
+
for py_file in fw_dir.rglob("*.py"):
|
|
57
|
+
try:
|
|
58
|
+
tree = ast.parse(py_file.read_text(), filename=str(py_file))
|
|
59
|
+
except SyntaxError:
|
|
60
|
+
continue
|
|
61
|
+
for node in ast.walk(tree):
|
|
62
|
+
imported_pkg = _imported_plugin_pkg(node, module_packages)
|
|
63
|
+
if imported_pkg:
|
|
64
|
+
diags.append(
|
|
65
|
+
Diagnostic(
|
|
66
|
+
level=DiagnosticLevel.ERROR,
|
|
67
|
+
code="SM009",
|
|
68
|
+
message=(
|
|
69
|
+
f"Framework package '{fw_pkg}' directly imports "
|
|
70
|
+
f"from module package '{imported_pkg}'"
|
|
71
|
+
),
|
|
72
|
+
module_name=module_packages[imported_pkg],
|
|
73
|
+
file=str(py_file),
|
|
74
|
+
suggestion=(
|
|
75
|
+
"Use a ModuleBase lifecycle hook "
|
|
76
|
+
"(register_middleware, register_routes, etc.) "
|
|
77
|
+
"instead of importing module code from the framework"
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
return diags
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Diagnostics that validate i18n locale file coverage and consistency."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
|
|
10
|
+
from simple_module_core.i18n import flatten_messages
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from simple_module_core.module import ModuleBase
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class I18nDiagnostics:
|
|
17
|
+
"""Validates locale file coverage per module.
|
|
18
|
+
|
|
19
|
+
Codes:
|
|
20
|
+
- SM013: missing locale file for a supported locale.
|
|
21
|
+
- SM014: non-default locale is missing keys present in the default.
|
|
22
|
+
- SM015: non-default locale has keys not present in the default.
|
|
23
|
+
- SM016: locale JSON fails to parse or has non-string leaves.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
supported_locales: list[str],
|
|
29
|
+
default_locale: str,
|
|
30
|
+
extra_sources: list[tuple[str, str, Path]] | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Build the diagnostic.
|
|
33
|
+
|
|
34
|
+
``extra_sources`` is an optional list of ``(reporter_name, namespace,
|
|
35
|
+
locale_dir)`` triples for locale directories that aren't owned by any
|
|
36
|
+
``ModuleBase`` instance — notably the host's ``host/locales/`` and
|
|
37
|
+
the shared ``packages/ui/locales/``. ``reporter_name`` is used as the
|
|
38
|
+
``module_name`` field on findings for display purposes.
|
|
39
|
+
"""
|
|
40
|
+
self.supported_locales = list(supported_locales)
|
|
41
|
+
self.default_locale = default_locale
|
|
42
|
+
self.extra_sources = list(extra_sources or [])
|
|
43
|
+
|
|
44
|
+
def run(self, modules: list[ModuleBase]) -> list[Diagnostic]:
|
|
45
|
+
findings: list[Diagnostic] = []
|
|
46
|
+
for mod in modules:
|
|
47
|
+
for namespace, locale_dir in mod.locale_dirs().items():
|
|
48
|
+
findings.extend(self._check_namespace(mod.meta.name, namespace, Path(locale_dir)))
|
|
49
|
+
for reporter_name, namespace, locale_dir in self.extra_sources:
|
|
50
|
+
findings.extend(self._check_namespace(reporter_name, namespace, Path(locale_dir)))
|
|
51
|
+
return findings
|
|
52
|
+
|
|
53
|
+
def _check_namespace(
|
|
54
|
+
self, module_name: str, namespace: str, locale_dir: Path
|
|
55
|
+
) -> list[Diagnostic]:
|
|
56
|
+
findings: list[Diagnostic] = []
|
|
57
|
+
per_locale_keys: dict[str, set[str]] = {}
|
|
58
|
+
|
|
59
|
+
for locale in self.supported_locales:
|
|
60
|
+
path = locale_dir / f"{locale}.json"
|
|
61
|
+
if not path.is_file():
|
|
62
|
+
findings.append(
|
|
63
|
+
Diagnostic(
|
|
64
|
+
level=DiagnosticLevel.WARNING,
|
|
65
|
+
code="SM013",
|
|
66
|
+
message=(f"Missing locale file {locale}.json for namespace '{namespace}'"),
|
|
67
|
+
module_name=module_name,
|
|
68
|
+
file=str(path),
|
|
69
|
+
suggestion=f"Create {path} (even if empty: '{{}}')",
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
continue
|
|
73
|
+
try:
|
|
74
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
75
|
+
if not isinstance(raw, dict):
|
|
76
|
+
raise ValueError("top-level JSON must be an object")
|
|
77
|
+
flat = flatten_messages(raw)
|
|
78
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
79
|
+
findings.append(
|
|
80
|
+
Diagnostic(
|
|
81
|
+
level=DiagnosticLevel.ERROR,
|
|
82
|
+
code="SM016",
|
|
83
|
+
message=f"Invalid locale JSON in {path}: {exc}",
|
|
84
|
+
module_name=module_name,
|
|
85
|
+
file=str(path),
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
continue
|
|
89
|
+
per_locale_keys[locale] = set(flat.keys())
|
|
90
|
+
|
|
91
|
+
default_keys = per_locale_keys.get(self.default_locale, set())
|
|
92
|
+
for locale, keys in per_locale_keys.items():
|
|
93
|
+
if locale == self.default_locale:
|
|
94
|
+
continue
|
|
95
|
+
missing = default_keys - keys
|
|
96
|
+
extra = keys - default_keys
|
|
97
|
+
if missing:
|
|
98
|
+
findings.append(
|
|
99
|
+
Diagnostic(
|
|
100
|
+
level=DiagnosticLevel.WARNING,
|
|
101
|
+
code="SM014",
|
|
102
|
+
message=(
|
|
103
|
+
f"Locale '{locale}' in namespace '{namespace}' is missing keys: "
|
|
104
|
+
f"{', '.join(sorted(missing))}"
|
|
105
|
+
),
|
|
106
|
+
module_name=module_name,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
if extra:
|
|
110
|
+
findings.append(
|
|
111
|
+
Diagnostic(
|
|
112
|
+
level=DiagnosticLevel.WARNING,
|
|
113
|
+
code="SM015",
|
|
114
|
+
message=(
|
|
115
|
+
f"Locale '{locale}' in namespace '{namespace}' has keys not in "
|
|
116
|
+
f"default: {', '.join(sorted(extra))}"
|
|
117
|
+
),
|
|
118
|
+
module_name=module_name,
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
return findings
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""SM018: flag Inertia ``router.{post,patch,put,delete}('/api/...')`` calls.
|
|
2
|
+
|
|
3
|
+
Inertia's client-side router (``@inertiajs/react``'s ``router.*``) expects
|
|
4
|
+
Inertia-shaped responses or redirects — a raw JSON body from the REST
|
|
5
|
+
API layer triggers ``All Inertia requests must receive a valid Inertia
|
|
6
|
+
response``. The fix is to point the call at a module **view** endpoint
|
|
7
|
+
that returns ``RedirectResponse(..., status_code=303)``.
|
|
8
|
+
|
|
9
|
+
This check regex-scans each module's ``pages/**/*.tsx`` files for the
|
|
10
|
+
anti-pattern. It is textual (not AST) because a full TSX parser in
|
|
11
|
+
Python is not worth the dependency — the pattern is unambiguous enough
|
|
12
|
+
that string matching catches it reliably.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from simple_module_core.module import ModuleBase
|
|
25
|
+
|
|
26
|
+
# Matches calls like:
|
|
27
|
+
# router.post('/api/datasets/', ...)
|
|
28
|
+
# router.patch(`/api/datasets/${id}`, ...)
|
|
29
|
+
# router.delete("/api/datasets/" + id)
|
|
30
|
+
# The quote character is captured so we only match string-literal URLs
|
|
31
|
+
# (backtick, single, or double) — variable URLs don't count.
|
|
32
|
+
_ROUTER_API_CALL = re.compile(
|
|
33
|
+
r"router\.(?P<method>post|patch|put|delete)\s*\(\s*[`'\"]/api/",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def check_inertia_api_calls(mod: ModuleBase, src_dir: Path) -> list[Diagnostic]:
|
|
38
|
+
"""Warn when a page uses Inertia's router against a JSON API endpoint."""
|
|
39
|
+
pages_dir = src_dir / "pages"
|
|
40
|
+
if not pages_dir.exists():
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
diags: list[Diagnostic] = []
|
|
44
|
+
for tsx in sorted(pages_dir.rglob("*.tsx")):
|
|
45
|
+
try:
|
|
46
|
+
source = tsx.read_text()
|
|
47
|
+
except OSError:
|
|
48
|
+
continue
|
|
49
|
+
for lineno, line in enumerate(source.splitlines(), start=1):
|
|
50
|
+
match = _ROUTER_API_CALL.search(line)
|
|
51
|
+
if not match:
|
|
52
|
+
continue
|
|
53
|
+
method = match.group("method").upper()
|
|
54
|
+
diags.append(
|
|
55
|
+
Diagnostic(
|
|
56
|
+
level=DiagnosticLevel.WARNING,
|
|
57
|
+
code="SM018",
|
|
58
|
+
message=(
|
|
59
|
+
f"router.{method.lower()}() targets a JSON API endpoint — "
|
|
60
|
+
"Inertia will reject the response"
|
|
61
|
+
),
|
|
62
|
+
module_name=mod.meta.name,
|
|
63
|
+
file=f"{tsx}:{lineno}",
|
|
64
|
+
suggestion=(
|
|
65
|
+
"Point the call at a view endpoint (e.g. '/"
|
|
66
|
+
f"{mod.meta.name.lower()}/...') that returns "
|
|
67
|
+
"RedirectResponse(..., status_code=303). Or, if you "
|
|
68
|
+
"really need the JSON payload, use fetch() instead of "
|
|
69
|
+
"Inertia's router."
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
return diags
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""SM017: warn modules shipping .tsx pages but missing npm workspace files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from simple_module_core.module import ModuleBase
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def check_js_workspace_files(mod: ModuleBase, src_dir: Path) -> list[Diagnostic]:
|
|
15
|
+
"""Warn when a module ships .tsx pages but is missing npm workspace files."""
|
|
16
|
+
pages_dir = src_dir / "pages"
|
|
17
|
+
if not pages_dir.exists() or not any(pages_dir.rglob("*.tsx")):
|
|
18
|
+
return []
|
|
19
|
+
module_dir = src_dir.parent
|
|
20
|
+
return [
|
|
21
|
+
Diagnostic(
|
|
22
|
+
level=DiagnosticLevel.WARNING,
|
|
23
|
+
code="SM017",
|
|
24
|
+
message=f"Module ships pages/*.tsx but has no {fn}",
|
|
25
|
+
module_name=mod.meta.name,
|
|
26
|
+
file=str(module_dir / fn),
|
|
27
|
+
suggestion=(
|
|
28
|
+
f"Create {module_dir / fn} — without it npm won't treat the "
|
|
29
|
+
"module as a workspace member and Vite may fail to resolve "
|
|
30
|
+
"@simple-module-py/ui subpath imports"
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
for fn in ("package.json", "tsconfig.json")
|
|
34
|
+
if not (module_dir / fn).exists()
|
|
35
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Alembic migration-state diagnostics (SM010, SM011)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MigrationDiagnostics:
|
|
9
|
+
"""Validates database migration state."""
|
|
10
|
+
|
|
11
|
+
def check_revision_mismatch(
|
|
12
|
+
self,
|
|
13
|
+
current_revision: str | None,
|
|
14
|
+
head_revision: str | None,
|
|
15
|
+
) -> list[Diagnostic]:
|
|
16
|
+
"""SM010: Error if database is not at the migration head."""
|
|
17
|
+
if current_revision == head_revision:
|
|
18
|
+
return []
|
|
19
|
+
return [
|
|
20
|
+
Diagnostic(
|
|
21
|
+
level=DiagnosticLevel.ERROR,
|
|
22
|
+
code="SM010",
|
|
23
|
+
message=(f"Database at revision {current_revision!r}, expected {head_revision!r}"),
|
|
24
|
+
module_name="migrations",
|
|
25
|
+
suggestion="Run: make migrate",
|
|
26
|
+
)
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
def check_table_coverage(
|
|
30
|
+
self,
|
|
31
|
+
module_tables: set[str],
|
|
32
|
+
migrated_tables: set[str],
|
|
33
|
+
) -> list[Diagnostic]:
|
|
34
|
+
"""SM011: Warning if module tables are missing from migration history."""
|
|
35
|
+
missing = module_tables - migrated_tables
|
|
36
|
+
return [
|
|
37
|
+
Diagnostic(
|
|
38
|
+
level=DiagnosticLevel.WARNING,
|
|
39
|
+
code="SM011",
|
|
40
|
+
message=f"Table '{table}' declared in models but not found in migration history",
|
|
41
|
+
module_name="migrations",
|
|
42
|
+
suggestion=f'Run: make migration msg="add {table}"',
|
|
43
|
+
)
|
|
44
|
+
for table in sorted(missing)
|
|
45
|
+
]
|