From 3b9fd3d21490234b42d54ca9f8f6b0490e82a570 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 23:44:28 +0000 Subject: [PATCH 1/2] test: add a sync-local regression suite and wire up CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the two gaps the simplification sweep left open: the repo had no tests and no CI, and the PSScriptAnalyzer dependency the sweep started relying on was recorded nowhere. scripts/test-sync-local.sh — 49 assertions, all passing. Every case runs BOTH twins against the same fixture and asserts the same exit code and byte-identical stdout, because a divergence between documented parity twins is itself a defect. Only stdout is compared: PowerShell wraps stderr in its own exception frame, so the twins' error text matches while their framing does not. pwsh is optional -- without it the PowerShell half reports as skipped and the bash half still runs. Fixtures are built under $TMPDIR and HOME is redirected per case, so the suite never touches a real ~/.cursor. It pins the defects that were found the hard way, each of which passed a linter before it was found: - path traversal via a plugin `name` and via an entry `source` - a metadata.pluginRoot that escapes the source repo - an empty plugins/ dir and "plugins": [], which used to exit 0 printing "Synced (0)" -- a false pass in CI - a nameless marketplace entry, which used to dump a Python traceback - prefix-name sort order (ai before ai-briefing) - a symlinked plugin directory, which must install as a REAL directory scripts/validate-manifests.mjs — validates the manifests against Cursor's PUBLISHED schemas rather than its prose reference. The two disagree in both directions: the prose lists plugin-entry fields the schema forbids, and marks `owner` required where the schema does not. A prose-derived checklist would pass files a validator rejects. Verified both ways -- it passes the current manifests and fails a fixture carrying `category`/`tags` on a marketplace entry, which is exactly the state this repo was in before the sweep. .github/workflows/ci.yml — four jobs: shell (shellcheck + the regression suite), powershell (PSScriptAnalyzer, printing -SuppressedOnly so suppressions stay visible rather than silently disappearing), manifests (jq + the schemas, fetched at run time so an upstream schema change surfaces here), and links (internal markdown only). Two deliberate exclusions in CI, both to avoid failures that say nothing about the repository: external URLs are not checked, because GitHub and cursor.directory answer CI runners with 403/429; and the repo-root files synced from melodic-software/standards are not re-linted, since they are validated upstream and must not be hand-edited here. The link checker strips inline code spans before extracting links. Without that it reports a false positive on the plugin-name pattern ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$, which looks exactly like ](...) to a naive matcher. Verified it still catches a genuinely broken link. AGENTS.md — replaced "no test framework" with how to run the suite, and added a table of which lint/test tools are NOT preinstalled and how to install each (shellcheck, pwsh, PSScriptAnalyzer, node+ajv), keeping the runtime dependencies (bash, git, python3, jq) separate from the checking dependencies. Documents the schema-vs-prose conflict, the zero-findings PSScriptAnalyzer rule and its justification requirement, and what CI does and does not cover. The existing claim about pwsh being absent from the base image is untouched: it is scoped to Cursor Cloud's image, which is not observable from a dev container. .shellcheckrc scope widened from sync-local.sh to scripts/*.sh so the new script is covered; nothing is disabled and no `# shellcheck disable=` directive exists in the tree. The SC2016 that the new script would have tripped was designed out (pwsh --version) rather than suppressed. Verified: shellcheck 0 across scripts/*.sh; bash -n clean; PSScriptAnalyzer 0; test suite 49/49 with both twins; manifest validator passes real manifests and fails a bad fixture; link check 0 broken; workflow YAML parses and every step carries a uses/run. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017Ejst51HQobnYvjLcW1bgJ --- .github/workflows/ci.yml | 144 +++++++++++++++++ .shellcheckrc | 5 +- AGENTS.md | 57 ++++++- scripts/test-sync-local.sh | 276 +++++++++++++++++++++++++++++++++ scripts/validate-manifests.mjs | 69 +++++++++ 5 files changed, 546 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100755 scripts/test-sync-local.sh create mode 100644 scripts/validate-manifests.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..eceee5e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,144 @@ +name: CI + +# This repository has no build and no package manager. CI is therefore the +# lint/validate proxies documented in AGENTS.md, plus the sync-local regression +# suite, run on every push and pull request. +# +# Scope note: the repo-root config files synced from melodic-software/standards +# (.editorconfig, .gitattributes, _typos.toml, lychee.toml, .gitleaks.toml, +# .markdownlint-cli2.jsonc, .editorconfig-checker.json) are validated by their +# own tooling upstream and are deliberately not re-linted here. + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + shell: + name: Shell (shellcheck + regression suite) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install shellcheck + run: sudo apt-get update && sudo apt-get install -y shellcheck + + - name: shellcheck (must be zero findings) + run: shellcheck scripts/*.sh + + - name: bash -n (syntax floor) + run: for f in scripts/*.sh; do bash -n "$f"; done + + # pwsh is preinstalled on ubuntu-latest, so the suite exercises BOTH twins + # here and asserts their output is byte-identical. + - name: sync-local regression suite + run: bash scripts/test-sync-local.sh + + powershell: + name: PowerShell (PSScriptAnalyzer) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install PSScriptAnalyzer + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module PSScriptAnalyzer -Scope CurrentUser -Force + + # Suppressions in sync-local.ps1 are deliberate and each carries a written + # Justification; -SuppressedOnly is printed so a reviewer can see what is + # being suppressed rather than having it silently disappear. + - name: PSScriptAnalyzer (must be zero unsuppressed findings) + shell: pwsh + run: | + $suppressed = Invoke-ScriptAnalyzer -Path ./scripts/sync-local.ps1 -SuppressedOnly + if ($suppressed) { + Write-Host "Deliberately suppressed (each carries a Justification):" + $suppressed | Format-Table RuleName, @{n='Count';e={1}} -AutoSize | Out-String | Write-Host + } + $findings = Invoke-ScriptAnalyzer -Path ./scripts/sync-local.ps1 + if ($findings) { + $findings | Format-Table Severity, RuleName, Line, Message -AutoSize -Wrap | Out-String | Write-Host + throw "PSScriptAnalyzer reported $($findings.Count) finding(s)." + } + Write-Host "PSScriptAnalyzer: 0 findings." + + manifests: + name: Manifests (JSON + official Cursor schemas) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: jq — manifests are well-formed JSON + run: | + jq empty .cursor-plugin/marketplace.json + for f in plugins/*/.cursor-plugin/plugin.json; do jq empty "$f"; done + + - name: Skill frontmatter — each SKILL.md opens with a YAML block + run: | + status=0 + for f in plugins/*/skills/*/SKILL.md; do + if [ "$(head -1 "$f")" != "---" ]; then + echo "::error file=$f::SKILL.md must start with a --- YAML frontmatter block" + status=1 + fi + done + exit $status + + # Validated against Cursor's PUBLISHED schemas, fetched at run time. These + # are the authority: Cursor's prose reference and its schemas disagree in + # both directions, so a prose-derived checklist would pass files the + # schema rejects. A fetch failure fails the job rather than skipping the + # check silently. + - name: Validate manifests against the official schemas + run: | + set -euo pipefail + npm init -y >/dev/null 2>&1 + npm install --no-fund --no-audit --silent ajv@8 ajv-formats + base=https://raw.githubusercontent.com/cursor/plugins/main/schemas + curl -fsS "$base/marketplace.schema.json" -o marketplace.schema.json + curl -fsS "$base/plugin.schema.json" -o plugin.schema.json + node scripts/validate-manifests.mjs + + links: + name: Docs (internal link integrity) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Relative links only. External URLs are deliberately NOT checked here: + # GitHub and cursor.directory return 403/429 to CI runners, which would + # make this job fail for reasons that say nothing about the repository. + # + # Inline code spans are stripped BEFORE extracting links. Without that, a + # regex inside backticks parses as link syntax and reports a false + # positive: the plugin-name pattern `^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$` + # looks exactly like ](...) to a naive matcher. + - name: Every relative markdown link resolves on disk + run: | + status=0 + while IFS= read -r src; do + dir=$(dirname "$src") + while IFS= read -r link; do + case "$link" in http*|mailto:*|'') continue ;; esac + target=${link%%#*} + [ -z "$target" ] && continue + if [ ! -e "$dir/$target" ]; then + echo "::error file=$src::broken relative link -> $link" + status=1 + fi + done < <(sed 's/`[^`]*`//g' "$src" | + grep -oE '\]\([^)#][^)]*\)' | + sed 's/^](//; s/)$//') + done < <(git ls-files '*.md') + exit $status diff --git a/.shellcheckrc b/.shellcheckrc index 1d004dc..41ae405 100644 --- a/.shellcheckrc +++ b/.shellcheckrc @@ -1,7 +1,8 @@ # ShellCheck configuration for this repository. # -# Scope: scripts/*.sh. Run `shellcheck scripts/sync-local.sh`; the default rule -# set must stay at zero findings. +# Scope: scripts/*.sh. Run `shellcheck scripts/*.sh`; the default rule set must +# stay at zero findings across every script in that directory. Site counts quoted +# below were measured against scripts/sync-local.sh, the largest of them. # # Nothing is disabled here. ShellCheck's default rules are all in force, and no # `# shellcheck disable=` directive appears anywhere in the tree. This file exists diff --git a/AGENTS.md b/AGENTS.md index 457d69d..3d6fb1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,9 +10,22 @@ and the `scripts/sync-local.*` sync tooling. See `README.md` and ### Environment / dependencies -- There is **no package manager, build step, or test framework**. Nothing needs to be - installed to work here — the required tools (`bash`, `git`, `python3`, `jq`) are all - present in the base image, so the startup update script is a no-op. +- There is **no package manager and no build step**. The tools the repo's own scripts + need at runtime (`bash`, `git`, `python3`, `jq`) are all present in the base image, so + the startup update script is a no-op. +- There **is** a test suite: `bash scripts/test-sync-local.sh`. See "Test" below. +- The lint and test tooling is **not** all preinstalled. Probe with `command -v` and + install what is missing; never assume: + + | Tool | Needed for | If missing | + | --- | --- | --- | + | `shellcheck` | linting `scripts/*.sh` | `sudo apt-get install -y shellcheck` | + | `pwsh` | running/testing `sync-local.ps1` | see the caveat below — the `.sh` twin covers Linux | + | `PSScriptAnalyzer` | linting `sync-local.ps1` | `pwsh -c "Install-Module PSScriptAnalyzer -Scope CurrentUser -Force"` | + | `node` + `ajv` | validating manifests against Cursor's schemas | `npm install ajv@8 ajv-formats` | + + Only `bash`, `git`, `python3` and `jq` are relied on at runtime; everything in that + table is for checking the repo, not for using it. ### Lint / validate (there is no configured linter — use these proxies) @@ -29,6 +42,31 @@ and the `scripts/sync-local.*` sync tooling. See `README.md` and - JSON manifests: `jq empty .cursor-plugin/marketplace.json` and `jq empty plugins/*/.cursor-plugin/plugin.json` - Skill frontmatter: each `plugins/*/skills/*/SKILL.md` must start with a `---` YAML block. +- PowerShell: `pwsh -c "Invoke-ScriptAnalyzer -Path ./scripts/sync-local.ps1"` must report + **zero** findings. `sync-local.ps1` carries two file-level + `SuppressMessageAttribute` entries, each with a written Justification; add + `-SuppressedOnly` to see them. Do not add a suppression without one. +- Manifest **schemas**: `jq empty` only proves the JSON parses. Cursor publishes real + schemas, and its prose reference disagrees with them **in both directions** — the prose + lists plugin-entry fields the schema forbids, and marks `owner` required where the + schema does not. The schema wins. CI fetches both and runs + `node scripts/validate-manifests.mjs`; run it the same way locally. + +### Test + +- `bash scripts/test-sync-local.sh` — the sync-local regression suite. Every case runs + **both** twins against the same fixture and asserts the same exit code and + byte-identical stdout, because a divergence between documented parity twins is itself a + defect. It builds its fixtures under `$TMPDIR` and redirects `HOME` per case, so it + never touches your real `~/.cursor`. +- `pwsh` is optional: without it the PowerShell half reports as skipped and the bash half + still runs. +- Add a case whenever you fix a defect here. The suite already pins the ones that were + found the hard way: path traversal via a plugin `name` and via an entry `source`, a + `pluginRoot` that escapes the repo, an empty `plugins/` and `"plugins": []` (which used + to exit 0 printing `Synced (0)`), a nameless marketplace entry (which used to dump a + Python traceback), prefix-name sort order, and a symlinked plugin directory (which must + install as a **real** directory, not a link). ### Run (the "application") @@ -56,6 +94,19 @@ and the `scripts/sync-local.*` sync tooling. See `README.md` and that date". Never bump one without fetching. A 403/429 is a blocked fetch, not a verification and not a dead link — record it as blocked and leave the old date. +### CI + +`.github/workflows/ci.yml` runs the checks above on every push and pull request: shell +lint plus the regression suite, PSScriptAnalyzer, JSON and schema validation of the +manifests, and internal markdown link integrity. It deliberately does **not** check +external URLs — GitHub and cursor.directory answer CI runners with 403/429, which would +fail the build for reasons that say nothing about the repository. Note that GitHub's +runners preinstall `pwsh`, so CI exercises both twins even though a Cursor Cloud box may +not. + +The repo-root files synced from `melodic-software/standards` are not re-linted here; they +are validated upstream, and this repo must not hand-edit them. + ### Non-obvious caveats - The PowerShell twin `scripts/sync-local.ps1` requires `pwsh` (PowerShell), which is diff --git a/scripts/test-sync-local.sh b/scripts/test-sync-local.sh new file mode 100755 index 0000000..0c0d5dc --- /dev/null +++ b/scripts/test-sync-local.sh @@ -0,0 +1,276 @@ +#!/usr/bin/env bash +# Regression suite for the sync-local parity twins. +# +# Every case runs BOTH scripts/sync-local.sh and scripts/sync-local.ps1 against +# the same fixture and asserts the same exit code and the same stdout, because +# the two are documented parity twins and a divergence between them is itself a +# defect. Only stdout is compared: PowerShell wraps stderr in its own exception +# frame, so the twins' error *text* matches while their error *framing* does not. +# +# pwsh is optional. When it is absent the PowerShell half is reported as skipped +# and the bash half still runs, so this suite is useful on a machine that only +# has bash. +# +# Usage: bash scripts/test-sync-local.sh +# Exit: 0 all assertions passed, 1 otherwise. +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +sh_script="$repo_root/scripts/sync-local.sh" +ps_script="$repo_root/scripts/sync-local.ps1" + +have_pwsh=0 +if command -v pwsh >/dev/null 2>&1; then have_pwsh=1; fi + +pass=0 +fail=0 +skip=0 +failed_names=() + +work="$(mktemp -d "${TMPDIR:-/tmp}/sync-local-tests.XXXXXX")" +trap 'rm -rf "$work"' EXIT + +# --- assertions --------------------------------------------------------------- + +ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; } + +not_ok() { + fail=$((fail + 1)) + failed_names+=("$1") + printf ' FAIL %s\n' "$1" + shift + while [[ $# -gt 0 ]]; do printf ' %s\n' "$1"; shift; done +} + +# run_sh / run_ps write stdout to $out_file and return the exit code. HOME is +# redirected per call so a test never touches the real ~/.cursor. +run_sh() { + local home="$1" out="$2" + shift 2 + local rc=0 + HOME="$home" bash "$sh_script" "$@" >"$out" 2>"$out.err" || rc=$? + return "$rc" +} + +run_ps() { + local home="$1" out="$2" + shift 2 + local rc=0 + env -u USERPROFILE HOME="$home" pwsh -NoProfile -File "$ps_script" "$@" \ + >"$out" 2>"$out.err" || rc=$? + return "$rc" +} + +# Normalize the sandbox HOME out of the output so the two twins are comparable. +normalize() { sed "s|$1||g" "$2"; } + +# assert_twins -- +# Asserts both twins exit with and produce identical stdout. +assert_twins() { + local name="$1" want="$2" + shift 2 + local sh_args=() ps_args=() seen=0 + while [[ $# -gt 0 ]]; do + if [[ "$1" == "--" ]]; then seen=1; shift; continue; fi + if [[ "$seen" -eq 0 ]]; then sh_args+=("$1"); else ps_args+=("$1"); fi + shift + done + + local h1 h2 o1 o2 rc1 rc2 + h1="$work/h.$name.sh"; h2="$work/h.$name.ps" + o1="$work/o.$name.sh"; o2="$work/o.$name.ps" + mkdir -p "$h1" "$h2" + + rc1=0; run_sh "$h1" "$o1" "${sh_args[@]}" || rc1=$? + if [[ "$rc1" -ne "$want" ]]; then + not_ok "$name (sh exit)" "expected $want, got $rc1" "$(head -3 "$o1" "$o1.err" 2>/dev/null)" + else + ok "$name (sh exit $want)" + fi + + if [[ "$have_pwsh" -eq 0 ]]; then + skip=$((skip + 1)); printf ' skip %s (pwsh not installed)\n' "$name" + return 0 + fi + + rc2=0; run_ps "$h2" "$o2" "${ps_args[@]}" || rc2=$? + if [[ "$rc2" -ne "$want" ]]; then + not_ok "$name (ps1 exit)" "expected $want, got $rc2" "$(head -3 "$o2" "$o2.err" 2>/dev/null)" + else + ok "$name (ps1 exit $want)" + fi + + if diff <(normalize "$h1" "$o1") <(normalize "$h2" "$o2") >"$work/d.$name" 2>&1; then + ok "$name (twins byte-identical)" + else + not_ok "$name (twin parity)" "$(head -8 "$work/d.$name")" + fi +} + +# assert_contains +assert_contains() { + if grep -qF -- "$3" "$2"; then ok "$1"; else + not_ok "$1" "expected to find: $3" "got: $(head -3 "$2")" + fi +} + +# assert_absent +assert_absent() { + if grep -qF -- "$3" "$2"; then + not_ok "$1" "should NOT contain: $3" "got: $(head -5 "$2")" + else ok "$1"; fi +} + +# --- fixtures ----------------------------------------------------------------- + +mkplugin() { # mkplugin + mkdir -p "$1/.cursor-plugin" + printf '{ "name": "%s" }\n' "$2" >"$1/.cursor-plugin/plugin.json" +} + +mkmarket() { # mkmarket [pluginRoot] + mkdir -p "$1/.cursor-plugin" + printf '{ "name": "fx", "metadata": { "pluginRoot": "%s" }, "plugins": [%s] }\n' \ + "${3:-plugins}" "$2" >"$1/.cursor-plugin/marketplace.json" +} + +# 1. a well-formed marketplace with two plugins +f_ok="$work/f_ok" +mkmarket "$f_ok" '{"name":"alpha","source":"alpha"},{"name":"beta","source":"beta"}' +mkplugin "$f_ok/plugins/alpha" alpha +mkplugin "$f_ok/plugins/beta" beta + +# 2. empty plugins/ directory, no marketplace.json +f_empty="$work/f_empty"; mkdir -p "$f_empty/plugins" + +# 3. marketplace declaring no plugins at all +f_noplugins="$work/f_noplugins"; mkmarket "$f_noplugins" '' + +# 4. a plugin name that tries to escape the destination root +f_badname="$work/f_badname" +mkmarket "$f_badname" '{"name":"../../pwned","source":"alpha"},{"name":"alpha","source":"alpha"}' +mkplugin "$f_badname/plugins/alpha" alpha + +# 5. a plugin source that tries to escape the plugin root +f_badsrc="$work/f_badsrc" +mkmarket "$f_badsrc" '{"name":"escapee","source":"../../../etc"},{"name":"alpha","source":"alpha"}' +mkplugin "$f_badsrc/plugins/alpha" alpha + +# 6. a pluginRoot that escapes the source repo +f_badroot="$work/f_badroot"; mkmarket "$f_badroot" '{"name":"a","source":"a"}' '../../..' + +# 7. a marketplace entry with no name key at all +f_noname="$work/f_noname"; mkmarket "$f_noname" '{"source":"alpha"}' +mkplugin "$f_noname/plugins/alpha" alpha + +# 8. plugins/ fallback layout containing a SYMLINK to a plugin outside plugins/ +f_link="$work/f_link" +mkplugin "$f_link/plugins/real" real +mkplugin "$f_link/vendor/linked" linked +ln -s ../vendor/linked "$f_link/plugins/linked" + +# 9. prefix-related names, to pin enumeration order +f_sort="$work/f_sort" +for n in ai ai-briefing docs docs-hygiene; do mkplugin "$f_sort/plugins/$n" "$n"; done + +# 10. a single-plugin repo +f_single="$work/f_single"; mkplugin "$f_single" solo + +# --- cases -------------------------------------------------------------------- + +printf 'sync-local regression suite\n' +printf 'bash: %s\n' "$(bash --version | head -1)" +if [[ "$have_pwsh" -eq 1 ]]; then + printf 'pwsh: %s\n\n' "$(pwsh --version)" +else + printf 'pwsh: NOT INSTALLED (PowerShell half will be skipped)\n\n' +fi + +printf 'happy path\n' +assert_twins repo-dry-run 0 --dry-run "$repo_root" -- -Source "$repo_root" -DryRun +assert_twins marketplace 0 --dry-run "$f_ok" -- -Source "$f_ok" -DryRun + +printf 'failures must not look like success\n' +assert_twins missing-source 1 --dry-run "$work/does-not-exist" -- -Source "$work/does-not-exist" -DryRun +assert_twins empty-plugins-dir 1 --dry-run "$f_empty" -- -Source "$f_empty" -DryRun +assert_twins empty-plugins-array 1 --dry-run "$f_noplugins" -- -Source "$f_noplugins" -DryRun +assert_twins nameless-entry 1 --dry-run "$f_noname" -- -Source "$f_noname" -DryRun +assert_twins plugin-root-escape 1 --dry-run "$f_badroot" -- -Source "$f_badroot" -DryRun +assert_twins single-filter-miss 1 --dry-run "$f_single" nomatch -- -Source "$f_single" -Plugin nomatch -DryRun + +printf 'traversal is refused on both sides\n' +assert_twins name-escape 0 --dry-run "$f_badname" -- -Source "$f_badname" -DryRun +assert_twins source-escape 0 --dry-run "$f_badsrc" -- -Source "$f_badsrc" -DryRun + +printf 'enumeration\n' +assert_twins symlinked-plugin 0 --dry-run "$f_link" -- -Source "$f_link" -DryRun +assert_twins prefix-sort-order 0 --dry-run "$f_sort" -- -Source "$f_sort" -DryRun + +# --- content assertions ------------------------------------------------------- + +printf 'output content\n' +h="$work/h.content"; mkdir -p "$h" + +o="$work/c.badname"; run_sh "$h" "$o" --dry-run "$f_badname" || true +assert_contains "escaping name is skipped, not synced" "$o" '(invalid plugin name)' +assert_absent "escaping name never reaches the sync list" "$o" 'Would sync (2)' + +o="$work/c.badsrc"; run_sh "$h" "$o" --dry-run "$f_badsrc" || true +assert_contains "escaping source is skipped" "$o" '(source escapes plugin root)' +assert_contains "the sibling plugin still syncs" "$o" 'alpha' + +o="$work/c.noname"; run_sh "$h" "$o" --dry-run "$f_noname" || true +assert_absent "a nameless entry does not raise a Python traceback" "$o.err" 'Traceback' +assert_absent "a nameless entry does not raise KeyError" "$o.err" 'KeyError' + +o="$work/c.sort"; run_sh "$h" "$o" --dry-run "$f_sort" || true +assert_contains "prefix names keep basename sort order" "$o" 'ai, ai-briefing, docs, docs-hygiene' + +o="$work/c.dry"; run_sh "$h" "$o" --dry-run "$f_ok" || true +assert_contains "a dry run says it would sync" "$o" 'Would sync' +assert_absent "a dry run never claims it synced" "$o" 'Synced (' + +# --ref with no value is bash-only: pwsh's parameter binder owns that path. +rc=0; run_sh "$h" "$work/c.ref" --dry-run "$f_ok" --ref || rc=$? +if [[ "$rc" -eq 2 ]]; then ok "--ref with no value exits 2"; else + not_ok "--ref with no value" "expected exit 2, got $rc" +fi +assert_contains "--ref with no value explains itself" "$work/c.ref.err" 'Missing value for --ref' + +# --- real (non-dry) sync: the destination must be a real directory ------------ + +printf 'real sync\n' +h="$work/h.real"; mkdir -p "$h" +run_sh "$h" "$work/r.link" "$f_link" || true +linked="$h/.cursor/plugins/local/linked" +if [[ -L "$linked" ]]; then + not_ok "a symlinked plugin installs as a real directory" "installed a symlink instead" +elif [[ -f "$linked/.cursor-plugin/plugin.json" ]]; then + ok "a symlinked plugin installs as a real directory" +else + not_ok "a symlinked plugin installs as a real directory" "destination missing or empty" +fi + +if [[ "$have_pwsh" -eq 1 ]]; then + h="$work/h.real.ps"; mkdir -p "$h" + run_ps "$h" "$work/r.link.ps" -Source "$f_link" || true + linked="$h/.cursor/plugins/local/linked" + if [[ -L "$linked" ]]; then + not_ok "pwsh: a symlinked plugin installs as a real directory" "installed a symlink" + elif [[ -f "$linked/.cursor-plugin/plugin.json" ]]; then + ok "pwsh: a symlinked plugin installs as a real directory" + else + not_ok "pwsh: a symlinked plugin installs as a real directory" "destination missing" + fi +fi + +# --- summary ------------------------------------------------------------------ + +printf '\n%s\n' "----------------------------------------" +printf 'passed %d, failed %d, skipped %d\n' "$pass" "$fail" "$skip" +if [[ "$fail" -gt 0 ]]; then + printf 'failing:\n' + for n in "${failed_names[@]}"; do printf ' - %s\n' "$n"; done + exit 1 +fi +printf 'all assertions passed\n' diff --git a/scripts/validate-manifests.mjs b/scripts/validate-manifests.mjs new file mode 100644 index 0000000..75e251e --- /dev/null +++ b/scripts/validate-manifests.mjs @@ -0,0 +1,69 @@ +// Validate this repository's Cursor manifests against Cursor's PUBLISHED JSON +// schemas. +// +// The schemas are the authority, not the prose reference. The two disagree in +// both directions: the reference page's "Plugin entry fields" table lists ~16 +// keys that marketplace.schema.json forbids (the plugin entry is +// additionalProperties: false with only name/source/description/ +// minClientVersions), and its "Marketplace manifest fields" table marks `owner` +// required where the schema's required array is only ["name","plugins"]. A +// checklist derived from the prose would therefore pass files a validator +// rejects, and vice versa. +// +// Expects marketplace.schema.json and plugin.schema.json in the working +// directory (CI fetches them at run time so a schema change upstream surfaces +// here rather than going unnoticed). +// +// Usage: node scripts/validate-manifests.mjs +// Exit: 0 all manifests valid, 1 otherwise. + +import { readFileSync, existsSync } from "node:fs"; +import { globSync } from "node:fs"; +import Ajv from "ajv"; +import addFormats from "ajv-formats"; + +const readJson = (path) => JSON.parse(readFileSync(path, "utf8")); + +const targets = [ + { schema: "marketplace.schema.json", doc: ".cursor-plugin/marketplace.json" }, + ...globSync("plugins/*/.cursor-plugin/plugin.json").map((doc) => ({ + schema: "plugin.schema.json", + doc, + })), +]; + +let failed = 0; + +for (const { schema, doc } of targets) { + if (!existsSync(schema)) { + console.error(`::error::schema not found: ${schema}`); + failed = 1; + continue; + } + if (!existsSync(doc)) { + console.error(`::error::manifest not found: ${doc}`); + failed = 1; + continue; + } + + // strict:false — Cursor's schemas use keywords Ajv's strict mode rejects; + // that is a property of their authoring, not of our manifests. + const ajv = new Ajv({ strict: false, allErrors: true }); + addFormats(ajv); + + const validate = ajv.compile(readJson(schema)); + if (validate(readJson(doc))) { + console.log(`PASS ${doc} (${schema})`); + continue; + } + + failed = 1; + console.log(`FAIL ${doc} (${schema})`); + for (const e of validate.errors) { + const where = e.instancePath || "/"; + const extra = e.params ? ` ${JSON.stringify(e.params)}` : ""; + console.error(`::error file=${doc}::${where} ${e.message}${extra}`); + } +} + +process.exit(failed); From 91c05deb23c09a8a5d44ada5242446e7e01212e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 23:47:09 +0000 Subject: [PATCH 2/2] ci: pin actions/checkout to a full-length commit SHA The first CI run failed all four jobs at "Set up job", before any repository code executed: The action actions/checkout@v4 is not allowed in melodic-software/cursor-plugins because all actions must be pinned to a full-length commit SHA. That is an organization policy (this repo's settings are managed by melodic-software/github-iac), not a defect in the workflow logic. A version tag is mutable, so pinning to one lets a compromised or retagged release run with the workflow's permissions; the policy forecloses that. Pinned to fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09, resolved from the upstream repository rather than copied from memory: refs/tags/v5 and refs/tags/v5.1.0 both dereference to that commit, and it is 40 hex characters. The version is recorded in a comment above each use so the pin stays readable and can be bumped deliberately. Also moves from v4 to v5 while pinning, since the pin is being written fresh. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017Ejst51HQobnYvjLcW1bgJ --- .github/workflows/ci.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eceee5e..cf3411f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,9 @@ jobs: name: Shell (shellcheck + regression suite) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + # actions/checkout v5.1.0 — pinned to a full-length commit SHA because this + # org requires it; a tag or short SHA is rejected at "Set up job". + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 - name: Install shellcheck run: sudo apt-get update && sudo apt-get install -y shellcheck @@ -47,7 +49,9 @@ jobs: name: PowerShell (PSScriptAnalyzer) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + # actions/checkout v5.1.0 — pinned to a full-length commit SHA because this + # org requires it; a tag or short SHA is rejected at "Set up job". + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 - name: Install PSScriptAnalyzer shell: pwsh @@ -77,7 +81,9 @@ jobs: name: Manifests (JSON + official Cursor schemas) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + # actions/checkout v5.1.0 — pinned to a full-length commit SHA because this + # org requires it; a tag or short SHA is rejected at "Set up job". + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 - name: jq — manifests are well-formed JSON run: | @@ -114,7 +120,9 @@ jobs: name: Docs (internal link integrity) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + # actions/checkout v5.1.0 — pinned to a full-length commit SHA because this + # org requires it; a tag or short SHA is rejected at "Set up job". + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # Relative links only. External URLs are deliberately NOT checked here: # GitHub and cursor.directory return 403/429 to CI runners, which would