ADR-013 Phase 0: plugin lifecycle groundwork

Additive, zero-risk-to-running-sites prep for the plugin catalog. No
distribution or lean-build behavior yet; fixes latent bugs and adds the
declarative + validate tooling later phases build on.

Fixes:
- upgrade_all_plugins iterates registry.get_all(); only adopted plugins are
  migrated. Removes the phantom hasattr(registry, 'list_installed') probe
  that always fell through to migrating every folder on disk (unadopted DDL
  ran with full DB rights on every deploy).
- Reverse-dependency checks on uninstall/disable read dependencies from the
  manifest on disk via _installed_dependents, so an installed-but-unloaded or
  disabled dependent is counted. Uninstall blocks on any installed dependent;
  disable blocks on an enabled dependent.
- _sort_by_dependencies detects a dependency cycle (back edge in the DFS) and
  raises PluginDependencyError instead of looping or dropping a plugin.

New:
- flask plugin validate <name>: manifest loads + name match, manifest-schema
  check, core_version admits the framework contract, declared dependencies
  exist on disk. No new dependency (lightweight checker); schema ships in the
  package at shopdb/plugins/manifest_schema.json (docs/ is stripped on
  publish). The check caught that provides is an object, not an array.
- flask plugin apply-profile <file>: declarative install AND enable of a
  chosen plugin set plus its hard-dependency closure, in dependency order,
  idempotent. Replaces the hand-ordered runbook sequences that could enable a
  plugin that was never installed. deploy/site-profile.example.json template.
- Dockerfile header corrected (all 13 catalog plugins, not "eleven core").

10 new lifecycle tests (reverse-deps from disk, cycle detection, upgrade-all
scope, profile closure, schema, all 13 manifests match schema). 1018 pass,
naming green.
This commit is contained in:
cproudlock
2026-07-18 18:08:34 -04:00
parent 3ac5ed2580
commit d178726687
7 changed files with 533 additions and 39 deletions

View File

@@ -242,24 +242,35 @@ class PluginLoader:
"""Sort plugins so dependencies come first.
Reads dependencies from manifest.json directly; does not
instantiate plugin classes during sort.
instantiate plugin classes during sort. Detects a dependency cycle
(a back edge in the DFS) and raises PluginDependencyError rather than
looping or silently dropping a plugin.
"""
sorted_list = []
visited = set()
visited = set() # fully processed (post-order emitted)
visiting = set() # on the current DFS stack; a revisit here is a cycle
def visit(name):
if name in visited:
return
visited.add(name)
if name in visiting:
raise PluginDependencyError(
f'Circular plugin dependency involving "{name}"',
plugin_name=name,
)
visiting.add(name)
# Only the manifest read is tolerant; a cycle raised by a nested
# visit must propagate, so the dep recursion sits outside the guard
# (PluginDependencyError is itself a PluginError).
try:
manifest = self.load_manifest(name)
for dep in manifest.get('dependencies', []):
if dep in plugin_names:
visit(dep)
deps = self.load_manifest(name).get('dependencies', [])
except PluginError:
pass
deps = []
for dep in deps:
if dep in plugin_names:
visit(dep)
visiting.discard(name)
visited.add(name)
sorted_list.append(name)
for name in plugin_names: