refactor(plugins): one plugin-directory resolver (#623)

* refactor(plugins): one resolver for plugin id -> directory

Five places mapped a plugin id to its directory, each with its own rules
and each re-reading manifests per lookup: PluginManager discovery and
get_plugin_directory, PluginLoader.find_plugin_directory,
PluginStoreManager._find_plugin_path / list_installed_plugins, and
state_reconciliation.disk_plugin_ids. They disagreed on backup dirs,
on whether the manifest id or the directory name is the id, on duplicate
ids and on path safety.

src/plugin_system/plugin_dirs.py now holds the rules once:
PluginDirectoryIndex scans one directory and reads each manifest once;
resolve_plugin_dir() searches directories in order. What legitimately
differs per caller is an explicit argument: search dirs (discovery and
the loader: configured dir only; the store: configured then sibling
plugins/), ledmatrix- prefix (not for the store), case folding (loader
only), manifest pass (not for get_plugin_directory, whose discovery map
already holds it).

Behaviour changes, all for layouts installs do not produce:
- a directory whose manifest declares the id beats one merely named for
  it (discovery already worked this way; the loader and store now agree)
- the store searches the configured dir completely before plugins/
- backup and hidden dirs are skipped everywhere (the loader's case and
  manifest scans and list_installed_plugins used to return them)
- duplicate ids resolve deterministically (exact name, then
  ledmatrix-<id>, then by name) with a one-time warning; discovery no
  longer lists the id twice
- disk_plugin_ids / list_installed_plugins report manifest ids, falling
  back to the directory name; auto-update looks the directory up
- ids that are not one plain path segment resolve to nothing in every
  caller (the loader used to truncate them, the store to join them)

The .standalone-backup- marker is one constant, BACKUP_MARKER, used by
store_manager's rename-aside names and every lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): one plugin-directory resolver

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-24 15:52:52 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 13bbb537f3
commit ece416c4e5
9 changed files with 895 additions and 295 deletions
+9 -4
View File
@@ -172,9 +172,10 @@ def _plugin_fingerprint(store_manager, plugin_dir):
try:
with open(plugin_dir / 'manifest.json', 'r', encoding='utf-8') as f:
manifest = json.load(f)
if manifest.get('local_only'):
return None
version = manifest.get('version')
if isinstance(manifest, dict): # valid JSON need not be an object
if manifest.get('local_only'):
return None
version = manifest.get('version')
except (OSError, ValueError):
pass
sha = None
@@ -190,8 +191,12 @@ def update_plugins(store_manager, operation_history=None):
"""Update every installed plugin that has an update. Returns (updated, failed)."""
updated, failed = [], []
plugins_dir = Path(store_manager.plugins_dir)
# list_installed_plugins() reports manifest ids, and a plugin's directory
# may be named differently (ledmatrix-stocks/ holding id "stocks"), so
# the directory comes from the store's own lookup, not a join.
find_dir = getattr(store_manager, '_find_plugin_path', None)
for plugin_id in sorted(store_manager.list_installed_plugins()):
plugin_dir = plugins_dir / plugin_id
plugin_dir = (find_dir(plugin_id) if find_dir else None) or plugins_dir / plugin_id
before = _plugin_fingerprint(store_manager, plugin_dir)
if before is None:
continue # local_only: managed by hand, never from the registry