From e4187d0054c381008c538c289bd222f654af2478 Mon Sep 17 00:00:00 2001 From: Miguel_LZPF Date: Tue, 19 May 2026 20:17:45 +0200 Subject: [PATCH] feat: add cross-tool orientation, CI schema validation, and Claude Code surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the contributor- and AI-tool-facing infrastructure on top of the modular docs from the previous commit. Lands AGENTS.md as the canonical cross-tool orientation (read natively by Cursor, Copilot, Codex, Aider, Cline, Continue, Cody, Devin, Replit Agent, and Claude Code), flips CLAUDE.md to a 22-line pointer that uses Claude Code's @-import to inline AGENTS.md, wires up plugin.json validation in CI, and adds the Claude-specific automation surfaces under .claude/. Cross-tool orientation: AGENTS.md (178 lines) — single source of truth: architecture, running the app, testing, git workflow, versioning, song formats, frontend and backend conventions, plugin authoring index, first-hour pitfalls, verification, house rules. CLAUDE.md (22 lines) — Claude Code memory file. Uses @AGENTS.md import (recursion depth 5) so the canonical content is inlined without duplication. Lists .claude/ surfaces. .github/copilot-instructions.md — Copilot custom instructions format; points at AGENTS.md and docs/PLUGIN_AUTHORING.md. .cursorrules — not added. Cursor reads AGENTS.md natively in 2026 and .cursorrules is legacy. Contribution hygiene (.github/): PULL_REQUEST_TEMPLATE.md — summary, linked issue, test plan, DCO and conventional-commit reminders. No AI-disclosure section. ISSUE_TEMPLATE/bug.yml — version, deployment, OS, plugins enabled, repro, logs (linked to docs/diagnostics-bundle-spec.md for redaction guidance). ISSUE_TEMPLATE/feature.yml — problem, proposed, alternatives, surface, plugin-author impact, license check. ISSUE_TEMPLATE/config.yml — disables blank issues; redirects plugin issues to plugin repos and security to the private advisory flow. CI: .github/workflows/validate-plugins.yml — runs on changes to plugins/*, schema/, CONTRIBUTING.md, the test file, or the workflow itself. Installs jsonschema and pytest, validates every plugins/*/plugin.json against schema/plugin.schema.json, and runs the license-allowlist subset check. tests/test_plugin_schema.py — 8 parametrized tests: schema is well-formed, the 3 in-tree manifests validate, manifest id matches its parent directory name, schema license enum is a subset of CONTRIBUTING's curated allowlist. requirements-test.txt — append jsonschema>=4.0. .github/workflows/sync-version.yml — comment retargeted to AGENTS.md "Versioning" section. Claude Code surfaces (.claude/): README.md — layout explanation. Spec-kit owns skills/speckit-*; repo-specific skills sit alongside. Hooks off by default; settings.json carries a commented opt-in example. skills/plugin-scaffold/SKILL.md — generates a plugin skeleton for type in {visualization, overlay, settings-only, routes-only}. skills/plugin-validate/SKILL.md — local pre-push check: validates plugin.json against schema, asserts declared files exist, enforces license allowlist. rules/plugin-author.md — globs scoped to plugins/**. Encodes the contracts from docs/PLUGIN_AUTHORING.md so AI suggestions don't drift from them (manifest required, context[\"log\"] over print, load_sibling over bare imports, playSong await discipline, settings.server_files conventions). agents/slopsmith-reviewer.md — plugin-aware reviewer subagent; invoke with @slopsmith-reviewer. 12-item checklist mirrors the rule and the schema. settings.json — empty hooks block plus a commented PostToolUse example for opt-in plugin.json validation on save. Inbound-ref updates (files we own): README.md — \"AI Agent Guide\" points at AGENTS.md and notes .claude/ and copilot-instructions are tool-specific. CONTRIBUTING.md — \"Plugin System in CLAUDE.md\" -> docs/ and schema/; \"Git Workflow\" -> AGENTS.md#git-workflow. docs/sloppak-spec.md — plugin-system table cell -> docs/. Out of scope (intentionally untouched): plugins/highway_3d/README.md (gitlink — plugin owns its docs). .specify/memory/constitution.md and other spec-kit artefacts (spec-kit owns that surface; CLAUDE.md still resolves transitively via the @-import). Verification: pytest -q # backend + schema tests pass python -c \"import json,glob,jsonschema; s=json.load(open('schema/plugin.schema.json')); [jsonschema.validate(json.load(open(p)), s) for p in sorted(glob.glob('plugins/*/plugin.json'))]\" Signed-off-by: Miguel_LZPF --- .claude/README.md | 42 ++++++ .claude/agents/slopsmith-reviewer.md | 69 +++++++++ .claude/rules/plugin-author.md | 51 +++++++ .claude/settings.json | 16 +++ .claude/skills/plugin-scaffold/SKILL.md | 79 +++++++++++ .claude/skills/plugin-validate/SKILL.md | 102 ++++++++++++++ .github/ISSUE_TEMPLATE/bug.yml | 85 +++++++++++ .github/ISSUE_TEMPLATE/config.yml | 12 +- .github/ISSUE_TEMPLATE/feature.yml | 57 ++++++++ .github/PULL_REQUEST_TEMPLATE.md | 38 +++++ .github/copilot-instructions.md | 21 +++ .github/workflows/sync-version.yml | 2 +- .github/workflows/validate-plugins.yml | 70 ++++++++++ AGENTS.md | 178 ++++++++++++++++++++++++ CONTRIBUTING.md | 4 +- docs/sloppak-spec.md | 4 +- requirements-test.txt | 1 + tests/test_plugin_schema.py | 111 +++++++++++++++ 18 files changed, 936 insertions(+), 6 deletions(-) create mode 100644 .claude/README.md create mode 100644 .claude/agents/slopsmith-reviewer.md create mode 100644 .claude/rules/plugin-author.md create mode 100644 .claude/settings.json create mode 100644 .claude/skills/plugin-scaffold/SKILL.md create mode 100644 .claude/skills/plugin-validate/SKILL.md create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/feature.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/copilot-instructions.md create mode 100644 .github/workflows/validate-plugins.yml create mode 100644 AGENTS.md create mode 100644 tests/test_plugin_schema.py diff --git a/.claude/README.md b/.claude/README.md new file mode 100644 index 0000000..4775173 --- /dev/null +++ b/.claude/README.md @@ -0,0 +1,42 @@ +# `.claude/` — Claude Code surfaces + +This directory holds [Claude Code](https://claude.ai/code) artifacts: skills, rules, and subagents. **Other AI tools** (Cursor, Copilot, Codex, Aider) should read [`AGENTS.md`](../AGENTS.md) instead — `.claude/` is Claude-Code-specific. + +## Layout + +``` +.claude/ +├── agents/ Subagents (invoked via @) +│ └── slopsmith-reviewer.md Plugin-aware code review +├── rules/ Globs-scoped behaviour rules +│ └── plugin-author.md Rules that fire when editing plugins/** +├── skills/ Skills (description triggers auto-invocation) +│ ├── plugin-scaffold/SKILL.md Generate a new plugin skeleton +│ ├── plugin-validate/SKILL.md Validate a plugin.json against the schema +│ └── speckit-*/ Spec-kit skills (auto-generated; don't edit) +├── settings.json Repo defaults (no hooks enabled by default) +└── README.md You are here +``` + +## Conventions + +- **Spec-kit owns `skills/speckit-*`.** Don't edit those manually — they're regenerated by the spec-kit installer (`.specify/`). +- **Repo skills live alongside spec-kit skills** in `skills/`. New skills should have a kebab-case directory name and a `SKILL.md` with YAML frontmatter. +- **Hooks are off by default.** Hooks in `.claude/settings.json` run in every contributor's Claude Code session — enabling one imposes work on everyone. The settings file ships with `hooks: {}` and a commented example so anyone who wants to opt in can copy it locally. +- **Subagent invocation.** Use `@slopsmith-reviewer` to invoke a subagent explicitly. Plain auto-routing isn't currently configured. + +## Adding a new skill + +1. Create `.claude/skills//SKILL.md`. +2. YAML frontmatter must include `name` (must match directory) and `description` (must read like a sentence — Claude Code matches the description against user intent for auto-invocation). +3. Body: instructions Claude follows when the skill triggers. Keep it concrete and short — long skills bit-rot. + +## Adding a new rule + +1. Create `.claude/rules/.md`. +2. Frontmatter may include `globs:` (array of glob patterns scoping when the rule applies). Without `globs`, the rule loads for every session. +3. Body: terse, imperative guidance — "always do X", "never do Y", "if you see Z, …". + +## Why so little here? + +The maintainer's personal Claude Code rules (context-mode routing, RTK, `@git-worker`, etc.) deliberately do **not** live in this repo. Those are user-global concerns in `~/.claude/` — keeping them out of the repo keeps the shared surface lean and makes the repo equally useful to contributors using Cursor, Copilot, plain editors, etc. diff --git a/.claude/agents/slopsmith-reviewer.md b/.claude/agents/slopsmith-reviewer.md new file mode 100644 index 0000000..4234ac0 --- /dev/null +++ b/.claude/agents/slopsmith-reviewer.md @@ -0,0 +1,69 @@ +--- +name: slopsmith-reviewer +description: Plugin-aware code reviewer for Slopsmith. USE WHEN reviewing plugin changes, auditing a plugin against the manifest contract, checking that a plugin uses load_sibling / context["log"] / scoped shortcuts correctly, or verifying that a `plugin.json` matches the schema and the directory it lives in. Returns a structured pass/fail report with specific file:line citations. +tools: Read, Grep, Glob, Bash +model: sonnet +--- + +# slopsmith-reviewer + +Plugin-aware reviewer for Slopsmith. Use when reviewing a plugin's code or manifest. Does **not** duplicate the built-in `peer-review` skill — focus is narrow: the plugin contract surface defined in [`docs/PLUGIN_AUTHORING.md`](../../docs/PLUGIN_AUTHORING.md) and enforced by [`schema/plugin.schema.json`](../../schema/plugin.schema.json) and `tests/test_plugin_schema.py`. + +## When to invoke + +Use this agent when the user asks to: +- "review this plugin" +- "audit `plugins//`" +- "check the manifest" +- "verify the plugin uses load_sibling / logging correctly" +- "lint plugin" + +Do **not** invoke for general code review — use the built-in `peer-review` skill for that. + +## Inputs + +The user typically points at a directory: `plugins//`. If no directory is given, ask which plugin to review. + +## Checklist (every review must run these) + +Run each item; structure the output as `PASS` / `FAIL` / `N/A` with file:line citations. + +1. **Manifest exists and validates.** `plugins//plugin.json` exists. Run: + ```bash + python -c "import json,jsonschema; s=json.load(open('schema/plugin.schema.json')); jsonschema.validate(json.load(open('plugins//plugin.json')), s); print('OK')" + ``` +2. **Manifest `id` matches the directory name.** `tests/test_plugin_schema.py::test_in_tree_manifest_id_matches_directory` enforces this — but call it out in review. +3. **Declared files exist.** For every path-bearing field in `plugin.json` (`script`, `routes`, `tour`, `settings.html`, `settings.server_files`, `diagnostics.server_files`), `test -f plugins//` must succeed (or the path must be a directory if it ends with `/`). +4. **License is on the curated allowlist** if present. Cross-check `plugin.json.license` against the SPDX list in [`CONTRIBUTING.md`](../../CONTRIBUTING.md) "Plugin licensing". +5. **`type: "visualization"` ↔ `window.slopsmithViz_` factory.** If `type == "visualization"`, grep `script` for the factory declaration. +6. **Backend logging.** Grep `plugins//*.py` for `print(`, `traceback.print_exc(`, `logging.getLogger(`. Suggest `context["log"]` replacements. +7. **Sibling imports.** If `routes.py` exists and grep finds bare `from import` for any sibling Python file in the plugin dir, flag and recommend `context["load_sibling"]`. +8. **Frontend IIFE.** If `script` exists, check the top of the file isn't running top-level statements that leak to global scope. Wrapping in `(function () { 'use strict'; ... })();` is the convention. +9. **`playSong` wrapper discipline.** If the script reassigns `window.playSong`, confirm it calls the original and `await`s it. +10. **Shortcut scope discipline.** If the script calls `window.registerShortcut`, confirm `scope` is set (not relying on the `'global'` default) and that an `unregisterShortcut` / `panel.clearShortcuts()` cleanup path exists when the plugin can be torn down. +11. **`localStorage` prefix.** Grep for `localStorage.` usage; keys must start with ``. +12. **`settings.server_files` paths are safe.** Each entry must be a relpath — no leading `/`, no `..`, no backslashes. The schema enforces this but call it out. + +## Output format + +``` +plugin-review: +========================= +1. manifest validates PASS +2. id matches directory PASS +3. declared files exist FAIL — settings.server_files lists "missing_db.sqlite" but plugins//missing_db.sqlite is absent +... + +Total: 10 PASS / 1 FAIL / 1 N/A +Action items: +- Remove the dangling settings.server_files entry, or create the file. +- Replace `print(...)` calls at routes.py:42, routes.py:88 with `context["log"].info(...)`. +``` + +If everything passes, say so explicitly — silent success is unhelpful in a review context. + +## Out of scope + +- General code style / formatting — use the built-in `peer-review`. +- Bug-hunting beyond the plugin contract. +- Suggesting major refactors. Stay on contract compliance. diff --git a/.claude/rules/plugin-author.md b/.claude/rules/plugin-author.md new file mode 100644 index 0000000..41a9c2e --- /dev/null +++ b/.claude/rules/plugin-author.md @@ -0,0 +1,51 @@ +--- +name: plugin-author +description: Rules that apply when editing files under plugins/**. Enforces the plugin contracts documented in docs/PLUGIN_AUTHORING.md. +globs: + - "plugins/**" +--- + +# Plugin authoring rules + +These rules apply only when editing files under `plugins/**`. They encode the contracts described in [`docs/PLUGIN_AUTHORING.md`](../../docs/PLUGIN_AUTHORING.md) so AI suggestions don't drift from them. + +## Manifest + +- **`plugin.json` is required** and must validate against [`schema/plugin.schema.json`](../../schema/plugin.schema.json). Required fields: `id`, `name`. The `id` must match the parent directory name (the loader keys discovery by directory; drift breaks plugin lookup). +- **License must come from the curated allowlist** if the plugin is intended for the curated list. See [`CONTRIBUTING.md`](../../CONTRIBUTING.md) "Plugin licensing". +- **`type: "visualization"`** requires a `script` field exporting `window.slopsmithViz_`. See [`docs/plugin-visualization-contracts.md`](../../docs/plugin-visualization-contracts.md). + +## Backend (`routes.py`) + +- **Use `context["log"]`, never `print()` or `traceback.print_exc()`.** The CI workflow blocks `print(` and `traceback.print_exc(` in `server.py` / `lib/`; plugin code should follow the same rule. The provided logger is a stdlib `logging.Logger` namespaced to `slopsmith.plugin.` with correlation IDs, JSON mode, and rotation already wired. See [`docs/plugin-logging.md`](../../docs/plugin-logging.md). +- **Multi-file plugins must use `context["load_sibling"]("")`**, not bare `from import X`. Two plugins shipping a same-named helper collide via `sys.modules`. See [`docs/plugin-sibling-imports.md`](../../docs/plugin-sibling-imports.md). +- **`setup(app, context)` is the required entry.** Don't run side effects at import time. + +## Frontend (`screen.js`) + +- **Wrap in an IIFE** — `(function () { 'use strict'; ... })();`. Frontend scripts share global scope; leaking variables collides with other plugins. +- **Hook `window.playSong` carefully** — always call the original, always `await` it. Wrappers run outermost-first; awaiting yields to the event loop and WebSocket messages can arrive before the outer wrapper finishes setup. Use `highway.getSongInfo()` as a fallback rather than relying solely on `_onReady`. +- **Hook `window.showScreen`** — clean up your plugin's state when the user leaves the player screen. +- **Use `window.slopsmith.emit` / `on`** for cross-plugin communication. Don't poll other plugins' globals. +- **Register shortcuts with `window.registerShortcut({ key, scope, handler })`** and clean up with `window.unregisterShortcut(key, scope)` — pass the same scope you registered with (default `'global'` won't match `'player'` / `'plugin-*'`). For panel-scoped registries, prefer `panel.clearShortcuts()`. See [`docs/plugin-keyboard-shortcuts.md`](../../docs/plugin-keyboard-shortcuts.md). + +## State and config + +- **`localStorage` keys must be prefixed with the plugin id** to avoid collisions. +- **`settings.server_files` declares config-dir paths the plugin wants included in the Settings export/import flow.** Relpaths only — no `..`, no abs paths, no backslashes. See [`docs/plugin-manifest.md`](../../docs/plugin-manifest.md). +- **`diagnostics.server_files` / `diagnostics.callable`** declares what enters the Export Diagnostics bundle. Keep payloads under 100 KB and don't include secrets. See [`docs/plugin-diagnostics.md`](../../docs/plugin-diagnostics.md). + +## Visualization specifics + +When `plugin.json` declares `"type": "visualization"`: + +- **Factory must be `window.slopsmithViz_`** where `` matches `plugin.json`. +- **Factory must return a fresh object on each call** — splitscreen creates N instances. +- **The renderer owns its `getContext()` call.** Declare `contextType: '2d'` or `'webgl2'` on the returned object so the highway can swap the canvas element when needed (`getContext` is one-shot per canvas). +- **`draw(bundle)` receives difficulty-filtered arrays** — never read from `_filteredNotes` or other internals. + +See [`docs/plugin-visualization-contracts.md`](../../docs/plugin-visualization-contracts.md) for the full lifecycle and the Overlay + Note-state-provider contracts. + +## Testing + +When changing plugin internals, add or update a test under `tests/` (Python) or `tests/js/` (Node) or `tests/browser/` (Playwright). See [`docs/testing-plugins.md`](../../docs/testing-plugins.md) for fixtures (`isolate_logging`, `reset_plugin_state`). diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..d814957 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "_comment": "Repo-default Claude Code settings for Slopsmith contributors. Hooks are intentionally empty by default — see commented example below.", + + "hooks": {}, + + "_commented_example_hooks": { + "_comment": "Copy the block below into the empty `hooks` object above if you want plugin.json edits to be auto-validated against schema/plugin.schema.json on save. Per-user opt-in; not enabled by default because hooks run in every Claude Code session that loads this repo.", + "PostToolUse": [ + { + "matcher": "Write|Edit", + "command": "if echo \"$CLAUDE_TOOL_INPUT_FILE_PATH\" | grep -qE '^plugins/[^/]+/plugin\\.json$'; then python -c \"import json,sys,jsonschema; s=json.load(open('schema/plugin.schema.json')); m=json.load(open('$CLAUDE_TOOL_INPUT_FILE_PATH')); jsonschema.validate(m,s); print('OK','$CLAUDE_TOOL_INPUT_FILE_PATH')\"; fi" + } + ] + } +} diff --git a/.claude/skills/plugin-scaffold/SKILL.md b/.claude/skills/plugin-scaffold/SKILL.md new file mode 100644 index 0000000..822fa17 --- /dev/null +++ b/.claude/skills/plugin-scaffold/SKILL.md @@ -0,0 +1,79 @@ +--- +name: plugin-scaffold +description: Scaffold a new Slopsmith plugin skeleton. USE WHEN the user asks to create a new plugin, scaffold a plugin, bootstrap a plugin, new visualization plugin, new overlay plugin, new settings-only plugin, plugin starter, plugin skeleton. Args needed - plugin slug (snake_case) and type (visualization / overlay / settings-only / routes-only). Generates plugins// with plugin.json, screen.js, and optional routes.py / settings.html / Playwright test stub matching the requested type. +--- + +# plugin-scaffold + +Generates a minimum-viable Slopsmith plugin skeleton matching a requested shape. The output validates against [`schema/plugin.schema.json`](../../../schema/plugin.schema.json). + +## When to invoke + +The user says one of: +- "scaffold a new plugin called X" +- "create a visualization plugin" +- "new overlay plugin" +- "plugin starter for settings" +- "bootstrap a routes-only plugin" + +If the plugin slug or type is missing, ask once. + +## Inputs + +| Arg | Required | Values | Notes | +|---|---|---|---| +| `id` | yes | snake_case | Becomes plugin's `id` field and directory name | +| `name` | optional | string | Defaults to title-case of `id` | +| `type` | yes | `visualization` / `overlay` / `settings-only` / `routes-only` | Determines which files get scaffolded | + +## What to generate + +**Common to all types:** + +- `plugins//plugin.json` — minimum schema-valid manifest. Set `id`, `name`, `version: "0.1.0"`, and `license: "AGPL-3.0-only"` by default (ask if a different license is desired). + +**`type=visualization`** — adds: +- `"type": "visualization"` and `"script": "screen.js"` to manifest +- `screen.js` exporting `window.slopsmithViz_ = function () { return { contextType: '2d', init(canvas, bundle) { this.ctx = canvas.getContext('2d'); }, draw(bundle) { /* TODO */ }, destroy() {} }; };` plus a static `matchesArrangement` example commented out +- `tests/browser/.spec.ts` — Playwright stub that loads the app and asserts the plugin's factory is registered + +**`type=overlay`** — adds: +- `"script": "screen.js"` to manifest (no `type` declared — overlays don't use the picker) +- `screen.js` scaffolding a navbar toggle, an own-canvas + own-rAF loop reading `highway.getNotes()` / `getChords()` / `getTime()`, and respecting `highway.isDefaultRenderer()` if using `highway.project` / `fretX` +- `tests/browser/.spec.ts` — toggle on / off test + +**`type=settings-only`** — adds: +- `"settings": { "html": "settings.html" }` to manifest +- `settings.html` — empty form skeleton with explanatory comments +- `screen.js` reading/writing `localStorage` keys prefixed with `_` + +**`type=routes-only`** — adds: +- `"routes": "routes.py"` to manifest +- `routes.py` with `def setup(app, context):` that registers one example route and uses `context["log"].info("plugin ready")` (never `print()`) +- `tests/test__routes.py` — FastAPI TestClient stub + +## After scaffolding + +Run validation locally: + +```bash +python -c "import json,jsonschema; s=json.load(open('schema/plugin.schema.json')); jsonschema.validate(json.load(open('plugins//plugin.json')), s); print('OK')" +``` + +Then point the user at [`docs/PLUGIN_AUTHORING.md`](../../../docs/PLUGIN_AUTHORING.md) and the relevant contract doc for the type they chose. + +## Don'ts + +- Don't add fields to the manifest that aren't in the schema. If the user wants something custom, ask whether it should become a real field — that's a `schema/plugin.schema.json` change, not a plugin-local convention. +- Don't scaffold a `requirements.txt` without confirming the deps. The plugin loader installs them on first load; an accidental dep slows everyone's startup. +- Don't pre-fill `localStorage` keys without a prefix. Collisions across plugins are real. +- Don't generate hidden side effects at import time (`screen.js` top level or `routes.py` top level). Keep all wiring inside the IIFE / `setup()`. + +## Verification + +The scaffolded plugin should pass: + +```bash +pytest tests/test_plugin_schema.py::test_in_tree_manifest_validates -v +pytest tests/test_plugin_schema.py::test_in_tree_manifest_id_matches_directory -v +``` diff --git a/.claude/skills/plugin-validate/SKILL.md b/.claude/skills/plugin-validate/SKILL.md new file mode 100644 index 0000000..d357230 --- /dev/null +++ b/.claude/skills/plugin-validate/SKILL.md @@ -0,0 +1,102 @@ +--- +name: plugin-validate +description: Validate a Slopsmith plugin's plugin.json against the schema and run structural checks. USE WHEN the user asks to validate a plugin, check the manifest, plugin.json errors, lint plugin, verify plugin structure, plugin license check, or audit plugin contract. Runs JSON Schema validation, file-existence checks for declared script/routes/settings.html/tour paths, and license-allowlist check. +--- + +# plugin-validate + +Runs the same checks `.github/workflows/validate-plugins.yml` + `tests/test_plugin_schema.py` run in CI, but **locally and instantly** — useful for catching errors before push. + +## When to invoke + +The user says one of: +- "validate plugins/" +- "check the manifest for X" +- "lint this plugin" +- "verify my plugin.json" +- "plugin license check" + +Works on either a specific plugin (e.g. `plugins/highway_3d/`) or all in-tree plugins if none is specified. + +## What to run + +```bash +# All in-tree plugins (default) +python <<'PY' +import json, glob, sys, jsonschema, pathlib +schema = json.load(open('schema/plugin.schema.json')) +jsonschema.Draft202012Validator.check_schema(schema) +ok = True +for path in sorted(glob.glob('plugins/*/plugin.json')): + plugin_dir = pathlib.Path(path).parent + plugin_id = plugin_dir.name + m = json.load(open(path)) + # 1. Schema + try: + jsonschema.validate(m, schema) + except jsonschema.ValidationError as e: + print(f"FAIL {path}: {e.message} (at {list(e.absolute_path)})") + ok = False + continue + # 2. id == directory name + if m['id'] != plugin_id: + print(f"FAIL {path}: id={m['id']!r} but directory is {plugin_id!r}") + ok = False + # 3. Declared files exist + for field in ('script', 'routes', 'tour'): + if field in m and not (plugin_dir / m[field]).exists(): + print(f"FAIL {path}: {field}={m[field]!r} but file missing") + ok = False + if 'settings' in m and 'html' in m['settings']: + h = m['settings']['html'] + if not (plugin_dir / h).exists(): + print(f"FAIL {path}: settings.html={h!r} but file missing") + ok = False + for field in ('settings', 'diagnostics'): + if field in m and 'server_files' in m[field]: + for relpath in m[field]['server_files']: + # server_files paths live under context["config_dir"], NOT the + # plugin dir, so we don't existence-check them here. We only + # confirm the path *looks* safe (already enforced by schema). + if '..' in relpath or relpath.startswith('/') or '\\' in relpath: + print(f"FAIL {path}: {field}.server_files contains unsafe path {relpath!r}") + ok = False + print(f"OK {path}") +sys.exit(0 if ok else 1) +PY +``` + +## Targeted invocation + +If the user names a specific plugin, swap the glob for `plugins//plugin.json` and report on just that one. + +## License-allowlist check + +If the user specifically asks for a license check (or the plugin declares `license` in `plugin.json`), additionally run: + +```bash +pytest tests/test_plugin_schema.py::test_schema_license_enum_subset_of_contributing_allowlist -v --noconftest +``` + +(or skip `--noconftest` if `structlog` is installed locally). + +## Output + +Use the format from the script: `OK ` per validated manifest, `FAIL : ` per failure. Add a one-line summary: + +``` +Result: 3/3 plugins valid (OK app_tour_library, app_tour_settings, highway_3d) +``` + +or + +``` +Result: 2/3 plugins valid; 1 FAIL (see above) +``` + +## Related + +- [`schema/plugin.schema.json`](../../../schema/plugin.schema.json) +- [`tests/test_plugin_schema.py`](../../../tests/test_plugin_schema.py) +- [`.github/workflows/validate-plugins.yml`](../../../.github/workflows/validate-plugins.yml) +- [`docs/plugin-manifest.md`](../../../docs/plugin-manifest.md) diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..621e700 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,85 @@ +name: Bug report +description: Report something broken in Slopsmith core (server, frontend, library, player). +title: "[bug] " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for reporting! Before submitting: + - For **plugin** issues, please open the issue in the plugin's own repository instead — plugins under `plugins/` are separate projects. + - For sensitive logs, redact paths/credentials. The `docs/diagnostics-bundle-spec.md` describes the redaction surface used by Export Diagnostics. + + - type: input + id: version + attributes: + label: Slopsmith version + description: "Contents of the `VERSION` file at the root, or what the navbar badge shows." + placeholder: "e.g. 0.2.8" + validations: + required: true + + - type: dropdown + id: deployment + attributes: + label: How are you running Slopsmith? + options: + - Docker Compose (docker-compose.yml) + - Bare Python (python main.py) + - Proxmox CT (build-proxmox-ct.sh) + - slopsmith-desktop bundle + - Other (describe below) + validations: + required: true + + - type: input + id: os + attributes: + label: Host OS / browser + placeholder: "e.g. macOS 15.4 + Chrome 134; Linux + Firefox 142" + validations: + required: true + + - type: input + id: plugins + attributes: + label: Plugins enabled + description: "List the plugins active when the bug happened. Most bugs reproduce only with certain plugins." + placeholder: "highway_3d, fretboard, note_detect" + + - type: textarea + id: repro + attributes: + label: Reproduction steps + description: "Numbered steps. Include song format (PSARC / sloppak / loose) and arrangement type if relevant." + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected vs. actual behaviour + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs / diagnostics + description: | + Either paste relevant log lines (set `LOG_LEVEL=DEBUG` or `LOG_FORMAT=json` if useful) **or** attach an Export Diagnostics bundle. See `docs/diagnostics-bundle-spec.md` for what's inside the bundle and what's redacted. + render: text + + - type: checkboxes + id: terms + attributes: + label: Confirmations + options: + - label: I searched existing issues and didn't find a duplicate + required: true + - label: This is a core bug, not a plugin bug + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 0086358..7422f43 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1 +1,11 @@ -blank_issues_enabled: true +blank_issues_enabled: false +contact_links: + - name: Plugin issues + url: https://github.com/slopsmith/slopsmith#plugins + about: Plugins live in their own repositories. Open plugin bugs/features in the plugin's own repo. + - name: Documentation questions + url: https://github.com/slopsmith/slopsmith/tree/main/docs + about: Plugin contracts, sloppak spec, diagnostics, and note-detect tuning are documented under docs/. Try there first. + - name: Security disclosures + url: https://github.com/slopsmith/slopsmith/security + about: For security issues, please use GitHub's private security advisory flow rather than a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..777a692 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,57 @@ +name: Feature request +description: Propose a feature, behaviour change, or new plugin contract. +title: "[feature] " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for the suggestion! For **plugin features**, open the issue in the plugin's own repository unless the change requires new core API. + + - type: textarea + id: problem + attributes: + label: What problem does this solve? + description: "Lead with the user/contributor pain — the solution shape often falls out of a sharp problem statement." + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: "What you'd like to see. UI mockups, code sketches, config examples all welcome." + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + + - type: dropdown + id: surface + attributes: + label: Which surface does this touch? + multiple: true + options: + - Server (FastAPI / lib/) + - Frontend (static/) + - Plugin contract (new field / event / API) + - Sloppak format + - WebSocket protocol + - Diagnostics + - Docs only + - Other (describe in proposal) + + - type: textarea + id: plugin_impact + attributes: + label: Plugin author impact + description: "Does this require existing plugins to change? Add a new optional field? Break a contract? Be specific so the maintainer can size compatibility work." + + - type: checkboxes + id: license + attributes: + label: License compatibility + options: + - label: If suggesting third-party code, it's AGPL-3.0-compatible (see CONTRIBUTING.md curated list) + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..201af83 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,38 @@ + + +## Summary + + + +## Linked issue + + + +## Test plan + + + +- [ ] `pytest -q` passes locally +- [ ] `npm run test:js` passes locally +- [ ] `npm test` (Playwright) passes locally — *or* CI will run it +- [ ] Verified in `docker compose up` (live-reload working directory) +- [ ] Plugin manifest validates against `schema/plugin.schema.json` +- [ ] Not applicable — explain below + +## Screenshots / recordings + + + +## Checklist + +- [ ] DCO sign-off on every commit (`Signed-off-by:` trailer) +- [ ] Conventional-commit subject (`feat(scope):`, `fix(scope):`, `docs:`, `chore:`) +- [ ] `CHANGELOG.md` `[Unreleased]` section updated (skip for chore/docs) +- [ ] Documentation updated if behaviour or contracts changed diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..9a20b30 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,21 @@ +# GitHub Copilot instructions + +This file customizes GitHub Copilot Chat and Copilot inline suggestions for the Slopsmith repository. + +## Read first + +- [`AGENTS.md`](../AGENTS.md) — canonical project orientation (architecture, conventions, first-hour pitfalls, verification). This file is a thin pointer; the real content lives there. +- [`docs/PLUGIN_AUTHORING.md`](../docs/PLUGIN_AUTHORING.md) — plugin work entry point + +## House rules + +- **License: AGPL-3.0-only.** Inbound contributions are AGPL-compatible. Do not suggest code copied verbatim from incompatible sources. +- **DCO sign-off required** on every commit (`git commit -s`). +- **No frontend frameworks.** Vanilla JS, Canvas, Tailwind classes. Do not suggest React/Vue/Svelte additions. +- **Plugin backend logging.** Suggest `context["log"]`, never `print()`. +- **Plugin Python imports.** For cross-file backend plugins, suggest `context["load_sibling"]("module_name")` instead of bare `from module_name import X`. +- **DCO/license headers.** When creating a new file in the main repo, no license header is needed (the LICENSE file at root governs). Plugin authors should add an SPDX-License-Identifier comment to their plugin's source files; the `license` field in `plugin.json` must match the allowlist in [`CONTRIBUTING.md`](../CONTRIBUTING.md). + +## Validation + +When suggesting changes to a `plugin.json`, validate against [`schema/plugin.schema.json`](../schema/plugin.schema.json). diff --git a/.github/workflows/sync-version.yml b/.github/workflows/sync-version.yml index 9262925..255e454 100644 --- a/.github/workflows/sync-version.yml +++ b/.github/workflows/sync-version.yml @@ -3,7 +3,7 @@ name: Sync VERSION from desktop release # Updates the VERSION file in this repo whenever slopsmith-desktop # publishes a new tagged release. slopsmith-desktop's build.yml # dispatches the `desktop-released` event at the end of a successful -# tag build (see docs in CLAUDE.md). A `workflow_dispatch` trigger is +# tag build (see Versioning section in AGENTS.md). A `workflow_dispatch` trigger is # kept for manual testing / recovery. # # Related issue: #81. diff --git a/.github/workflows/validate-plugins.yml b/.github/workflows/validate-plugins.yml new file mode 100644 index 0000000..665115c --- /dev/null +++ b/.github/workflows/validate-plugins.yml @@ -0,0 +1,70 @@ +name: Validate plugins + +on: + push: + branches: [main] + paths: + - 'plugins/**/plugin.json' + - 'schema/plugin.schema.json' + - 'CONTRIBUTING.md' + - 'tests/test_plugin_schema.py' + - '.github/workflows/validate-plugins.yml' + pull_request: + branches: [main] + paths: + - 'plugins/**/plugin.json' + - 'schema/plugin.schema.json' + - 'CONTRIBUTING.md' + - 'tests/test_plugin_schema.py' + - '.github/workflows/validate-plugins.yml' + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Install jsonschema + run: | + python -m pip install --upgrade pip + pip install -r requirements-test.txt + + - name: Validate every in-tree plugin.json against schema + run: | + python - <<'PY' + import glob, json, sys + import jsonschema + + schema_path = "schema/plugin.schema.json" + with open(schema_path) as f: + schema = json.load(f) + + # Validate the schema itself is a well-formed JSON Schema. + jsonschema.Draft202012Validator.check_schema(schema) + + manifests = sorted(glob.glob("plugins/*/plugin.json")) + if not manifests: + print("No plugin manifests found under plugins/*/plugin.json") + sys.exit(0) + + failures = [] + for path in manifests: + with open(path) as f: + manifest = json.load(f) + try: + jsonschema.validate(manifest, schema) + print(f"OK {path}") + except jsonschema.ValidationError as e: + failures.append((path, e)) + print(f"FAIL {path}: {e.message} (at {list(e.absolute_path)})") + if failures: + sys.exit(1) + PY + + - name: Run schema sanity tests + run: pytest tests/test_plugin_schema.py -v diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2d539aa --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,178 @@ +# AGENTS.md + +Project orientation for AI coding assistants (Cursor, GitHub Copilot, OpenAI Codex, Aider, Claude Code, Cline, Continue, Cody, Devin, …) and human contributors. + +Slopsmith is a self-hosted web app for browsing, playing, and practicing Rocksmith 2014 Custom DLC. It runs as a Docker container with a FastAPI backend (`server.py`), vanilla JavaScript frontend (`static/`), shared Python libraries (`lib/`), and an extensive plugin system (`plugins/`). No frontend frameworks — plain JS, HTML, Tailwind CSS. AGPL-3.0-only. + +This file is the canonical orientation. Tool-specific automation (Claude skills/subagents/rules, Copilot instructions, etc.) lives in [`.claude/`](.claude/) and [`.github/copilot-instructions.md`](.github/copilot-instructions.md); both point back here. For plugin work, start at [`docs/PLUGIN_AUTHORING.md`](docs/PLUGIN_AUTHORING.md). + +## Architecture quick reference + +``` +server.py FastAPI app — library API, WebSocket highway, plugin loading +main.py Programmatic uvicorn entrypoint — installs structlog before boot +logging_setup.py Structured logging + correlation IDs (LOG_LEVEL/LOG_FORMAT/LOG_FILE) +static/ + app.js Main frontend — screens, library views, player, plugin loader + highway.js Canvas note highway renderer (createHighway factory) + diagnostics.js window.slopsmith.diagnostics namespace (loaded first in ) + index.html Single-page app shell +lib/ + song.py Core data models (Note, Chord, Arrangement, Song) + psarc.py PSARC archive reading and extraction + sloppak.py Sloppak format support + sloppak_convert.py PSARC → sloppak conversion + Demucs stem split + audio.py WEM/OGG/MP3 audio handling + retune.py Pitch-shifting logic + tunings.py Tuning name/offset utilities + gp2rs.py Guitar Pro to Rocksmith XML conversion + gp2midi.py Guitar Pro to MIDI +plugins/ + __init__.py Plugin discovery, loading, requirements install, load_sibling + / Each plugin is its own directory (often a git submodule) +schema/ + plugin.schema.json JSON Schema for plugin.json (validated in CI) +docs/ Plugin contracts + format specs (see Plugin authoring below) +tests/ pytest + tests/js/ (node --test) + tests/browser/ (Playwright) +specs/ Active spec-kit features (specs/001-slopsmith-platform/...) +``` + +## Running the app + +Canonical dev path is Docker Compose — `docker-compose.yml` live-mounts `static/`, `server.py`, `lib/`, `plugins/`, and `VERSION` into the container, so frontend edits are visible on refresh and backend edits trigger uvicorn auto-reload. + +```bash +docker compose up # build + run on :8000 +DLC_PATH=/path/to/dlc docker compose up # override default Steam DLC path +``` + +For host-side runs (tests, scripts, no Docker), the programmatic entry point is `main.py`: + +```bash +python main.py # HOST=0.0.0.0 PORT=8000 default +HOST=127.0.0.1 PORT=8001 python main.py +``` + +`main.py` installs the structlog pipeline via `logging_setup.configure_logging()` **before** uvicorn boots and passes `log_config=None` so uvicorn's `dictConfig` never overwrites it. Do not invoke `uvicorn server:app` directly during development — early lifecycle log lines will bypass the structured pipeline and correlation IDs. + +**Logging env vars** (read by `logging_setup`): +- `LOG_LEVEL` — `DEBUG | INFO | WARNING | ERROR` (default `INFO`) +- `LOG_FORMAT` — `json | text` (default `text` — coloured console) +- `LOG_FILE` — optional path for a persistent log file (e.g. `/config/slopsmith.log`) + +Plugin backend code receives a pre-configured `context["log"]` logger namespaced to `slopsmith.plugin.` — never use `print()`. See [`docs/plugin-logging.md`](docs/plugin-logging.md). + +## Testing + +```bash +pytest # All Python tests +pytest tests/test_plugins.py -v # Specific file +pytest -k "load_sibling" -v # Pattern match + +npm run test:js # Node-native JS plugin-API contract tests (tests/js/) +npm run install:playwright # One-time: install Chromium for Playwright +npm test # Playwright browser tests (tests/browser/) +``` + +Pytest config in `pyproject.toml` sets `pythonpath = [".", "lib"]` and `testpaths = ["tests"]`. CI runs `pytest` on every push/PR to `main` (Python 3.12). See [`docs/testing-plugins.md`](docs/testing-plugins.md) for fixtures (`isolate_logging`, `reset_plugin_state`) and Playwright patterns. + +## Git workflow + +- **Never push directly to `main`** — always create a feature branch and open a PR. +- **DCO sign-off is mandatory.** `git commit -s` appends `Signed-off-by:`. Forgot? `git commit --amend -s`. See [`CONTRIBUTING.md`](CONTRIBUTING.md). +- **Upstream remote** — set `upstream` to the canonical Slopsmith repository; `origin` is your fork. +- **Plugins are gitlinks** — each plugin in `plugins/` is typically its own git repo (submodule or clone). Branch switches on the main repo can clobber plugin directories. Use `git update-index --assume-unchanged` for plugin dirs if needed. +- **Commit style** — short imperative subject line, blank line, then body explaining *why*. Conventional-commit prefixes (`feat(scope):`, `fix(scope):`, `chore:`, `docs:`) are conventional in the log but not enforced. + +## Versioning + +- **`VERSION`** (repo root) — single source of truth; plain semver string. Bind-mounted into the container and copied by the Dockerfile so it's always available at `/app/VERSION`. +- **`GET /api/version`** — returns `{"version", "source_url", "license_url"}`. `source_url` is overridable via `APP_SOURCE_URL` (default `https://github.com/byrongamatos/slopsmith`); `license_url` falls back to `source_url + "/blob/main/LICENSE"` and is overridable via `APP_LICENSE_URL`. Both must be `http(s)`; non-http(s) values are rejected. +- **Auto-sync** — `.github/workflows/sync-version.yml` rewrites `VERSION` via a `repository_dispatch` (`desktop-released`) from `slopsmith-desktop`'s release job. As an explicit automation-only exception to "Never push directly to main", the sync job commits straight to `main` as `github-actions[bot]`. Humans still go through PRs. +- **`CHANGELOG.md`** — [Keep a Changelog](https://keepachangelog.com/) format. Update `[Unreleased]` on each PR; release renames it. + +## Song formats + +Slopsmith supports two: + +- **PSARC** (Rocksmith native) — encrypted archive. Read-only. Fast metadata scan via `lib/psarc.py` (`read_psarc_entries`); full unpack via `unpack_psarc()` for playback. Audio via `vgmstream-cli` + `ffmpeg`. +- **Sloppak** (open format) — hand-editable, two interchangeable forms: `.sloppak` zip or `.sloppak/` directory. Preferred for new features. Full spec: [`docs/sloppak-spec.md`](docs/sloppak-spec.md). Key code: `lib/sloppak.py`, `lib/sloppak_convert.py`, `lib/song.py`. + +## Frontend conventions + +- **No frameworks** — vanilla JS, fetch API, DOM manipulation +- **Globals** — `highway`, `audio`, `playSong()`, `showScreen()`, `createHighway()`, `window.slopsmith` +- **Storage** — `localStorage` for all user preferences, prefixed with plugin id +- **Styling** — Tailwind utility classes; dark theme (`bg-dark-600`, `text-gray-300`, accent `#4080e0`, gold `#e8c040`) +- **Naming** — camelCase for JS, kebab-case for CSS, snake_case for plugin IDs +- **Player layout** — `#player` is `display:flex; flex-direction:column; position:fixed; inset:0`. `#highway` is `flex:1`. Hiding the highway collapses the layout — use `margin-top: auto` on controls if you must hide it. + +## Backend conventions + +- **Framework** — FastAPI + uvicorn (boot via `main.py`) +- **Imports** — flat imports from `lib/` (no `__init__.py`): `from song import Song` +- **Database** — SQLite via `MetadataDB` class with `threading.Lock` +- **WebSocket** — JSON frames, try/except `WebSocketDisconnect`. Protocol: [`docs/websocket-protocol.md`](docs/websocket-protocol.md) +- **Error handling** — graceful fallbacks (audio conversion errors don't crash the song; missing art returns a placeholder) +- **Type hints** — used sparingly (`Path | None`, `dict`, `list`) +- **Docstrings** — minimal; code is self-documenting + +## Plugin authoring — see [`docs/PLUGIN_AUTHORING.md`](docs/PLUGIN_AUTHORING.md) + +Plugins are the primary extension point. Each lives in `plugins//` with a `plugin.json` manifest. Curated plugins must be AGPL-3.0 or AGPL-compatible — see [`CONTRIBUTING.md`](CONTRIBUTING.md) for the allowlist. Manifest is validated in CI against [`schema/plugin.schema.json`](schema/plugin.schema.json). + +Topic | Doc +--- | --- +Manifest reference (`plugin.json` fields) | [`docs/plugin-manifest.md`](docs/plugin-manifest.md) +Visualization (setRenderer / overlay / note-state) | [`docs/plugin-visualization-contracts.md`](docs/plugin-visualization-contracts.md) +Audio mixer fader registration | [`docs/plugin-audio-mixer.md`](docs/plugin-audio-mixer.md) +Backend `context["log"]` logging | [`docs/plugin-logging.md`](docs/plugin-logging.md) +Diagnostics opt-in (export bundle) | [`docs/plugin-diagnostics.md`](docs/plugin-diagnostics.md) +Keyboard shortcuts (`registerShortcut`) | [`docs/plugin-keyboard-shortcuts.md`](docs/plugin-keyboard-shortcuts.md) +Multi-file backends (`load_sibling`) | [`docs/plugin-sibling-imports.md`](docs/plugin-sibling-imports.md) +WebSocket highway protocol | [`docs/websocket-protocol.md`](docs/websocket-protocol.md) +Testing plugins (pytest + Playwright) | [`docs/testing-plugins.md`](docs/testing-plugins.md) +Diagnostics bundle layout | [`docs/diagnostics-bundle-spec.md`](docs/diagnostics-bundle-spec.md) +Sloppak format spec | [`docs/sloppak-spec.md`](docs/sloppak-spec.md) +Tuning the note_detect plugin | [`docs/note-detect-tuning.md`](docs/note-detect-tuning.md) + +## First-hour pitfalls (read these before your first PR) + +1. **`load_sibling` for cross-file backend plugins.** Bare `from extractor import X` in `routes.py` collides across plugins because Python caches by module name in `sys.modules`. Use `context["load_sibling"]("extractor")` — gets a per-plugin namespaced module. Full explanation: [`docs/plugin-sibling-imports.md`](docs/plugin-sibling-imports.md). + +2. **`playSong` wrapper race condition.** Plugins commonly wrap `window.playSong`. Wrappers chain outermost-first (last-loaded runs first). If an inner wrapper does `await import(CDN)`, it yields to the event loop and WebSocket messages (`song_info`, `ready`) can arrive before outer wrappers finish setup. Use `getSongInfo()` as a fallback, not `_onReady` alone. + +3. **Highway flex layout.** `#highway` has `flex:1` in the player. Hiding it with `display:none` removes the flex child and `#player-controls` floats to the top. If you must hide the highway, add `margin-top: auto` to the controls div. + +4. **Plugin gitlinks bite on branch switches.** Plugins are separate git repos cloned into `plugins/`. `git checkout` / `git clean` on the main repo can delete or clobber them. Use `git update-index --assume-unchanged plugins/` if needed. + +5. **DCO sign-off is mandatory.** Every commit needs `Signed-off-by:`. Add via `git commit -s` (or `git commit --amend -s` if you forgot). PRs without DCO won't merge. + +## Verification (run before claiming done) + +```bash +pytest -q # backend tests +npm run test:js # JS plugin-API contract tests +npm test # Playwright browser tests (slow; defers to CI) +``` + +If you touched `plugins/*/plugin.json` or `schema/`: + +```bash +python -c "import json,glob,jsonschema; s=json.load(open('schema/plugin.schema.json')); [jsonschema.validate(json.load(open(p)), s) for p in sorted(glob.glob('plugins/*/plugin.json'))]" +``` + +## House rules + +- **AGPL-3.0-only.** Inbound contributions are inbound under the same terms. Don't paste from incompatible sources. +- **DCO sign-off mandatory** on every commit (`git commit -s`). +- **No frontend frameworks.** Vanilla JS, fetch API, Tailwind classes. Don't add React/Vue/Svelte. +- **Backend logging.** Plugin `routes.py` must use `context["log"]`, never `print()`. See [`docs/plugin-logging.md`](docs/plugin-logging.md). +- **Plugin Python imports.** Multi-file backends use `context["load_sibling"]("")`, not bare `from import`. See [`docs/plugin-sibling-imports.md`](docs/plugin-sibling-imports.md). +- **Spec-kit owns `.specify/` and `specs/`.** Don't modify those without explicit instruction; the `/speckit-*` skills own that surface. + +## Tool-specific surfaces (optional reading) + +- [`CLAUDE.md`](CLAUDE.md) — points back here. Exists for Claude Code's auto-loading convention. Claude-specific *automation* lives in [`.claude/`](.claude/) (skills, subagents, rules, settings). +- [`.github/copilot-instructions.md`](.github/copilot-instructions.md) — points back here. Exists for GitHub Copilot's native instructions format. +- No `.cursorrules` — Cursor reads `AGENTS.md` natively. Cursor-specific rules, if ever needed, would go under `.cursor/rules/`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cf979ac..68a7166 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,7 @@ If you forget to sign off, amend the most recent commit with `git commit --amend ## Plugin licensing -Plugins live in their own repositories and are loaded at runtime — see the [Plugin System section in CLAUDE.md](CLAUDE.md) for the technical contract, and [Plugin Best Practices](CLAUDE.md) for the conventions every plugin should follow (v2/v3 player chrome, the visualization contracts, and the **performance rules** — no per-frame DOM queries or broad `document.body` `MutationObserver`s — that keep the 60 fps highway smooth). Plugins are not subject to AGPL by being loaded into Slopsmith (the loader runs them as separate Python modules / browser scripts), but for the **curated plugin list** to accept your plugin we ask that it be released under an AGPL-3.0-compatible license: +Plugins live in their own repositories and are loaded at runtime — see [`docs/PLUGIN_AUTHORING.md`](docs/PLUGIN_AUTHORING.md) for the technical contract and [`schema/plugin.schema.json`](schema/plugin.schema.json) for the manifest schema. Plugins are not subject to AGPL by being loaded into Slopsmith (the loader runs them as separate Python modules / browser scripts), but for the **curated plugin list** to accept your plugin we ask that it be released under an AGPL-3.0-compatible license: - AGPL-3.0-only or AGPL-3.0-or-later - GPL-3.0-only or GPL-3.0-or-later @@ -41,7 +41,7 @@ Plugins under GPL-2.0-only, LGPL-2.1-only, CDDL, EPL, or proprietary terms will ## Workflow -Standard PR workflow described in [CLAUDE.md → Git Workflow](CLAUDE.md): +Standard PR workflow described in [AGENTS.md → Git workflow](AGENTS.md#git-workflow): - Never push directly to `main`. - Create a feature branch on your fork. - Open a PR against `got-feedback/feedback:main`. diff --git a/docs/sloppak-spec.md b/docs/sloppak-spec.md index 7e83f9d..7870eed 100644 --- a/docs/sloppak-spec.md +++ b/docs/sloppak-spec.md @@ -435,7 +435,7 @@ Always use `yaml.safe_dump` (not `yaml.dump`) and pass `sort_keys=False` so the ### 4.3. Reading (JavaScript, plugin-side) -Plugins typically don't read the sloppak file directly — they consume the `/ws/highway/{filename}` WebSocket stream (see `CLAUDE.md` for the message protocol), which produces the same shapes. If you specifically need raw manifest access from the browser, expose it through a custom backend route in your plugin's `routes.py` and fetch it. +Plugins typically don't read the sloppak file directly — they consume the `/ws/highway/{filename}` WebSocket stream (see [`websocket-protocol.md`](websocket-protocol.md) for the message protocol), which produces the same shapes. If you specifically need raw manifest access from the browser, expose it through a custom backend route in your plugin's `routes.py` and fetch it. --- @@ -935,5 +935,5 @@ The full pytest suite (`pytest`) must stay green before any PR. | Drum tab vocabulary and wire helpers | [lib/drums.py](../lib/drums.py) | | Notation vocabulary and wire helpers | [lib/notation.py](../lib/notation.py) | | Live streaming over WebSocket (consumes the same shapes) | `server.py` (`/ws/highway/{filename}`) | -| The plugin system (where new viz consumers go) | [CLAUDE.md](../CLAUDE.md) — Plugin System section | +| The plugin system (where new viz consumers go) | [docs/PLUGIN_AUTHORING.md](PLUGIN_AUTHORING.md) + [docs/plugin-visualization-contracts.md](plugin-visualization-contracts.md) | | Tests | [tests/test_sloppak.py](../tests/test_sloppak.py), [tests/test_sloppak_convert.py](../tests/test_sloppak_convert.py) | diff --git a/requirements-test.txt b/requirements-test.txt index f86c072..e0f80aa 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,5 +1,6 @@ pytest>=8.0 httpx>=0.27.0 +jsonschema>=4.0 # Required by tests/test_lyrics_transcribe.py for the vocals_has_signal # RMS-gate tests (silent vs loud WAV generation). Without these the # soundfile-backed tests are silently skipped via pytest.importorskip diff --git a/tests/test_plugin_schema.py b/tests/test_plugin_schema.py new file mode 100644 index 0000000..8c88a9e --- /dev/null +++ b/tests/test_plugin_schema.py @@ -0,0 +1,111 @@ +"""Plugin manifest schema sanity tests. + +Three independent guarantees: + +1. `schema/plugin.schema.json` is itself a well-formed JSON Schema + (Draft 2020-12) and accepts every in-tree `plugins/*/plugin.json`. +2. Each in-tree manifest's `id` matches its parent directory name — + the loader assumes this and silent drift would break plugin + discovery. +3. The `license` enum in the schema is a subset of the SPDX identifiers + listed in `CONTRIBUTING.md`'s "Plugin licensing" curated allowlist. + If you edit the allowlist in `CONTRIBUTING.md`, run pytest locally + and update the schema enum to match — these two files must stay in + sync because the same allowlist is referenced from both human-facing + docs and from CI manifest validation. +""" + +from __future__ import annotations + +import glob +import json +import re +from pathlib import Path + +import jsonschema +import pytest + + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCHEMA_PATH = REPO_ROOT / "schema" / "plugin.schema.json" +CONTRIBUTING_PATH = REPO_ROOT / "CONTRIBUTING.md" +PLUGINS_GLOB = str(REPO_ROOT / "plugins" / "*" / "plugin.json") + + +@pytest.fixture(scope="module") +def schema() -> dict: + with SCHEMA_PATH.open() as f: + return json.load(f) + + +def test_schema_is_well_formed(schema: dict) -> None: + """The schema file must itself validate as a Draft 2020-12 schema.""" + jsonschema.Draft202012Validator.check_schema(schema) + + +@pytest.mark.parametrize("manifest_path", sorted(glob.glob(PLUGINS_GLOB))) +def test_in_tree_manifest_validates(manifest_path: str, schema: dict) -> None: + """Every plugin.json under plugins/ must pass schema validation.""" + with open(manifest_path) as f: + manifest = json.load(f) + jsonschema.validate(manifest, schema) + + +@pytest.mark.parametrize("manifest_path", sorted(glob.glob(PLUGINS_GLOB))) +def test_in_tree_manifest_id_matches_directory(manifest_path: str) -> None: + """The `id` field must match the parent directory name.""" + with open(manifest_path) as f: + manifest = json.load(f) + expected = Path(manifest_path).parent.name + assert manifest["id"] == expected, ( + f"Plugin id {manifest['id']!r} in {manifest_path} does not match " + f"directory name {expected!r}. The loader keys plugin lookup by " + f"directory; drift would silently break discovery." + ) + + +def _extract_allowlist_from_contributing() -> set[str]: + """Pull the curated-license allowlist out of CONTRIBUTING.md. + + Looks at the "Plugin licensing" section: any bullet line whose text + starts with a recognized SPDX-shape identifier is considered part of + the allowlist. Forms like "AGPL-3.0-only or AGPL-3.0-or-later" are + split on " or ". + """ + text = CONTRIBUTING_PATH.read_text(encoding="utf-8") + section = text.split("## Plugin licensing", 1) + if len(section) < 2: + pytest.fail("'## Plugin licensing' section not found in CONTRIBUTING.md") + body = section[1].split("\n## ", 1)[0] + + spdx_re = re.compile(r"^[A-Za-z0-9.+-]+$") + allowlist: set[str] = set() + for line in body.splitlines(): + if not line.lstrip().startswith("- "): + continue + rest = line.lstrip()[2:].strip() + # Strip trailing punctuation / parenthetical notes. + rest = re.split(r"\s*\(|\s*—|\s*--", rest)[0].strip().rstrip(".,;") + for token in re.split(r"\s+or\s+|\s*/\s+|\s*,\s+", rest): + token = token.strip().rstrip(".,;").strip() + if token and spdx_re.match(token): + allowlist.add(token) + return allowlist + + +def test_schema_license_enum_subset_of_contributing_allowlist(schema: dict) -> None: + """Schema's license enum must be ⊆ CONTRIBUTING.md curated allowlist. + + If you add a license to the schema enum, also list it in + CONTRIBUTING.md "Plugin licensing". Direction matters: schema ⊆ + allowlist (the schema can be stricter than what CONTRIBUTING.md + documents — typically the schema *equals* the allowlist). + """ + license_enum = set(schema["properties"]["license"]["enum"]) + allowlist = _extract_allowlist_from_contributing() + missing = license_enum - allowlist + assert not missing, ( + f"License enum values present in schema/plugin.schema.json but " + f"not listed in CONTRIBUTING.md 'Plugin licensing' section: {sorted(missing)}. " + f"Update CONTRIBUTING.md or remove from the schema enum." + )