Harden M8 preflight foundation
This commit is contained in:
@@ -93,6 +93,10 @@ def main():
|
||||
# 'inspect' command
|
||||
p_inspect = subparsers.add_parser("inspect", help="Run preflight analysis on a tool")
|
||||
p_inspect.add_argument("name", help="Tool name")
|
||||
p_inspect.add_argument(
|
||||
"--registry", action="store_true",
|
||||
help="Include similar tools from the configured registry",
|
||||
)
|
||||
p_inspect.set_defaults(func=cmd_inspect)
|
||||
|
||||
# 'check' command
|
||||
@@ -530,7 +534,11 @@ def cmd_inspect(args):
|
||||
print(f"Error: Tool '{args.name}' not found.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
report = analyze_tool(tool)
|
||||
registry_client = None
|
||||
if getattr(args, "registry", False):
|
||||
from ..registry_client import get_client
|
||||
registry_client = get_client()
|
||||
report = analyze_tool(tool, registry_client=registry_client)
|
||||
|
||||
if report.errors:
|
||||
print(f"Errors ({len(report.errors)}):")
|
||||
|
||||
@@ -351,6 +351,48 @@ def _cmd_registry_update(args):
|
||||
return 0
|
||||
|
||||
|
||||
def _print_preflight_sections(report: dict, prefix: str = "") -> None:
|
||||
"""Render the common portions of a structured preflight report."""
|
||||
labels = (
|
||||
("errors", "ERROR"),
|
||||
("warnings", "WARN"),
|
||||
("suggestions", "HINT"),
|
||||
)
|
||||
for key, marker in labels:
|
||||
values = report.get(key) or []
|
||||
if values:
|
||||
print(f"{prefix}{key.title()} ({len(values)}):")
|
||||
for value in values:
|
||||
print(f" {marker}: {value}")
|
||||
|
||||
|
||||
def _print_registry_suggestions(suggestions: dict) -> None:
|
||||
"""Render registry-specific category, similarity, and scrutiny evidence."""
|
||||
category = suggestions.get("category") or {}
|
||||
if category.get("suggested"):
|
||||
print(
|
||||
f"Suggested category: {category['suggested']} "
|
||||
f"({category.get('confidence', 0):.0%} confidence)"
|
||||
)
|
||||
|
||||
similar = suggestions.get("similar_tools") or []
|
||||
if similar:
|
||||
print(f"Similar registry tools ({len(similar)}):")
|
||||
for item in similar:
|
||||
print(
|
||||
f" - {item.get('name', 'unknown')} "
|
||||
f"({item.get('similarity', 0):.0%} similar)"
|
||||
)
|
||||
|
||||
scrutiny = suggestions.get("scrutiny") or {}
|
||||
findings = scrutiny.get("findings") or []
|
||||
if findings:
|
||||
print(f"Scrutiny findings ({len(findings)}):")
|
||||
for finding in findings:
|
||||
marker = str(finding.get("result", "info")).upper()
|
||||
print(f" {marker}: {finding.get('message', '')}")
|
||||
|
||||
|
||||
def _cmd_registry_publish(args):
|
||||
"""Publish a tool to the registry."""
|
||||
from ..registry_client import RegistryError, get_client
|
||||
@@ -404,6 +446,9 @@ def _cmd_registry_publish(args):
|
||||
# Validate
|
||||
try:
|
||||
data = yaml.safe_load(config_yaml)
|
||||
if not isinstance(data, dict):
|
||||
print("Error: config.yaml must contain a YAML mapping", file=sys.stderr)
|
||||
return 1
|
||||
name = data.get("name", "")
|
||||
version = data.get("version", "")
|
||||
if not name or not version:
|
||||
@@ -424,36 +469,41 @@ def _cmd_registry_publish(args):
|
||||
print(f" README: {len(readme)} bytes")
|
||||
print()
|
||||
|
||||
from ..preflight import analyze_tool
|
||||
from ..tool import Tool
|
||||
try:
|
||||
local_report = analyze_tool(Tool.from_dict(data))
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
print(f"Local preflight error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
_print_preflight_sections(local_report.to_dict(), prefix="Local ")
|
||||
if not local_report.ok:
|
||||
return 1
|
||||
|
||||
# Attempt registry preflight if token is configured
|
||||
config = load_config()
|
||||
if config.registry.token:
|
||||
try:
|
||||
client = get_client()
|
||||
result = client.publish_tool(
|
||||
config_yaml, readme=readme, defaults=defaults,
|
||||
dry_run=True,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Registry preflight error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
errors = result.get("errors") or []
|
||||
warnings = result.get("warnings") or []
|
||||
suggestions = result.get("suggestions") or []
|
||||
if errors:
|
||||
print(f"Preflight errors ({len(errors)}):")
|
||||
for err in errors:
|
||||
print(f" ERROR: {err}")
|
||||
if warnings:
|
||||
print(f"Preflight warnings ({len(warnings)}):")
|
||||
for warn in warnings:
|
||||
print(f" WARN: {warn}")
|
||||
if suggestions:
|
||||
print(f"Suggestions ({len(suggestions)}):")
|
||||
for sug in suggestions:
|
||||
print(f" HINT: {sug}")
|
||||
if not errors and not warnings and not suggestions:
|
||||
print("Registry preflight passed.")
|
||||
return 0 if not errors else 1
|
||||
if not config.registry.token:
|
||||
print("No registry token configured — local preflight only.")
|
||||
return 0
|
||||
|
||||
try:
|
||||
client = get_client()
|
||||
result = client.publish_tool(
|
||||
config_yaml, readme=readme, defaults=defaults,
|
||||
dry_run=True,
|
||||
)
|
||||
except RegistryError as exc:
|
||||
print(f"Registry preflight failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
remote_report = result.get("preflight") or {}
|
||||
_print_preflight_sections(remote_report, prefix="Registry ")
|
||||
_print_registry_suggestions(result.get("suggestions") or {})
|
||||
if not remote_report.get("errors"):
|
||||
print("Registry preflight passed.")
|
||||
return 0
|
||||
return 1
|
||||
|
||||
# Check for token
|
||||
config = load_config()
|
||||
|
||||
@@ -19,6 +19,12 @@ from ...tool import (
|
||||
from ..widgets.icons import get_prompt_icon, get_code_icon, get_tool_icon
|
||||
|
||||
|
||||
def _switch_to_existing_tool(main_window, name: str) -> None:
|
||||
"""Remove the creation page before opening an existing tool editor."""
|
||||
main_window.close_tool_builder()
|
||||
main_window.open_tool_builder(name)
|
||||
|
||||
|
||||
class ToolBuilderPage(QWidget):
|
||||
"""Tool builder/editor page."""
|
||||
|
||||
@@ -1093,7 +1099,7 @@ class ToolBuilderPage(QWidget):
|
||||
|
||||
clicked = msg.clickedButton()
|
||||
if clicked is btn_open:
|
||||
self.main_window.open_tool_builder(name)
|
||||
_switch_to_existing_tool(self.main_window, name)
|
||||
return
|
||||
elif clicked is btn_copy:
|
||||
suffix = 2
|
||||
@@ -1122,9 +1128,23 @@ class ToolBuilderPage(QWidget):
|
||||
steps=self._tool.steps if self._tool else [],
|
||||
output=output,
|
||||
dependencies=self._tool.dependencies if self._tool else [],
|
||||
system_dependencies=self._tool.system_dependencies if self._tool else []
|
||||
system_dependencies=self._tool.system_dependencies if self._tool else [],
|
||||
visibility=self._tool.visibility if self._tool else "public",
|
||||
input_schema=self._tool.input_schema if self._tool else None,
|
||||
output_schema=self._tool.output_schema if self._tool else None,
|
||||
)
|
||||
|
||||
from ...preflight import analyze_tool
|
||||
preflight = analyze_tool(tool)
|
||||
if preflight.errors:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Preflight Failed",
|
||||
"\n".join(preflight.errors),
|
||||
)
|
||||
return
|
||||
advisory_count = len(preflight.warnings) + len(preflight.suggestions)
|
||||
|
||||
# Preserve source if editing
|
||||
if self._tool and self._tool.source:
|
||||
tool.source = self._tool.source
|
||||
@@ -1159,7 +1179,10 @@ class ToolBuilderPage(QWidget):
|
||||
if settings_path.exists():
|
||||
settings_path.unlink()
|
||||
|
||||
self.main_window.show_status(f"Saved tool '{name}'")
|
||||
status = f"Saved tool '{name}'"
|
||||
if advisory_count:
|
||||
status += f" with {advisory_count} preflight suggestion(s)"
|
||||
self.main_window.show_status(status)
|
||||
self.main_window.close_tool_builder()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Failed to save tool:\n{e}")
|
||||
|
||||
+27
-15
@@ -4,11 +4,10 @@ Produces a PreflightReport used by the GUI, CLI, and registry workflow.
|
||||
All checks are deterministic and evidence-based.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .tool import Tool
|
||||
from .tool import Tool, validate_json_schema
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -36,8 +35,23 @@ class PreflightReport:
|
||||
compatibility=self.compatibility + other.compatibility,
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"errors": list(self.errors),
|
||||
"warnings": list(self.warnings),
|
||||
"suggestions": list(self.suggestions),
|
||||
"similar_tools": list(self.similar_tools),
|
||||
"generated_tests": list(self.generated_tests),
|
||||
"compatibility": list(self.compatibility),
|
||||
}
|
||||
|
||||
def analyze_tool(tool: Tool, registry_client=None) -> PreflightReport:
|
||||
|
||||
def analyze_tool(
|
||||
tool: Tool,
|
||||
registry_client=None,
|
||||
*,
|
||||
check_local_dependencies: bool = True,
|
||||
) -> PreflightReport:
|
||||
"""Run all preflight checks on a tool.
|
||||
|
||||
Args:
|
||||
@@ -51,7 +65,8 @@ def analyze_tool(tool: Tool, registry_client=None) -> PreflightReport:
|
||||
_check_config_integrity(tool, report)
|
||||
_check_contracts(tool, report)
|
||||
_check_secrets(tool, report)
|
||||
_check_dependencies(tool, report)
|
||||
if check_local_dependencies:
|
||||
_check_dependencies(tool, report)
|
||||
if registry_client:
|
||||
_check_similar_tools(tool, report, registry_client)
|
||||
return report
|
||||
@@ -62,7 +77,7 @@ def _check_config_integrity(tool: Tool, report: PreflightReport):
|
||||
report.errors.append("Tool name is required")
|
||||
if tool.version and not _is_semver(tool.version):
|
||||
report.errors.append(f"Version '{tool.version}' is not valid semver")
|
||||
if not tool.steps and not tool.arguments:
|
||||
if not tool.steps and not tool.arguments and tool.output == "{input}":
|
||||
report.warnings.append(
|
||||
"Tool has no steps and no arguments — output template is passthrough"
|
||||
)
|
||||
@@ -80,15 +95,10 @@ def _check_contracts(tool: Tool, report: PreflightReport):
|
||||
|
||||
|
||||
def _validate_schema(schema: Any, label: str, report: PreflightReport):
|
||||
if not isinstance(schema, dict):
|
||||
report.errors.append(f"{label} must be a JSON Schema object")
|
||||
return
|
||||
schema_type = schema.get("type")
|
||||
if schema_type and not isinstance(schema_type, (str, list)):
|
||||
report.errors.append(f"{label} 'type' must be a string or array")
|
||||
required = schema.get("required")
|
||||
if required is not None and not isinstance(required, list):
|
||||
report.errors.append(f"{label} 'required' must be an array")
|
||||
try:
|
||||
validate_json_schema(schema, label)
|
||||
except ValueError as exc:
|
||||
report.errors.append(str(exc))
|
||||
|
||||
|
||||
def _check_secrets(tool: Tool, report: PreflightReport):
|
||||
@@ -113,6 +123,8 @@ def _check_dependencies(tool: Tool, report: PreflightReport):
|
||||
|
||||
|
||||
def _check_similar_tools(tool: Tool, report: PreflightReport, client):
|
||||
from .registry_client import RegistryError
|
||||
|
||||
try:
|
||||
results = client.search_tools(tool.name, per_page=5)
|
||||
items = results.data if hasattr(results, "data") else results
|
||||
@@ -124,7 +136,7 @@ def _check_similar_tools(tool: Tool, report: PreflightReport, client):
|
||||
})
|
||||
if not items:
|
||||
report.suggestions.append("No similar tools found on the registry")
|
||||
except Exception:
|
||||
except RegistryError:
|
||||
report.warnings.append("Could not search registry for similar tools")
|
||||
|
||||
|
||||
|
||||
@@ -2436,6 +2436,10 @@ def create_app() -> Flask:
|
||||
data = yaml.safe_load(config_text) or {}
|
||||
except yaml.YAMLError:
|
||||
return error_response("VALIDATION_ERROR", "Invalid YAML in config")
|
||||
if not isinstance(data, dict):
|
||||
return error_response(
|
||||
"VALIDATION_ERROR", "Tool config must contain a YAML mapping"
|
||||
)
|
||||
|
||||
name = (data.get("name") or "").strip()
|
||||
version = (data.get("version") or "").strip()
|
||||
@@ -2485,6 +2489,26 @@ def create_app() -> Flask:
|
||||
if len(str(tag)) > MAX_TAG_LEN:
|
||||
return error_response("VALIDATION_ERROR", "Tag exceeds 32 characters")
|
||||
|
||||
# Run the same deterministic contract/configuration checks used by the
|
||||
# CLI and builder. Registry hosts intentionally skip local-install
|
||||
# dependency checks because publisher dependencies are resolved by name.
|
||||
try:
|
||||
from ..preflight import analyze_tool as run_preflight
|
||||
from ..tool import Tool
|
||||
|
||||
preflight_report = run_preflight(
|
||||
Tool.from_dict(data), check_local_dependencies=False
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
return error_response("INVALID_CONFIG", str(exc), 400)
|
||||
if not preflight_report.ok:
|
||||
return error_response(
|
||||
"PREFLIGHT_FAILED",
|
||||
preflight_report.errors[0],
|
||||
400,
|
||||
details={"preflight": preflight_report.to_dict()},
|
||||
)
|
||||
|
||||
# Determine owner - admins can publish as "official" or other owners
|
||||
owner = g.current_publisher["slug"]
|
||||
requested_owner = payload.get("owner", "").strip()
|
||||
@@ -2625,6 +2649,7 @@ def create_app() -> Flask:
|
||||
"version": version,
|
||||
"status": "validated",
|
||||
"suggestions": suggestions,
|
||||
"preflight": preflight_report.to_dict(),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2730,6 +2755,7 @@ def create_app() -> Flask:
|
||||
"forked_from": forked_from,
|
||||
"forked_version": forked_version,
|
||||
"suggestions": suggestions,
|
||||
"preflight": preflight_report.to_dict(),
|
||||
}
|
||||
})
|
||||
response.status_code = 201
|
||||
|
||||
+32
-1
@@ -44,6 +44,22 @@ def _validate_optional_patterns(patterns: Optional[List[str]], field_name: str)
|
||||
raise ValueError(f"{field_name} must be a list of non-empty strings or null")
|
||||
|
||||
|
||||
def validate_json_schema(schema: Optional[dict], field_name: str) -> None:
|
||||
"""Validate an optional tool contract as a real JSON Schema."""
|
||||
if schema is None:
|
||||
return
|
||||
if not isinstance(schema, dict):
|
||||
raise ValueError(f"{field_name} must be a JSON Schema object")
|
||||
|
||||
from jsonschema.exceptions import SchemaError
|
||||
from jsonschema.validators import validator_for
|
||||
|
||||
try:
|
||||
validator_for(schema).check_schema(schema)
|
||||
except SchemaError as exc:
|
||||
raise ValueError(f"Invalid {field_name}: {exc.message}") from exc
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemDependency:
|
||||
"""A system-level package dependency (apt, brew, pacman, etc.)."""
|
||||
@@ -435,8 +451,15 @@ class Tool:
|
||||
output_schema: Optional[dict] = None # JSON Schema for tool output contract
|
||||
path: Optional[Path] = None # Path to config.yaml (set by load_tool)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
validate_json_schema(self.input_schema, "input_schema")
|
||||
validate_json_schema(self.output_schema, "output_schema")
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "Tool":
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Tool configuration must be a YAML mapping")
|
||||
|
||||
arguments = []
|
||||
for arg in data.get("arguments", []):
|
||||
arguments.append(ToolArgument.from_dict(arg))
|
||||
@@ -455,7 +478,15 @@ class Tool:
|
||||
# Parse source attribution if present
|
||||
source = None
|
||||
if "source" in data:
|
||||
source = ToolSource.from_dict(data["source"])
|
||||
source_data = data["source"]
|
||||
if isinstance(source_data, dict):
|
||||
source = ToolSource.from_dict(source_data)
|
||||
elif isinstance(source_data, str) and source_data.strip():
|
||||
source = ToolSource(
|
||||
type="imported", original_tool=source_data.strip()
|
||||
)
|
||||
elif source_data is not None:
|
||||
raise ValueError("source must be an attribution object or string")
|
||||
|
||||
# Normalize dependencies - can be strings or dicts with name/version
|
||||
raw_deps = data.get("dependencies", [])
|
||||
|
||||
Reference in New Issue
Block a user