diff --git a/hooks/ci-merge-gate.py b/hooks/ci-merge-gate.py index fcd5c3a8..58bd3dbe 100755 --- a/hooks/ci-merge-gate.py +++ b/hooks/ci-merge-gate.py @@ -8,6 +8,7 @@ """ import json +import os import subprocess import sys from pathlib import Path @@ -29,9 +30,46 @@ def main() -> None: if "gh pr merge" not in command and "gh pr merge" not in command.replace(" ", " "): return + parts = command.split() + + # --- Block --admin before any CI check --- + if "--admin" in parts: + if os.environ.get("ALLOW_ADMIN_MERGE") == "1": + print("[ci-merge-gate] WARNING: --admin override allowed via ALLOW_ADMIN_MERGE=1", file=sys.stderr) + else: + print("[ci-merge-gate] BLOCKED: --admin bypasses branch protection", file=sys.stderr) + deny_output = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": ( + "Use of --admin bypasses CI checks. Remove --admin and wait for CI to pass." + ), + } + } + print(json.dumps(deny_output)) + sys.exit(0) + + # --- Block --force before any CI check --- + if "--force" in parts: + if os.environ.get("ALLOW_FORCE_MERGE") == "1": + print("[ci-merge-gate] WARNING: --force override allowed via ALLOW_FORCE_MERGE=1", file=sys.stderr) + else: + print("[ci-merge-gate] BLOCKED: --force bypasses merge safeguards", file=sys.stderr) + deny_output = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": ( + "Use of --force bypasses merge safeguards. Remove --force and merge normally." + ), + } + } + print(json.dumps(deny_output)) + sys.exit(0) + # Extract PR number from command # Patterns: gh pr merge 55, gh pr merge #55, gh pr merge --squash 55 - parts = command.split() pr_number = None for i, part in enumerate(parts): if part.lstrip("#").isdigit() and i > 0 and parts[i - 1] != "--count": diff --git a/hooks/tests/test_ci_merge_gate.py b/hooks/tests/test_ci_merge_gate.py new file mode 100755 index 00000000..fb1fd55f --- /dev/null +++ b/hooks/tests/test_ci_merge_gate.py @@ -0,0 +1,104 @@ +"""Tests for ci-merge-gate.py --admin and --force blocking.""" + +import json +import os +import subprocess +import sys + +import pytest + +HOOK = os.path.join(os.path.dirname(__file__), "..", "ci-merge-gate.py") + + +def run_hook(command: str, env_overrides: dict | None = None) -> subprocess.CompletedProcess: + """Run the hook with a synthetic PreToolUse event.""" + event = json.dumps({"tool_name": "Bash", "tool_input": {"command": command}}) + env = os.environ.copy() + # Clear escape hatches by default + env.pop("ALLOW_ADMIN_MERGE", None) + env.pop("ALLOW_FORCE_MERGE", None) + if env_overrides: + env.update(env_overrides) + return subprocess.run( + [sys.executable, HOOK], + input=event, + capture_output=True, + text=True, + timeout=15, + env=env, + ) + + +class TestAdminBlock: + def test_admin_flag_denied(self): + r = run_hook("gh pr merge 55 --admin") + assert r.returncode == 0 + out = json.loads(r.stdout.strip().split("\n")[-1]) + assert out["hookSpecificOutput"]["permissionDecision"] == "deny" + assert "--admin" in out["hookSpecificOutput"]["permissionDecisionReason"] + + def test_admin_allowed_with_env(self): + r = run_hook("gh pr merge 55 --admin", env_overrides={"ALLOW_ADMIN_MERGE": "1"}) + assert r.returncode == 0 + # Should NOT contain a deny decision + for line in r.stdout.strip().split("\n"): + line = line.strip() + if line.startswith("{"): + data = json.loads(line) + decision = data.get("hookSpecificOutput", {}).get("permissionDecision") + assert decision != "deny", "Should not deny when ALLOW_ADMIN_MERGE=1" + # Should warn on stderr + assert "ALLOW_ADMIN_MERGE" in r.stderr + + +class TestForceBlock: + def test_force_flag_denied(self): + r = run_hook("gh pr merge 55 --force") + assert r.returncode == 0 + out = json.loads(r.stdout.strip().split("\n")[-1]) + assert out["hookSpecificOutput"]["permissionDecision"] == "deny" + assert "--force" in out["hookSpecificOutput"]["permissionDecisionReason"] + + def test_force_allowed_with_env(self): + r = run_hook("gh pr merge 55 --force", env_overrides={"ALLOW_FORCE_MERGE": "1"}) + assert r.returncode == 0 + for line in r.stdout.strip().split("\n"): + line = line.strip() + if line.startswith("{"): + data = json.loads(line) + decision = data.get("hookSpecificOutput", {}).get("permissionDecision") + assert decision != "deny", "Should not deny when ALLOW_FORCE_MERGE=1" + assert "ALLOW_FORCE_MERGE" in r.stderr + + +class TestPassthrough: + def test_normal_merge_passes(self): + """Normal merge should not be blocked by admin/force checks.""" + r = run_hook("gh pr merge 55 --squash") + assert r.returncode == 0 + # Should not have a deny from admin/force (CI check may still fire but that's separate) + for line in r.stdout.strip().split("\n"): + line = line.strip() + if line.startswith("{"): + data = json.loads(line) + reason = data.get("hookSpecificOutput", {}).get("permissionDecisionReason", "") + assert "--admin" not in reason + assert "--force" not in reason + + def test_non_merge_command_passes(self): + """Non-merge commands should pass through completely.""" + r = run_hook("gh pr view 55") + assert r.returncode == 0 + assert r.stdout.strip() == "" or "deny" not in r.stdout + + def test_merge_with_delete_branch_passes(self): + """Normal merge flags should not trigger admin/force block.""" + r = run_hook("gh pr merge 55 --squash --delete-branch") + assert r.returncode == 0 + for line in r.stdout.strip().split("\n"): + line = line.strip() + if line.startswith("{"): + data = json.loads(line) + reason = data.get("hookSpecificOutput", {}).get("permissionDecisionReason", "") + assert "--admin" not in reason + assert "--force" not in reason diff --git a/scripts/migrate-skills-to-folders.py b/scripts/migrate-skills-to-folders.py index 2a697fc5..9c53614d 100755 --- a/scripts/migrate-skills-to-folders.py +++ b/scripts/migrate-skills-to-folders.py @@ -102,6 +102,7 @@ "workflow-help": "meta", "reference-enrichment": "meta", "generate-claudemd": "meta", + "html-artifact": "meta", "docs-sync-checker": "meta", "explanation-traces": "meta", # process/ — methodologies, git, debugging diff --git a/skills/meta/do/references/routing-tables.md b/skills/meta/do/references/routing-tables.md index 1167f67a..79310a1d 100644 --- a/skills/meta/do/references/routing-tables.md +++ b/skills/meta/do/references/routing-tables.md @@ -181,6 +181,7 @@ Route to these agents based on the user's task domain. Each entry describes what | **wordpress-live-validation** | User wants to validate WordPress posts live after upload: check rendering, canonical URLs, or publication status. | | **pptx-generator** | User wants to generate a PowerPoint presentation, slide deck, or pitch deck from content or research. | | **frontend-slides** | User wants browser-based HTML presentations: reveal-style slide decks, kiosk presentations, or converting PPTX to web format. | +| **html-artifact** | User wants rich self-contained HTML output instead of markdown — specs, code reviews, reports, prototypes, editors, data viz. Auto-detected by router or invoked via `/html`. NOT: web applications, React/Vue projects, multi-page sites (use typescript-frontend-engineer). NOT: slide decks (use frontend-slides). NOT: interactive essays (use interactive-essay). | | **gemini-image-generator** | User wants to generate images from text prompts via Google Gemini: sprites, character art, or AI-generated visuals. | | **bluesky-reader** | User wants to read public Bluesky feeds, fetch posts, or interact with the AT Protocol API. | | **image-to-video** | User wants to combine a static image with audio to create a video file (album art video, podcast video, music visualization). | diff --git a/skills/meta/html-artifact/scripts/assemble-template.py b/skills/meta/html-artifact/scripts/assemble-template.py new file mode 100644 index 00000000..9cb121d7 --- /dev/null +++ b/skills/meta/html-artifact/scripts/assemble-template.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Deterministic HTML template assembler for html-artifact skill. + +Given a shape and title, injects the correct theme CSS into the base template +and outputs a ready-to-fill HTML skeleton to stdout. + +Exit codes: + 0: template assembled successfully + 1: invalid shape or theme + 2: base template not found + +Usage: + python3 skills/meta/html-artifact/scripts/assemble-template.py --shape spec --title "Auth Comparison" + python3 skills/meta/html-artifact/scripts/assemble-template.py --shape code-review --title "PR #42" --theme dark-focus +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +VALID_SHAPES = ("spec", "code-review", "prototype", "report", "editor", "data-viz") +VALID_THEMES = ("birchline", "dark-focus", "interactive-warm", "minimal-document") + +SHAPE_DEFAULT_THEME: dict[str, str] = { + "spec": "birchline", + "code-review": "dark-focus", + "prototype": "interactive-warm", + "report": "birchline", + "editor": "interactive-warm", + "data-viz": "dark-focus", +} + +# Theme CSS overrides. birchline is the base template default — no override needed. +THEME_CSS: dict[str, str] = { + "birchline": "", + "dark-focus": ( + " /* === Dark Focus Theme Override === */\n" + " :root { --color-primary: #64B5F6; --color-slate: #e0e0e0; --color-ivory: #1a1a2e;" + " --color-oat: #232340; --color-white: #2a2a4a; --color-gray-100: #2d2d50;" + " --color-gray-300: #3d3d60; --color-gray-500: #8888a0; --color-gray-700: #c0c0d0;" + " --color-success: #81C784; --color-warning: #FFB74D; --color-danger: #E57373;" + " --color-info: #64B5F6; }\n" + " body { background: var(--color-ivory); color: var(--color-slate); }\n" + ), + "interactive-warm": ( + " /* === Interactive Warm Theme Override === */\n" + " :root { --color-primary: #5B8DEF; --color-ivory: #FAFAF8; --color-oat: #F0F0EC; }\n" + ), + "minimal-document": ( + " /* === Minimal Document Theme Override === */\n" + " :root { --color-ivory: #FFFFF8; --color-slate: #333333; --color-primary: #555555; }\n" + " body { font-family: Georgia, 'Times New Roman', serif; max-width: 680px;" + " margin: 0 auto; line-height: 1.7; }\n" + ), +} + +BASE_TEMPLATE_PATH = Path(__file__).parent.parent / "assets" / "base-template.html" + + +def assemble_template(shape: str, title: str, theme: str | None = None) -> str: + """Assemble an HTML template with the given shape, title, and theme. + + Args: + shape: One of the 6 valid artifact shapes. + title: The title to inject into the template. + theme: Optional theme override. Defaults to shape-specific theme. + + Returns: + The assembled HTML string. + + Raises: + ValueError: If shape or theme is invalid. + FileNotFoundError: If base template is missing. + """ + if shape not in VALID_SHAPES: + raise ValueError(f"Invalid shape '{shape}'. Valid shapes: {', '.join(VALID_SHAPES)}") + + resolved_theme = theme if theme is not None else SHAPE_DEFAULT_THEME[shape] + if resolved_theme not in VALID_THEMES: + raise ValueError(f"Invalid theme '{resolved_theme}'. Valid themes: {', '.join(VALID_THEMES)}") + + template = BASE_TEMPLATE_PATH.read_text(encoding="utf-8") + + # Inject title + html = template.replace("", title) + + # Inject theme CSS override after the STYLES comment placeholder + theme_css = THEME_CSS[resolved_theme] + if theme_css: + html = html.replace("/* */", theme_css + " /* */") + + return html + + +def main() -> None: + """CLI entry point.""" + parser = argparse.ArgumentParser(description="Assemble an HTML artifact template.") + parser.add_argument("--shape", required=True, help="Artifact shape.") + parser.add_argument("--title", required=True, help="Title for the artifact.") + parser.add_argument( + "--theme", default=None, help="Theme override (birchline, dark-focus, interactive-warm, minimal-document)." + ) + args = parser.parse_args() + + if args.shape not in VALID_SHAPES: + sys.stderr.write(f"Error: Invalid shape '{args.shape}'. Valid shapes: {', '.join(VALID_SHAPES)}\n") + sys.exit(1) + + if args.theme is not None and args.theme not in VALID_THEMES: + sys.stderr.write(f"Error: Invalid theme '{args.theme}'. Valid themes: {', '.join(VALID_THEMES)}\n") + sys.exit(1) + + try: + html = assemble_template(args.shape, args.title, args.theme) + except FileNotFoundError: + sys.stderr.write(f"Error: Base template not found at {BASE_TEMPLATE_PATH}\n") + sys.exit(2) + + sys.stdout.write(html) + + +if __name__ == "__main__": + main() diff --git a/skills/meta/html-artifact/scripts/generate-filename.py b/skills/meta/html-artifact/scripts/generate-filename.py new file mode 100644 index 00000000..daf24c4a --- /dev/null +++ b/skills/meta/html-artifact/scripts/generate-filename.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Deterministic filename generator for html-artifact skill. + +Given a request string, produces a kebab-case .html filename. + +Usage: + python3 skills/meta/html-artifact/scripts/generate-filename.py --request "explore 3 approaches to rate limiting" + python3 skills/meta/html-artifact/scripts/generate-filename.py --request "build a dashboard" --shape data-viz +""" + +from __future__ import annotations + +import argparse +import re +import sys + +VALID_SHAPES = ("spec", "code-review", "prototype", "report", "editor", "data-viz") + +STOP_WORDS = frozenset( + { + "the", + "a", + "an", + "to", + "for", + "of", + "in", + "on", + "at", + "by", + "with", + "from", + "this", + "that", + "these", + "those", + "my", + "our", + "your", + "is", + "are", + "was", + "were", + "be", + "been", + "being", + "have", + "has", + "had", + "do", + "does", + "did", + "will", + "would", + "shall", + "should", + "can", + "could", + "may", + "might", + "must", + "need", + "me", + "i", + "we", + "you", + "it", + "he", + "she", + "they", + } +) + +VERB_PREFIXES = frozenset( + { + "explore", + "create", + "make", + "build", + "write", + "generate", + "show", + "help", + "review", + "explain", + "analyze", + "compare", + "visualize", + "prototype", + "design", + "report", + "summarize", + "triage", + "reorder", + "edit", + "tune", + } +) + + +def generate_filename(request: str, shape: str | None = None) -> str: + """Generate a kebab-case .html filename from a request string. + + Args: + request: The user's natural language request. + shape: Optional shape to prepend if not already present. + + Returns: + A kebab-case .html filename. + """ + # Step 1: lowercase + lowered = request.lower() + + # Step 2-3: extract words, remove stop words and verb prefixes + words = re.findall(r"[a-z]+", lowered) + content_words = [w for w in words if w not in STOP_WORDS and w not in VERB_PREFIXES] + + # Step 4: max 4 content words + content_words = content_words[:4] + + # Step 7: fallback if no content words + if not content_words: + if shape: + return f"{shape}-artifact.html" + return "artifact.html" + + filename_base = "-".join(content_words) + + # Step 8: prepend shape if provided and shape word not in filename + if shape: + # Normalize shape for comparison (e.g., "data-viz" -> ["data", "viz"]) + shape_parts = shape.split("-") + if not any(part in content_words for part in shape_parts): + filename_base = f"{shape}-{filename_base}" + + return f"{filename_base}.html" + + +def main() -> None: + """CLI entry point.""" + parser = argparse.ArgumentParser(description="Generate a kebab-case filename from a request.") + parser.add_argument("--request", required=True, help="User request string.") + parser.add_argument("--shape", default=None, help="Optional shape to prepend.") + args = parser.parse_args() + + if args.shape is not None and args.shape not in VALID_SHAPES: + sys.stderr.write(f"Error: Invalid shape '{args.shape}'. Valid shapes: {', '.join(VALID_SHAPES)}\n") + sys.exit(1) + + filename = generate_filename(args.request, args.shape) + sys.stdout.write(filename + "\n") + + +if __name__ == "__main__": + main() diff --git a/skills/meta/html-artifact/scripts/select-references.py b/skills/meta/html-artifact/scripts/select-references.py new file mode 100644 index 00000000..c72206d9 --- /dev/null +++ b/skills/meta/html-artifact/scripts/select-references.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Deterministic reference file selector for html-artifact skill. + +Given a shape, outputs the list of reference files to load. +Replaces LLM table-lookup with a script. + +Exit codes: + 0: valid shape, references returned + 1: invalid shape + +Usage: + python3 skills/meta/html-artifact/scripts/select-references.py --shape spec + python3 skills/meta/html-artifact/scripts/select-references.py --shape report --json-compact +""" + +from __future__ import annotations + +import argparse +import json +import sys + +VALID_SHAPES = ("spec", "code-review", "prototype", "report", "editor", "data-viz") + +ALWAYS_LOAD = [ + "references/design-system.md", + "references/interaction-patterns.md", +] + +SHAPE_SPECIFIC: dict[str, str] = { + "spec": "references/shape-spec-exploration.md", + "code-review": "references/shape-code-review.md", + "prototype": "references/shape-design-prototype.md", + "report": "references/shape-report-research.md", + "editor": "references/shape-custom-editor.md", + "data-viz": "references/shape-data-visualization.md", +} + + +def select_references(shape: str) -> dict[str, object]: + """Return reference file lists for a given shape. + + Args: + shape: One of the 6 valid artifact shapes. + + Returns: + Dict with shape, always_load, shape_specific, and all_files keys. + + Raises: + ValueError: If shape is not one of the valid shapes. + """ + if shape not in VALID_SHAPES: + raise ValueError(f"Invalid shape '{shape}'. Valid shapes: {', '.join(VALID_SHAPES)}") + + shape_ref = SHAPE_SPECIFIC[shape] + shape_specific = [shape_ref] + + return { + "shape": shape, + "always_load": list(ALWAYS_LOAD), + "shape_specific": shape_specific, + "all_files": ALWAYS_LOAD + shape_specific, + } + + +def main() -> None: + """CLI entry point.""" + parser = argparse.ArgumentParser(description="Select reference files for an artifact shape.") + parser.add_argument( + "--shape", required=True, help="Artifact shape (spec, code-review, prototype, report, editor, data-viz)." + ) + parser.add_argument("--json-compact", action="store_true", help="Output compact JSON (no indentation).") + args = parser.parse_args() + + try: + result = select_references(args.shape) + except ValueError as e: + error_result = {"error": str(e), "valid_shapes": list(VALID_SHAPES)} + indent = None if args.json_compact else 2 + json.dump(error_result, sys.stderr, indent=indent) + sys.stderr.write("\n") + sys.exit(1) + + indent = None if args.json_compact else 2 + json.dump(result, sys.stdout, indent=indent) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main() diff --git a/skills/meta/html-artifact/scripts/tests/test_assemble_template.py b/skills/meta/html-artifact/scripts/tests/test_assemble_template.py new file mode 100644 index 00000000..a628d30f --- /dev/null +++ b/skills/meta/html-artifact/scripts/tests/test_assemble_template.py @@ -0,0 +1,127 @@ +"""Tests for assemble-template.py — deterministic HTML template assembler.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPT = str(Path(__file__).parent.parent / "assemble-template.py") + +# --- Import module directly for unit tests --- +sys.path.insert(0, str(Path(__file__).parent.parent)) +from importlib import import_module + +assemble_mod = import_module("assemble-template") +assemble_template = assemble_mod.assemble_template + + +class TestAssembleTemplateDirect: + """Unit tests calling assemble_template() directly.""" + + def test_title_injected(self) -> None: + html = assemble_template("spec", "My Title") + assert "My Title" in html + + def test_birchline_no_extra_css(self) -> None: + html = assemble_template("spec", "Test") + # Birchline is the default in base template — no theme override block + assert "Dark Focus Theme Override" not in html + assert "Interactive Warm Theme Override" not in html + + def test_dark_focus_theme(self) -> None: + html = assemble_template("code-review", "Test") + assert "Dark Focus Theme Override" in html + assert "--color-ivory: #1a1a2e" in html + + def test_interactive_warm_theme(self) -> None: + html = assemble_template("prototype", "Test") + assert "Interactive Warm Theme Override" in html + assert "--color-primary: #5B8DEF" in html + + def test_minimal_document_theme(self) -> None: + html = assemble_template("spec", "Test", theme="minimal-document") + assert "Minimal Document Theme Override" in html + assert "Georgia" in html + + def test_theme_override(self) -> None: + # spec defaults to birchline, override to dark-focus + html = assemble_template("spec", "Test", theme="dark-focus") + assert "Dark Focus Theme Override" in html + + def test_shape_default_themes(self) -> None: + expected = { + "spec": "birchline", + "code-review": "dark-focus", + "prototype": "interactive-warm", + "report": "birchline", + "editor": "interactive-warm", + "data-viz": "dark-focus", + } + for shape, theme in expected.items(): + html = assemble_template(shape, "Test") + if theme == "birchline": + assert "Theme Override" not in html, f"{shape} should use birchline (no override)" + elif theme == "dark-focus": + assert "Dark Focus Theme Override" in html, f"{shape} should use dark-focus" + elif theme == "interactive-warm": + assert "Interactive Warm Theme Override" in html, f"{shape} should use interactive-warm" + + def test_invalid_shape_raises(self) -> None: + with pytest.raises(ValueError, match="Invalid shape"): + assemble_template("invalid", "Test") + + def test_invalid_theme_raises(self) -> None: + with pytest.raises(ValueError, match="Invalid theme"): + assemble_template("spec", "Test", theme="neon") + + def test_output_is_valid_html_structure(self) -> None: + html = assemble_template("report", "Report Title") + assert "" in html + assert "" in html + assert "" in html + + def test_html_entities_in_title(self) -> None: + html = assemble_template("spec", "A & B ") + assert "A & B <comparison>" in html + + def test_deterministic_same_input_same_output(self) -> None: + results = [assemble_template("data-viz", "Dashboard") for _ in range(5)] + assert all(r == results[0] for r in results) + + +@pytest.mark.slow +class TestCLIInterface: + """Integration tests via subprocess.""" + + def test_cli_basic(self) -> None: + cmd = [sys.executable, SCRIPT, "--shape", "spec", "--title", "Auth Comparison"] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + assert proc.returncode == 0 + assert "Auth Comparison" in proc.stdout + + def test_cli_with_theme(self) -> None: + cmd = [sys.executable, SCRIPT, "--shape", "spec", "--title", "Test", "--theme", "dark-focus"] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + assert proc.returncode == 0 + assert "Dark Focus Theme Override" in proc.stdout + + def test_cli_invalid_shape_exits_1(self) -> None: + cmd = [sys.executable, SCRIPT, "--shape", "banana", "--title", "Test"] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + assert proc.returncode == 1 + + def test_cli_invalid_theme_exits_1(self) -> None: + cmd = [sys.executable, SCRIPT, "--shape", "spec", "--title", "Test", "--theme", "neon"] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + assert proc.returncode == 1 + + def test_cli_output_is_complete_html(self) -> None: + cmd = [sys.executable, SCRIPT, "--shape", "report", "--title", "Weekly Report"] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + assert proc.returncode == 0 + assert "" in proc.stdout + assert "" in proc.stdout diff --git a/skills/meta/html-artifact/scripts/tests/test_generate_filename.py b/skills/meta/html-artifact/scripts/tests/test_generate_filename.py new file mode 100644 index 00000000..a6a6a040 --- /dev/null +++ b/skills/meta/html-artifact/scripts/tests/test_generate_filename.py @@ -0,0 +1,112 @@ +"""Tests for generate-filename.py — deterministic filename generator.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPT = str(Path(__file__).parent.parent / "generate-filename.py") + +# --- Import module directly for unit tests --- +sys.path.insert(0, str(Path(__file__).parent.parent)) +from importlib import import_module + +filename_mod = import_module("generate-filename") +generate_filename = filename_mod.generate_filename + + +class TestGenerateFilenameDirect: + """Unit tests calling generate_filename() directly.""" + + def test_basic_request(self) -> None: + result = generate_filename("explore 3 approaches to rate limiting") + # "explore" is verb prefix, "3" is numeric (not [a-z]+), "to" is stop word + assert result == "approaches-rate-limiting.html" + + def test_stop_words_removed(self) -> None: + result = generate_filename("the quick brown fox") + assert result == "quick-brown-fox.html" + + def test_verb_prefixes_removed(self) -> None: + result = generate_filename("create a dashboard widget") + assert result == "dashboard-widget.html" + + def test_max_4_words(self) -> None: + result = generate_filename("foo bar baz qux quux corge") + assert result == "foo-bar-baz-qux.html" + + def test_empty_after_filtering_no_shape(self) -> None: + result = generate_filename("explore the") + assert result == "artifact.html" + + def test_empty_after_filtering_with_shape(self) -> None: + result = generate_filename("explore the", shape="spec") + assert result == "spec-artifact.html" + + def test_shape_prepended_when_not_in_words(self) -> None: + result = generate_filename("rate limiting strategies", shape="spec") + assert result == "spec-rate-limiting-strategies.html" + + def test_shape_not_prepended_when_already_present(self) -> None: + # "data" appears as content word and is a part of "data-viz" + result = generate_filename("data pipeline performance", shape="data-viz") + assert result == "data-pipeline-performance.html" + + def test_case_insensitive(self) -> None: + result = generate_filename("EXPLORE Rate LIMITING") + assert result == "rate-limiting.html" + + def test_numbers_stripped(self) -> None: + # re.findall(r"[a-z]+", ...) only matches letters + result = generate_filename("explore 3 approaches") + assert result == "approaches.html" + + def test_special_chars_ignored(self) -> None: + result = generate_filename("auth: OAuth2 vs JWT!") + assert result == "auth-oauth-vs-jwt.html" + + def test_deterministic_same_input_same_output(self) -> None: + results = [generate_filename("compare auth approaches") for _ in range(10)] + assert all(r == results[0] for r in results) + + def test_all_stop_words(self) -> None: + result = generate_filename("the a an to for of in on at by") + assert result == "artifact.html" + + def test_shape_code_review_prepended(self) -> None: + result = generate_filename("auth module refactor", shape="code-review") + assert result.startswith("code-review-") + assert result.endswith(".html") + + +@pytest.mark.slow +class TestCLIInterface: + """Integration tests via subprocess.""" + + def test_cli_basic(self) -> None: + cmd = [sys.executable, SCRIPT, "--request", "explore 3 approaches to rate limiting"] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + assert proc.returncode == 0 + assert proc.stdout.strip().endswith(".html") + + def test_cli_with_shape(self) -> None: + cmd = [sys.executable, SCRIPT, "--request", "rate limiting strategies", "--shape", "spec"] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + assert proc.returncode == 0 + assert proc.stdout.strip().startswith("spec-") + + def test_cli_invalid_shape_exits_1(self) -> None: + cmd = [sys.executable, SCRIPT, "--request", "test", "--shape", "banana"] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + assert proc.returncode == 1 + + def test_cli_output_has_no_extra_whitespace(self) -> None: + cmd = [sys.executable, SCRIPT, "--request", "build a dashboard"] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + assert proc.returncode == 0 + output = proc.stdout.strip() + assert " " not in output + assert output.endswith(".html") diff --git a/skills/meta/html-artifact/scripts/tests/test_select_references.py b/skills/meta/html-artifact/scripts/tests/test_select_references.py new file mode 100644 index 00000000..908154ca --- /dev/null +++ b/skills/meta/html-artifact/scripts/tests/test_select_references.py @@ -0,0 +1,93 @@ +"""Tests for select-references.py — deterministic reference file selector.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPT = str(Path(__file__).parent.parent / "select-references.py") + +# --- Import module directly for unit tests --- +sys.path.insert(0, str(Path(__file__).parent.parent)) +from importlib import import_module + +select_mod = import_module("select-references") +select_references = select_mod.select_references + + +class TestSelectReferencesDirect: + """Unit tests calling select_references() directly.""" + + @pytest.mark.parametrize( + "shape,expected_specific", + [ + ("spec", "references/shape-spec-exploration.md"), + ("code-review", "references/shape-code-review.md"), + ("prototype", "references/shape-design-prototype.md"), + ("report", "references/shape-report-research.md"), + ("editor", "references/shape-custom-editor.md"), + ("data-viz", "references/shape-data-visualization.md"), + ], + ) + def test_each_shape_returns_correct_specific(self, shape: str, expected_specific: str) -> None: + result = select_references(shape) + assert result["shape"] == shape + assert result["shape_specific"] == [expected_specific] + + def test_always_load_present(self) -> None: + result = select_references("spec") + assert result["always_load"] == [ + "references/design-system.md", + "references/interaction-patterns.md", + ] + + def test_all_files_is_union(self) -> None: + result = select_references("report") + assert result["all_files"] == result["always_load"] + result["shape_specific"] + + def test_invalid_shape_raises(self) -> None: + with pytest.raises(ValueError, match="Invalid shape"): + select_references("invalid") + + def test_deterministic_same_input_same_output(self) -> None: + results = [select_references("data-viz") for _ in range(5)] + assert all(r == results[0] for r in results) + + +@pytest.mark.slow +class TestCLIInterface: + """Integration tests via subprocess.""" + + def test_cli_valid_shape(self) -> None: + cmd = [sys.executable, SCRIPT, "--shape", "spec"] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + assert proc.returncode == 0 + result = json.loads(proc.stdout) + assert result["shape"] == "spec" + assert len(result["all_files"]) == 3 + + def test_cli_invalid_shape_exits_1(self) -> None: + cmd = [sys.executable, SCRIPT, "--shape", "banana"] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + assert proc.returncode == 1 + + def test_cli_compact_json(self) -> None: + cmd = [sys.executable, SCRIPT, "--shape", "editor", "--json-compact"] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + assert proc.returncode == 0 + output = proc.stdout.strip() + assert "\n" not in output.rstrip("\n") + parsed = json.loads(output) + assert parsed["shape"] == "editor" + + @pytest.mark.parametrize("shape", ["spec", "code-review", "prototype", "report", "editor", "data-viz"]) + def test_cli_all_shapes_succeed(self, shape: str) -> None: + cmd = [sys.executable, SCRIPT, "--shape", shape] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + assert proc.returncode == 0 + result = json.loads(proc.stdout) + assert result["shape"] == shape diff --git a/skills/meta/html-artifact/scripts/tests/test_validate_artifact.py b/skills/meta/html-artifact/scripts/tests/test_validate_artifact.py index f5fc8214..336ec819 100644 --- a/skills/meta/html-artifact/scripts/tests/test_validate_artifact.py +++ b/skills/meta/html-artifact/scripts/tests/test_validate_artifact.py @@ -31,6 +31,21 @@ """ +VALID_HTML_WITH_COPY = """ + + + + Editor + + + +

Editor

+ + +""" + MINIMAL_VALID = """ X @@ -199,6 +214,60 @@ def test_deterministic_same_input_same_output(self) -> None: finally: path.unlink() + def test_export_button_check_skipped_without_shape(self) -> None: + path = _write_tmp(VALID_HTML) + try: + result = validate_artifact(path) + assert "has_export_button" not in result.checks + finally: + path.unlink() + + def test_export_button_check_skipped_for_non_export_shape(self) -> None: + path = _write_tmp(VALID_HTML) + try: + result = validate_artifact(path, shape="spec") + assert "has_export_button" not in result.checks + finally: + path.unlink() + + def test_export_button_warning_for_editor_without_copy(self) -> None: + path = _write_tmp(VALID_HTML) + try: + result = validate_artifact(path, shape="editor") + assert not result.checks["has_export_button"] + assert any("copy/export" in w for w in result.warnings) + # Warning, not error — still valid + assert result.valid + finally: + path.unlink() + + def test_export_button_warning_for_prototype_without_copy(self) -> None: + path = _write_tmp(VALID_HTML) + try: + result = validate_artifact(path, shape="prototype") + assert not result.checks["has_export_button"] + assert any("copy/export" in w for w in result.warnings) + finally: + path.unlink() + + def test_export_button_passes_with_clipboard(self) -> None: + path = _write_tmp(VALID_HTML_WITH_COPY) + try: + result = validate_artifact(path, shape="editor") + assert result.checks["has_export_button"] + assert not any("copy/export" in w for w in result.warnings) + finally: + path.unlink() + + def test_export_button_passes_with_copy_word(self) -> None: + html = VALID_HTML.replace("", "") + path = _write_tmp(html) + try: + result = validate_artifact(path, shape="prototype") + assert result.checks["has_export_button"] + finally: + path.unlink() + @pytest.mark.slow class TestCLIInterface: @@ -239,3 +308,29 @@ def test_cli_compact_json(self) -> None: assert parsed["valid"] is True finally: path.unlink() + + def test_cli_shape_flag_editor(self) -> None: + path = _write_tmp(VALID_HTML) + try: + result, code = run_validate(str(path)) + assert "has_export_button" not in result["checks"] + + # Now with --shape editor + cmd = [sys.executable, SCRIPT, str(path), "--shape", "editor"] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + shaped_result = json.loads(proc.stdout) + assert "has_export_button" in shaped_result["checks"] + assert shaped_result["checks"]["has_export_button"] is False + finally: + path.unlink() + + def test_cli_shape_flag_with_copy(self) -> None: + path = _write_tmp(VALID_HTML_WITH_COPY) + try: + cmd = [sys.executable, SCRIPT, str(path), "--shape", "editor"] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + assert proc.returncode == 0 + result = json.loads(proc.stdout) + assert result["checks"]["has_export_button"] is True + finally: + path.unlink() diff --git a/skills/meta/html-artifact/scripts/validate-artifact.py b/skills/meta/html-artifact/scripts/validate-artifact.py index 75daf830..248eb452 100644 --- a/skills/meta/html-artifact/scripts/validate-artifact.py +++ b/skills/meta/html-artifact/scripts/validate-artifact.py @@ -12,6 +12,7 @@ Usage: python3 skills/meta/html-artifact/scripts/validate-artifact.py path/to/artifact.html python3 skills/meta/html-artifact/scripts/validate-artifact.py artifact.html --json-compact + python3 skills/meta/html-artifact/scripts/validate-artifact.py artifact.html --shape editor """ from __future__ import annotations @@ -141,11 +142,40 @@ def _check_valid_structure(content: str, result: ValidationResult) -> None: result.errors.append(f"Missing structural tags: {', '.join(missing)}.") -def validate_artifact(file_path: Path) -> ValidationResult: +EXPORT_SHAPES = frozenset({"editor", "prototype"}) + + +def _check_export_button(content: str, shape: str, result: ValidationResult) -> None: + """For editor/prototype shapes, check for copy/export functionality in scripts. + + This is a warning, not an error — the shape context isn't always available. + """ + if shape not in EXPORT_SHAPES: + return + + # Look for export/copy patterns in ", content, re.IGNORECASE | re.DOTALL) + script_content = " ".join(script_blocks) + + has_clipboard = "navigator.clipboard" in script_content + has_copy_func = "copyToClipboard" in script_content + has_copy = bool(re.search(r"\bcopy\b", script_content, re.IGNORECASE)) + + passed = has_clipboard or has_copy_func or has_copy + result.checks["has_export_button"] = passed + if not passed: + result.warnings.append( + f"Shape '{shape}' should include copy/export functionality " + "(navigator.clipboard, copyToClipboard, or copy function)." + ) + + +def validate_artifact(file_path: Path, shape: str | None = None) -> ValidationResult: """Run all validation checks on an HTML artifact file. Args: file_path: Path to the .html file to validate. + shape: Optional artifact shape. When provided, enables shape-specific checks. Returns: ValidationResult with all check outcomes. @@ -162,6 +192,9 @@ def validate_artifact(file_path: Path) -> ValidationResult: _check_no_empty_body(content, result) _check_valid_structure(content, result) + if shape is not None: + _check_export_button(content, shape, result) + return result @@ -170,6 +203,9 @@ def main() -> None: parser = argparse.ArgumentParser(description="Validate a generated HTML artifact.") parser.add_argument("file", help="Path to the .html file to validate.") parser.add_argument("--json-compact", action="store_true", help="Output compact JSON (no indentation).") + parser.add_argument( + "--shape", default=None, help="Artifact shape for shape-specific checks (e.g., editor, prototype)." + ) args = parser.parse_args() file_path = Path(args.file) @@ -182,7 +218,7 @@ def main() -> None: sys.exit(2) try: - result = validate_artifact(file_path) + result = validate_artifact(file_path, shape=args.shape) except (OSError, UnicodeDecodeError) as e: error_result = {"valid": False, "checks": {}, "warnings": [], "errors": [f"Cannot read file: {e}"]} indent = None if args.json_compact else 2