Source code for scitex_app.appmaker._validate

"""App validator — check structure, security, manifest, templates, and CSS."""

from __future__ import annotations

import json
import logging
import re
from pathlib import Path

logger = logging.getLogger(__name__)

REQUIRED_FILES = [
    "apps.py",
    "views.py",
    "urls.py",
    "LICENSE",
    "README.md",
    "manifest.json",
]

FORBIDDEN_PATTERNS = [
    (r"\bsubprocess\b", "subprocess"),
    (r"\bos\.system\b", "os.system"),
    (r"\beval\s*\(", "eval()"),
    (r"\bexec\s*\(", "exec()"),
    (r"\b__import__\b", "__import__"),
]

MANIFEST_REQUIRED_KEYS = ["name", "slug", "label", "version", "icon", "license"]

# Frame selectors that app CSS must not style
PROTECTED_SELECTORS = [
    ".stx-shell-sidebar",
    ".stx-shell-sidebar__title",
    ".panel-resizer",
    "footer",
]

# Forbidden frame block overrides
FORBIDDEN_BLOCK_OVERRIDES = [
    "workspace_worktree_pane",
    "workspace_ai_pane",
    "workspace_viewer_pane",
    "workspace_apps_pane",
]


[docs] def validate(app_dir: str | Path) -> list[str]: """Run all validations on a local app directory. Returns list of error strings (empty = valid). """ errors = [] root = Path(app_dir) is_embedded = _is_embedded_package(root) errors.extend(validate_structure(app_dir)) errors.extend(validate_security(app_dir)) errors.extend(validate_manifest(app_dir)) if not is_embedded: # Embedded packages use compiled React builds — skip template/CSS checks errors.extend(validate_templates(app_dir)) errors.extend(validate_css(app_dir)) errors.extend(validate_dependencies(app_dir)) return errors
[docs] def validate_structure(app_dir: str | Path) -> list[str]: """Check that required files exist.""" errors = [] root = Path(app_dir) if not root.exists(): return [f"App directory does not exist: {root}"] is_embedded = _is_embedded_package(root) frontend_type = _get_frontend_type(root) # Core files always required always_required = ["views.py", "urls.py", "manifest.json"] # Standalone-only files (embedded packages have these at package root) standalone_required = ["apps.py", "LICENSE", "README.md"] for required in always_required: if not (root / required).exists(): errors.append(f"Missing required file: {required}") if not is_embedded: for required in standalone_required: if not (root / required).exists(): errors.append(f"Missing required file: {required}") # Check template pattern (skip for React/bridge apps and embedded packages) app_name = _get_app_name(root) if app_name and not is_embedded and frontend_type != "react": partial = root / "templates" / app_name / "index_partial.html" if not partial.exists(): errors.append(f"Missing template: templates/{app_name}/index_partial.html") # Check agents config (skip for embedded packages) if not is_embedded: agents_paths = [ root / ".agents" / "agents.json", root / ".agents" / "README.md", ] if not any(p.exists() for p in agents_paths): errors.append( "Missing agents config: .agents/agents.json or .agents/README.md" ) return errors
[docs] def validate_security(app_dir: str | Path) -> list[str]: """Scan Python files for forbidden patterns.""" errors = [] root = Path(app_dir) excluded_dirs = {"__pycache__", ".git", "scitex", "node_modules", ".venv", "venv"} for py_file in root.rglob("*.py"): if excluded_dirs & set(py_file.relative_to(root).parts): continue try: content = py_file.read_text(encoding="utf-8", errors="replace") except OSError: continue relpath = py_file.relative_to(root) for pattern, name in FORBIDDEN_PATTERNS: if re.search(pattern, content): errors.append(f"Forbidden pattern '{name}' found in {relpath}") return errors
[docs] def validate_manifest(app_dir: str | Path) -> list[str]: """Check manifest.json schema and content.""" errors = [] root = Path(app_dir) manifest_path = root / "manifest.json" if not manifest_path.exists(): return ["manifest.json not found"] try: data = json.loads(manifest_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as e: return [f"manifest.json is not valid JSON: {e}"] if not isinstance(data, dict): return ["manifest.json must be a JSON object"] for key in MANIFEST_REQUIRED_KEYS: if key not in data: errors.append(f"manifest.json missing required key: '{key}'") # Validate name matches directory convention name = data.get("name", "") if name and not (name.endswith("_app") or name.endswith("-app")): errors.append( f"manifest.json 'name' should end with '_app' or '-app' (got: '{name}')" ) # Validate version format version = data.get("version", "") if version and not re.match(r"^\d+\.\d+\.\d+", version): errors.append(f"manifest.json 'version' should be semver (got: '{version}')") return errors
[docs] def validate_templates(app_dir: str | Path) -> list[str]: """Check template compliance with workspace frame rules.""" errors = [] root = Path(app_dir) app_name = _get_app_name(root) if not app_name: return errors index_html = root / "templates" / app_name / "index.html" if not index_html.exists(): return errors try: content = index_html.read_text(encoding="utf-8", errors="replace") except OSError: return errors # Must extend global_base.html if "global_base.html" not in content: errors.append("index.html must extend 'global_base.html'") # Must have {% block content %} if "block content" not in content: errors.append("index.html must define {% block content %}") # Must NOT override frame blocks for block_name in FORBIDDEN_BLOCK_OVERRIDES: if f"block {block_name}" in content: errors.append(f"index.html must not override '{{% block {block_name} %}}'") return errors
[docs] def validate_css(app_dir: str | Path) -> list[str]: """Check CSS compliance with workspace frame rules.""" errors = [] root = Path(app_dir) for css_file in root.rglob("*.css"): if ".git" in str(css_file): continue try: content = css_file.read_text(encoding="utf-8", errors="replace") except OSError: continue relpath = css_file.relative_to(root) # Warn about deprecated --color-* variables if re.search(r"var\(--color-", content): errors.append( f"{relpath}: use --workspace-* or --text-* CSS variables " f"instead of --color-* (see workspace template spec)" ) # Check for !important on protected selectors for selector in PROTECTED_SELECTORS: pattern = re.escape(selector) + r"[^{]*\{[^}]*!important" if re.search(pattern, content, re.DOTALL): errors.append(f"{relpath}: must not use !important on '{selector}'") # Check for footer hiding if re.search(r"footer\s*\{[^}]*display\s*:\s*none", content, re.DOTALL): errors.append(f"{relpath}: must not hide the footer") return errors
[docs] def validate_dependencies(app_dir: str | Path) -> list[str]: """Check that manifest.json dependencies field is well-formed.""" errors = [] root = Path(app_dir) manifest_path = root / "manifest.json" if not manifest_path.exists(): return errors try: data = json.loads(manifest_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): return errors deps = data.get("dependencies") if deps is None: errors.append("manifest.json missing 'dependencies' field") return errors if not isinstance(deps, dict): errors.append("manifest.json 'dependencies' must be a JSON object") return errors valid_types = {"python", "system", "node", "r", "other"} for key, val in deps.items(): if key not in valid_types: errors.append(f"manifest.json unknown dependency type: '{key}'") if not isinstance(val, list): errors.append(f"manifest.json dependencies.{key} must be a list") elif not all(isinstance(item, str) for item in val): errors.append(f"manifest.json dependencies.{key} items must be strings") return errors
def _is_embedded_package(root: Path) -> bool: """Return True if the app is embedded inside a Python package. Detection: manifest declares ``embedded_package: true``, OR the directory name starts with ``_`` (e.g. ``_django``). """ # Convention: _django/ is a private package directory if root.name.startswith("_"): return True manifest_path = root / "manifest.json" if manifest_path.exists(): try: data = json.loads(manifest_path.read_text(encoding="utf-8")) return bool(data.get("embedded_package", False)) except (json.JSONDecodeError, OSError): pass return False def _get_frontend_type(root: Path) -> str: """Return frontend_type from manifest, or empty string.""" manifest_path = root / "manifest.json" if manifest_path.exists(): try: data = json.loads(manifest_path.read_text(encoding="utf-8")) return data.get("frontend_type", "") except (json.JSONDecodeError, OSError): pass return "" def _get_app_name(root: Path) -> str: """Derive app name from manifest or directory name.""" manifest_path = root / "manifest.json" if manifest_path.exists(): try: data = json.loads(manifest_path.read_text(encoding="utf-8")) return data.get("name", "") except (json.JSONDecodeError, OSError): pass return root.name # EOF