-
Notifications
You must be signed in to change notification settings - Fork 64
fix(validate): diagnose plugin manifests without importing plugin code (#765) #830
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0fe0c0f
2bd1317
e637927
66280f1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -490,6 +490,12 @@ def cmd_validate(args: argparse.Namespace) -> int: | |
| {"repo_root": str(paths.repo_root), "project": str(paths.project)}, | ||
| ) | ||
|
|
||
| # The engine builds its registry from `paths.repo_root` (a `repo_root:` override | ||
| # under isolation = "none" points it at another checkout), so read the manifests | ||
| # that run will load. A failed BMAD config already failed above; fall back to | ||
| # the project dir so the manifest check still reports something. | ||
| _validate_plugin_manifests(paths.repo_root if paths is not None else project, report) | ||
|
|
||
| # Built exactly the way run/sweep's real preflight builds it, so validate's | ||
| # verdict and their abort cannot disagree. Deliberately NOT `[p.skill_tree for p | ||
| # in profiles]`: that carries triage's tree, and every skills check below asks a | ||
|
|
@@ -1517,6 +1523,48 @@ def _spec_closes_deferred(path: Path) -> tuple[tuple[str, ...], str | None]: | |
| return deferredwork.parse_declaration(raw) | ||
|
|
||
|
|
||
| def _validate_plugin_manifests(root: Path, report: ValidationReport) -> None: | ||
| """Parse every discovered plugin manifest the way a run will (#765). | ||
|
|
||
| `root` is the code root the engine hands `PluginRegistry.build` — | ||
| `paths.repo_root`, not necessarily the project dir. | ||
|
|
||
| Without this the first reader of a malformed project `plugin.toml` was | ||
| `PluginRegistry.build` inside `Engine.__init__` — after the run's directory, | ||
| state and journal were already published. `load_plugins` is manifest-only | ||
| discovery: it never imports a `[python]` module, which matters here because | ||
| validate is the command a user runs to decide whether a checkout is safe to | ||
| run at all. `PluginRegistry.build` would exec every allowlisted module. | ||
|
|
||
| A PluginError is the whole message: every manifest fault names its source | ||
| (the manifest path, for a project plugin). `load_plugins` stops at the first | ||
| bad manifest, so one fault is reported per pass. A third-party manifest on an | ||
| unsupported api_version is skipped with `warnings.warn`, which a run keeps; | ||
| here it is captured and reported as a warning finding instead, so it neither | ||
| leaks to stderr nor breaks the `--json` stream contract. | ||
| """ | ||
| import warnings | ||
|
|
||
| from .plugins import PluginError, load_plugins | ||
|
|
||
| with warnings.catch_warnings(record=True) as skipped: | ||
| warnings.simplefilter("always") # the once-per-location default would drop a repeat | ||
| try: | ||
| manifests = load_plugins(root) | ||
| except PluginError as e: | ||
|
Comment on lines
+1553
to
+1554
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the packaged AGENTS.md reference: AGENTS.md:L32-L32 Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 66280f1, for the whole class rather than the built-in listing alone: enumeration for both sources now goes through |
||
| manifests = None | ||
| report.fail("plugins.manifests", str(e)) | ||
| for w in skipped: | ||
| report.warn("plugins.manifests", f"{w.message} — skipped; a run will not load it") | ||
| if manifests is not None: | ||
| names = sorted(manifests) | ||
| report.ok( | ||
| "plugins.manifests", | ||
| f"plugin manifests OK: {len(names)} loaded ({', '.join(names) or 'none'})", | ||
| {"plugins": names}, | ||
| ) | ||
|
|
||
|
|
||
| def _validate_operator_registry( | ||
| project: Path, paths: bmadconfig.ProjectPaths, report: ValidationReport | ||
| ) -> None: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If
.bmad-loop/pluginscan be statted but cannot be enumerated—for example, it lacks read permission or its filesystem returns a transient I/O error—load_plugins()propagates theOSErrorfromPath.iterdir(), while this new boundary catches onlyPluginError. The command then falls through tomain()'s generic error backstop instead of producing aplugins.manifestsproblem; withvalidate --json, stdout is empty and stderr contains prose, violating the command's required one-object JSON contract. Catch or domain-wrap discovery I/O failures here.AGENTS.md reference: AGENTS.md:L32-L32
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in e637927, at the loader rather than the validate boundary so the engine start path gets the typed error too:
_discover_projectnow converts a failediterdir()intoPluginError("plugin dir <path>: unreadable: …"), matching what_read_manifest_textalready does for a manifest read.validate --jsonreports it as aplugins.manifestsproblem inside the one document. Covered bytest_unlistable_project_plugins_dir_raises_plugin_error(loader) andtest_validate_json_reports_an_unlistable_plugins_dir(the--jsoncontract); both ablated.