"""Lean-build safety: core must not hard-import a plugin (ADR-013 Phase 5). For a per-site build that omits a plugin, any core `from plugins. import` outside a try/except would crash the whole app when that plugin is absent. This statically asserts every such import in shopdb/core and shopdb/cli is guarded. """ import ast import os CORE_ROOTS = ['shopdb/core', 'shopdb/cli'] def _imports_plugin(node): if isinstance(node, ast.ImportFrom): return bool(node.module and node.module.startswith('plugins.')) if isinstance(node, ast.Import): return any(alias.name.startswith('plugins.') for alias in node.names) return False def _unguarded_in(path): found = [] tree = ast.parse(open(path).read()) class Visitor(ast.NodeVisitor): def __init__(self): self.try_depth = 0 def visit_Try(self, node): self.try_depth += 1 for child in node.body: self.visit(child) self.try_depth -= 1 for handler in node.handlers: for child in handler.body: self.visit(child) for child in node.orelse + node.finalbody: self.visit(child) def generic_visit(self, node): if _imports_plugin(node) and self.try_depth == 0: found.append(f"{path}:{node.lineno}") super().generic_visit(node) Visitor().visit(tree) return found def test_no_unguarded_plugin_imports_in_core(): unguarded = [] for root in CORE_ROOTS: for dirpath, _, filenames in os.walk(root): for name in filenames: if name.endswith('.py'): unguarded += _unguarded_in(os.path.join(dirpath, name)) assert unguarded == [], ( "Core imports a plugin without a try/except ImportError guard; a lean " "build omitting that plugin would crash. Wrap in try/except:\n " + "\n ".join(unguarded)) # ============================================================================= # Universal site profile completeness # # deploy/site-profile-universal.json is what the released Windows installer is # built from: one exe serving any site, with the wizard offering every bundled # plugin for the operator to tick. A bundled plugin missing from that list is # invisible to the installer AND, worse, `flask plugin prune-schema` drops the # tables it owns at provisioning (ADR-014) because the site never "installed" # it. That is silent data loss for a plugin that shipped in the build, and # nothing else in the suite catches it - the backups plugin was added to # PLUGIN_TABLE_OWNERS and the tree while the profile stayed stale. # ============================================================================= import json def _bundled_plugins_with_manifest(): """Plugin dirs carrying a manifest.json - a manifest-less dir (e.g. `applications`) is core and always ships, so it is deliberately excluded.""" root = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'plugins') return {name for name in os.listdir(root) if os.path.isfile(os.path.join(root, name, 'manifest.json'))} def _universal_profile_plugins(): path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'deploy', 'site-profile-universal.json') with open(path) as handle: return set(json.load(handle)['plugins']) def test_universal_profile_lists_every_bundled_plugin(): missing = _bundled_plugins_with_manifest() - _universal_profile_plugins() assert not missing, ( 'bundled plugin(s) absent from deploy/site-profile-universal.json: ' + ', '.join(sorted(missing)) + '. The installer will not offer them and prune-schema will drop ' 'their tables at provisioning.' ) def test_universal_profile_names_only_real_plugins(): unknown = _universal_profile_plugins() - _bundled_plugins_with_manifest() assert not unknown, ( 'site-profile-universal.json names plugin(s) that do not exist: ' + ', '.join(sorted(unknown)))