diff --git a/nitrostack/cli/validators.py b/nitrostack/cli/validators.py index 990b154..52ae7bd 100644 --- a/nitrostack/cli/validators.py +++ b/nitrostack/cli/validators.py @@ -372,6 +372,72 @@ def validate_module_references(root: str) -> List[ValidationIssue]: return issues +_WIDGET_DECORATOR_RE = re.compile(r"""@widget\(\s*["']([^"']+)["']""") +_WIDGET_OPTIONS_RE = re.compile(r"""WidgetOptions\(\s*route\s*=\s*["']([^"']+)["']""") +_WIDGET_ROUTE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$") + + + +def _collect_widget_routes(root: str) -> List[Tuple[str, str]]: + found: List[Tuple[str, str]] = [] + seen: Set[str] = set() + root_abs = os.path.abspath(root) + + for path in sorted(_iter_python_files(root)): + rel_path = os.path.relpath(path, root_abs) + parts = os.path.normpath(rel_path).split(os.sep) + + # Ignore a vendored copy of the NitroStack SDK. + if "vendor" in parts and "nitrostack" in parts: + continue + + try: + text = _read(path) + except OSError: + continue + + for route in ( + _WIDGET_DECORATOR_RE.findall(text) + + _WIDGET_OPTIONS_RE.findall(text) + ): + if route not in seen: + seen.add(route) + found.append((route, rel_path)) + + return found + + + +def validate_widgets(root: str) -> List[ValidationIssue]: + issues: List[ValidationIssue] = [] + for route, source in _collect_widget_routes(root): + if not _WIDGET_ROUTE_RE.fullmatch(route): + issues.append(ValidationIssue( + "warning", + f"@widget route {route!r} is not a single alphanumeric path segment", + "Use letters, digits, '-' or '_' only; the route maps to widgets/out/.html.", + source, + )) + continue + rel = os.path.join("widgets", "out", f"{route}.html") + uri = f"ui://widget/{route}.html" + html_path = os.path.join(root, rel) + if not os.path.isfile(html_path): + issues.append(ValidationIssue( + "warning", + f"widget route {route!r} -> {rel} -> {uri}: file is missing", + f"Create {rel} with the widget HTML.", + source, + )) + elif not _read(html_path).strip(): + issues.append(ValidationIssue( + "warning", + f"widget route {route!r} -> {rel} -> {uri}: file is empty", + f"Add widget HTML to {rel}.", + rel, + )) + return issues + def validate_project(root: Optional[str] = None) -> List[ValidationIssue]: root = os.path.abspath(root or os.getcwd()) if root not in sys.path: @@ -380,6 +446,7 @@ def validate_project(root: Optional[str] = None) -> List[ValidationIssue]: issues.extend(validate_dependencies(root)) issues.extend(validate_mcp_app_imports(root)) issues.extend(validate_module_references(root)) + issues.extend(validate_widgets(root)) return issues diff --git a/tests/test_validate_widgets.py b/tests/test_validate_widgets.py new file mode 100644 index 0000000..1595e60 --- /dev/null +++ b/tests/test_validate_widgets.py @@ -0,0 +1,85 @@ +import sys +from pathlib import Path + +from nitrostack.cli.validators import validate_project, validate_widgets + +TOOL = ( + "from nitrostack import tool, widget\n\n" + "@tool(name='show')\n" + "@widget('card')\n" + "async def show(self, input, context):\n" + " return {}\n" +) + + +def _project(tmp_path: Path, source: str = TOOL) -> Path: + (tmp_path / "tools.py").write_text(source, encoding="utf-8") + return tmp_path + + +def _write_html(root: Path, route: str, html: str) -> None: + out = root / "widgets" / "out" + out.mkdir(parents=True, exist_ok=True) + (out / f"{route}.html").write_text(html, encoding="utf-8") + + +def test_missing_widget_html_is_a_warning(tmp_path: Path): + issues = validate_widgets(str(_project(tmp_path))) + assert len(issues) == 1 + assert issues[0].severity == "warning" + assert "file is missing" in issues[0].message + assert "card.html" in issues[0].message + assert "ui://widget/card.html" in issues[0].message + assert issues[0].path == "tools.py" + + +def test_empty_widget_html_is_a_warning(tmp_path: Path): + _write_html(_project(tmp_path), "card", " \n") + issues = validate_widgets(str(tmp_path)) + assert len(issues) == 1 + assert issues[0].severity == "warning" + assert "file is empty" in issues[0].message + + +def test_valid_widget_html_passes(tmp_path: Path): + _write_html(_project(tmp_path), "card", "") + assert validate_widgets(str(tmp_path)) == [] + + +def test_widget_options_route_is_collected(tmp_path: Path): + src = "from nitrostack import WidgetOptions, widget\n\n@widget(WidgetOptions(route='chart'))\ndef f(): ...\n" + issues = validate_widgets(str(_project(tmp_path, src))) + assert len(issues) == 1 + assert "'chart'" in issues[0].message + + +def test_invalid_route_is_reported_not_resolved(tmp_path: Path): + issues = validate_widgets(str(_project(tmp_path, "@widget('../evil')\ndef f(): ...\n"))) + assert len(issues) == 1 + assert "not a single alphanumeric path segment" in issues[0].message + + +def test_duplicate_routes_are_reported_once(tmp_path: Path): + (tmp_path / "a.py").write_text(TOOL, encoding="utf-8") + (tmp_path / "b.py").write_text(TOOL, encoding="utf-8") + assert len(validate_widgets(str(tmp_path))) == 1 + + +def test_vendored_sdk_is_ignored(tmp_path: Path): + vendored = tmp_path / "vendor" / "nitrostack" + vendored.mkdir(parents=True) + (vendored / "x.py").write_text(TOOL, encoding="utf-8") + assert validate_widgets(str(tmp_path)) == [] + + +def test_project_without_widgets_has_no_widget_issues(tmp_path: Path): + (tmp_path / "plain.py").write_text("x = 1\n", encoding="utf-8") + assert validate_widgets(str(tmp_path)) == [] + + +def test_validate_project_reports_widget_warnings_only(tmp_path: Path, monkeypatch): + monkeypatch.setattr(sys, "path", list(sys.path)) + _project(tmp_path) + issues = [i for i in validate_project(str(tmp_path)) if "widget route" in i.message] + assert len(issues) == 1 + assert issues[0].severity == "warning" \ No newline at end of file