diff --git a/.gitattributes b/.gitattributes index 63176b53..aa17efac 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,11 @@ # Force LF line endings for test fixtures — tree-sitter grammars # expect Unix line endings and produce wrong parse trees with CRLF. tests/fixtures/** text eol=lf +# Force LF for bundled plugin skill docs; the skill hygiene tests assert the +# canonical source files are LF-only, and Windows checkout otherwise rewrites +# them before the lint runs. +codex-plugin/skills/** text eol=lf +cursor-plugin/skills/** text eol=lf # Force LF for embedded Hermes plugin template assets — they are pulled into # the binary via include_str! and the generated-plugin snapshot test asserts # their exact bytes. diff --git a/.github/workflows/plugin-validation.yml b/.github/workflows/plugin-validation.yml new file mode 100644 index 00000000..13d9ec2b --- /dev/null +++ b/.github/workflows/plugin-validation.yml @@ -0,0 +1,108 @@ +# Schema/lint layer for the shipped agent plugin bundles. Mirrors the official +# Cursor marketplace validation workflow: +# https://github.com/cursor/plugins/blob/main/.github/workflows/validate-plugins.yml +# (ajv + ajv-formats against the plugin/marketplace JSON schemas; the schemas +# are vendored in tests/fixtures/cursor-schemas/). +# +# The Rust contract tests for the bundles already run in ci.yml — do not add +# plain cargo test jobs here. The MCP conformance smoke below is the one +# exception: it needs a built binary plus npx, which cargo test can't provide. +name: Plugin Validation + +on: + pull_request: + paths: + - "cursor-plugin/**" + - "codex-plugin/**" + - "tests/fixtures/cursor-schemas/**" + # Plugin/skill test modules (e.g. plugin_manifest_schema_test.rs, + # plugin_skill_contract_test.rs, the skill lint tests). + - "tests/agent_suite/*plugin*" + - "tests/agent_suite/*skill*" + - "scripts/mcp-conformance-smoke.sh" + # The Inspector smoke is the workflow's SDK-backed coverage for + # `tracedecay serve` protocol and tool-schema compatibility. + - "src/serve.rs" + - "src/main.rs" + - "src/lib.rs" + - "src/mcp/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/plugin-validation.yml" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + manifest-schema: + name: Manifest schema + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + # Pinned, unlike upstream's floating `npm install ajv ajv-formats`. + - name: Install ajv-cli + run: npm install --no-save ajv-cli@5.0.0 ajv-formats@2.1.1 + + - name: Compile vendored schemas + run: | + npx --no-install ajv compile --spec=draft7 -c ajv-formats \ + -s tests/fixtures/cursor-schemas/plugin.schema.json \ + -s tests/fixtures/cursor-schemas/marketplace.schema.json \ + -s tests/fixtures/cursor-schemas/mcp.schema.json \ + -s tests/fixtures/cursor-schemas/hooks.schema.json + + - name: Validate Cursor plugin manifest + run: | + npx --no-install ajv validate --spec=draft7 -c ajv-formats --errors=text \ + -s tests/fixtures/cursor-schemas/plugin.schema.json \ + -d cursor-plugin/.cursor-plugin/plugin.json + + # The Codex manifest follows Codex's own layout (e.g. its `interface` + # block), so the Cursor schema does not apply; keep it to a strict JSON + # well-formedness check alongside the other bundle JSON files. + - name: Check bundle JSON files parse + run: | + set -euo pipefail + find cursor-plugin codex-plugin -name '*.json' -print0 | + while IFS= read -r -d '' f; do + python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$f" \ + || { echo "invalid JSON: $f" >&2; exit 1; } + echo "ok: $f" + done + + # Drives a real `tracedecay serve` stdio server through the MCP Inspector + # CLI (pinned version), which embeds the official TypeScript MCP SDK client + # — covering protocol-version negotiation and SDK-side schema validation + # that the in-repo Rust MCP tests cannot. See scripts/mcp-conformance-smoke.sh. + mcp-conformance-smoke: + name: MCP conformance smoke + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Build tracedecay + run: cargo build --bin tracedecay --locked + + - name: Run MCP conformance smoke + run: scripts/mcp-conformance-smoke.sh + env: + TRACEDECAY_BIN: target/debug/tracedecay diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c75cad5..47007949 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -109,6 +109,22 @@ section so the contributor command and blocking/advisory split still match CI. 4. Add a fixture file `tests/fixtures/sample.{ext}` and a test module `tests/extraction_suite/{lang}.rs`, then register it with a `mod {lang};` declaration in `tests/extraction_suite/main.rs`. 5. Update the feature flag tables in `Cargo.toml` and this document. +## Validating Plugins and Skills + +Changes under `cursor-plugin/`, `codex-plugin/`, or `src/agents/` are covered +by a layered validation system: vendored JSON-schema checks, per-host skill +frontmatter contracts, cross-bundle sync/parity tests, and a CI +schema-validation workflow. `cursor-plugin/` is the source of truth — never +hand-edit mirrored Codex skills. Before submitting, run: + +```bash +cargo nextest run -E 'binary(=agent_suite)' +``` + +See [`docs/PLUGIN-VALIDATION.md`](docs/PLUGIN-VALIDATION.md) for the full +layer breakdown, schema refresh procedure, and how to add a skill or a new +ecosystem bundle correctly. + ## Running Specific Tests ```bash diff --git a/Cargo.lock b/Cargo.lock index 5885210b..ac910b4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,7 +26,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy 0.8.48", ] @@ -434,6 +436,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bstr" version = "1.12.1" @@ -451,6 +459,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.0" @@ -890,6 +904,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "deranged" version = "0.5.8" @@ -984,6 +1004,15 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1038,6 +1067,17 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "faster-hex" version = "0.10.0" @@ -1091,6 +1131,17 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1118,6 +1169,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "fs2" version = "0.4.3" @@ -1237,6 +1298,20 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -1245,7 +1320,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core 0.10.1", "wasip2", "wasip3", @@ -2762,6 +2837,33 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.46.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24fc8535da347b994f6d2fab0a84b74e3b31ad08ebe4204d95f3e755db7af49b" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex 0.18.0", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + [[package]] name = "kstring" version = "2.0.2" @@ -3037,6 +3139,12 @@ dependencies = [ "libc", ] +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "mime" version = "0.3.17" @@ -3106,18 +3214,88 @@ dependencies = [ "winapi", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-modular" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3168,6 +3346,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking_lot" version = "0.12.5" @@ -3426,6 +3610,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -3538,6 +3728,43 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "referencing" +version = "0.46.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c8dceb372b46fecd006be48ca335468b49ad887a7f0150cabd6098fa4fc500f" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.3" @@ -4122,7 +4349,7 @@ dependencies = [ "anyhow", "base64 0.22.1", "bstr", - "fancy-regex", + "fancy-regex 0.17.0", "lazy_static", "regex", "rustc-hash 2.1.2", @@ -4515,6 +4742,7 @@ dependencies = [ "glob", "hex", "ignore", + "jsonschema", "libsql", "logo-art", "memmap2", @@ -5040,6 +5268,12 @@ version = "2.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -5141,12 +5375,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index f0406bc5..d2979965 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -160,6 +160,7 @@ criterion = { version = "0.5", features = ["async_tokio", "html_reports"] } # tests (daemon restart-grace windows) run on virtual time instead of real # sleeps. tokio = { version = "1", features = ["full", "test-util"] } +jsonschema = { version = "0.46.8", default-features = false } [[bench]] name = "large_repos" diff --git a/codex-plugin/.codex-plugin/plugin.json b/codex-plugin/.codex-plugin/plugin.json index 48cc0936..78c029cb 100644 --- a/codex-plugin/.codex-plugin/plugin.json +++ b/codex-plugin/.codex-plugin/plugin.json @@ -3,8 +3,7 @@ "version": "0.0.0", "description": "Codex integration for TraceDecay semantic code intelligence: MCP server plus a `tracedecay tool` CLI fallback exposing the same tools when MCP is unavailable.", "author": { - "name": "ScriptedAlchemy", - "url": "https://github.com/ScriptedAlchemy" + "name": "ScriptedAlchemy" }, "homepage": "https://github.com/ScriptedAlchemy/tracedecay", "repository": "https://github.com/ScriptedAlchemy/tracedecay", diff --git a/codex-plugin/skills/curating-project-memory/SKILL.md b/codex-plugin/skills/curating-project-memory/SKILL.md index cb456574..47f66b63 100644 --- a/codex-plugin/skills/curating-project-memory/SKILL.md +++ b/codex-plugin/skills/curating-project-memory/SKILL.md @@ -17,7 +17,7 @@ This skill owns memory lifecycle changes. For read-only recall, start with `trac 4. **Inventory candidates:** group facts into add, update, merge/dedupe, stale, contradiction, secret-like, transient, supersession, and possible hard-delete buckets. Keep fact ids, source/provenance, trust, tags, entities, evidence links, and counterevidence with each candidate. 5. **Research gaps:** use TraceDecay graph/search plus LCM/session/message tools to mine past sessions, raw messages, summary DAGs, branch/PR context, docs, and tests. For multi-step evidence gathering, scoped subagents may research bounded read-only questions only; the parent agent is the sole memory writer and must review raw findings before trusting them. 6. **Propose changes:** summarize durable additions, stale-fact updates, trust/tag/source changes, dedupe merges, and delete candidates. Prefer update/merge over removal when useful provenance should survive. -7. **Apply narrowly:** add/update only facts supported by evidence. Use `/curate/apply` or `tracedecay memory curate --llm-ops --apply` only for reviewed operations. Require explicit approval immediately before every `action: "remove"`, dashboard hard delete, or merge loser removal, showing fact id, content/source summary, reason, and permanent-delete warning. +7. **Apply narrowly:** add/update only facts supported by evidence. Use `POST /api/plugins/holographic/curate/apply` or `tracedecay memory curate --llm-ops --apply` only for reviewed operations. Require explicit approval immediately before every `action: "remove"`, dashboard hard delete, or merge loser removal, showing fact id, content/source summary, reason, and permanent-delete warning. 8. **Verify read-only:** re-run search/list/probe/related/contradict/get as appropriate, inspect apply results/oplog when used, and report final facts changed, skipped, or still needing human judgment. ## Guardrails @@ -25,7 +25,7 @@ This skill owns memory lifecycle changes. For read-only recall, start with `trac - `get` and `contradict` are non-destructive recall. Search/list/probe/related/reason are read-mostly but can update access/retrieval counters. Add/update/remove, feedback, memory status repair, and dashboard start/stop mutate state or launch a local process; respect host approval/run-mode. - Deletion is permanent: there is no archive, soft-delete, restore, or undo path. Prefer update/merge when useful provenance should survive; delete only approved stale, duplicate, wrong, secret-like, or user-requested facts. - Never store secrets, credentials, API keys, or PII. Do not lower trust merely because a fact is old; cite the newer evidence or contradiction. -- Dashboard curation can apply hard deletes. Use preview/dry-run first when available and surface high-risk delete/merge operations before applying them. `POST /api/plugins/holographic/curate` with `dry_run=false` applies deterministic duplicate deletion; `/curate/apply` applies explicit delete/merge ops. +- Dashboard curation can apply hard deletes. Use preview/dry-run first when available and surface high-risk delete/merge operations before applying them. `POST /api/plugins/holographic/curate` with `dry_run=false` applies deterministic duplicate deletion; `POST /api/plugins/holographic/curate/apply` applies explicit delete/merge ops. - Do not let subagents call add/update/remove/feedback tools, apply curation ops, start dashboard mutation flows, or run memory health repair. Ask them for cited evidence, candidate facts, suspected duplicates, and stale/conflicting claims, then perform parent-agent validation before writing. - Default autonomous grooming output is report-only. If a tool or dashboard action mutates unexpectedly, disclose it and verify state before continuing. - Hygiene candidates (`secret_like`, `transient`, `supersession`) are review evidence, not deterministic apply operations. @@ -36,7 +36,7 @@ This skill owns memory lifecycle changes. For read-only recall, start with `trac Before any mutation, produce a compact report with these sections: - `scope`: project root/store, tool/API used, dry-run timestamp, and whether memory health repair or dashboard start/stop was invoked. -- `native_plan`: `mode`, `provider`, `coverage`, `counts`, action count, and hygiene-candidate counts from `tracedecay memory curate` or `/curate`. +- `native_plan`: `mode`, `provider`, `coverage`, `counts`, action count, and hygiene-candidate counts from `tracedecay memory curate` or `POST /api/plugins/holographic/curate`. - `adds`: candidate durable facts with source spans, category, entities, trust, and duplicate-search result. - `updates`: fact ids, old/new summary, evidence, confidence, and why update beats add. - `merges`: winner/loser ids, similarity evidence, retained provenance, optional `merged_content`, and why separate facts are redundant. diff --git a/cursor-plugin/.cursor-plugin/plugin.json b/cursor-plugin/.cursor-plugin/plugin.json index 0219f7ca..98cb0f27 100644 --- a/cursor-plugin/.cursor-plugin/plugin.json +++ b/cursor-plugin/.cursor-plugin/plugin.json @@ -3,8 +3,7 @@ "version": "0.0.0", "description": "Cursor integration for TraceDecay semantic code intelligence: MCP server plus a `tracedecay tool` CLI fallback exposing the same tools when MCP is unavailable.", "author": { - "name": "ScriptedAlchemy", - "url": "https://github.com/ScriptedAlchemy" + "name": "ScriptedAlchemy" }, "homepage": "https://github.com/ScriptedAlchemy/tracedecay", "repository": "https://github.com/ScriptedAlchemy/tracedecay", diff --git a/cursor-plugin/skills/curating-project-memory/SKILL.md b/cursor-plugin/skills/curating-project-memory/SKILL.md index cb456574..47f66b63 100644 --- a/cursor-plugin/skills/curating-project-memory/SKILL.md +++ b/cursor-plugin/skills/curating-project-memory/SKILL.md @@ -17,7 +17,7 @@ This skill owns memory lifecycle changes. For read-only recall, start with `trac 4. **Inventory candidates:** group facts into add, update, merge/dedupe, stale, contradiction, secret-like, transient, supersession, and possible hard-delete buckets. Keep fact ids, source/provenance, trust, tags, entities, evidence links, and counterevidence with each candidate. 5. **Research gaps:** use TraceDecay graph/search plus LCM/session/message tools to mine past sessions, raw messages, summary DAGs, branch/PR context, docs, and tests. For multi-step evidence gathering, scoped subagents may research bounded read-only questions only; the parent agent is the sole memory writer and must review raw findings before trusting them. 6. **Propose changes:** summarize durable additions, stale-fact updates, trust/tag/source changes, dedupe merges, and delete candidates. Prefer update/merge over removal when useful provenance should survive. -7. **Apply narrowly:** add/update only facts supported by evidence. Use `/curate/apply` or `tracedecay memory curate --llm-ops --apply` only for reviewed operations. Require explicit approval immediately before every `action: "remove"`, dashboard hard delete, or merge loser removal, showing fact id, content/source summary, reason, and permanent-delete warning. +7. **Apply narrowly:** add/update only facts supported by evidence. Use `POST /api/plugins/holographic/curate/apply` or `tracedecay memory curate --llm-ops --apply` only for reviewed operations. Require explicit approval immediately before every `action: "remove"`, dashboard hard delete, or merge loser removal, showing fact id, content/source summary, reason, and permanent-delete warning. 8. **Verify read-only:** re-run search/list/probe/related/contradict/get as appropriate, inspect apply results/oplog when used, and report final facts changed, skipped, or still needing human judgment. ## Guardrails @@ -25,7 +25,7 @@ This skill owns memory lifecycle changes. For read-only recall, start with `trac - `get` and `contradict` are non-destructive recall. Search/list/probe/related/reason are read-mostly but can update access/retrieval counters. Add/update/remove, feedback, memory status repair, and dashboard start/stop mutate state or launch a local process; respect host approval/run-mode. - Deletion is permanent: there is no archive, soft-delete, restore, or undo path. Prefer update/merge when useful provenance should survive; delete only approved stale, duplicate, wrong, secret-like, or user-requested facts. - Never store secrets, credentials, API keys, or PII. Do not lower trust merely because a fact is old; cite the newer evidence or contradiction. -- Dashboard curation can apply hard deletes. Use preview/dry-run first when available and surface high-risk delete/merge operations before applying them. `POST /api/plugins/holographic/curate` with `dry_run=false` applies deterministic duplicate deletion; `/curate/apply` applies explicit delete/merge ops. +- Dashboard curation can apply hard deletes. Use preview/dry-run first when available and surface high-risk delete/merge operations before applying them. `POST /api/plugins/holographic/curate` with `dry_run=false` applies deterministic duplicate deletion; `POST /api/plugins/holographic/curate/apply` applies explicit delete/merge ops. - Do not let subagents call add/update/remove/feedback tools, apply curation ops, start dashboard mutation flows, or run memory health repair. Ask them for cited evidence, candidate facts, suspected duplicates, and stale/conflicting claims, then perform parent-agent validation before writing. - Default autonomous grooming output is report-only. If a tool or dashboard action mutates unexpectedly, disclose it and verify state before continuing. - Hygiene candidates (`secret_like`, `transient`, `supersession`) are review evidence, not deterministic apply operations. @@ -36,7 +36,7 @@ This skill owns memory lifecycle changes. For read-only recall, start with `trac Before any mutation, produce a compact report with these sections: - `scope`: project root/store, tool/API used, dry-run timestamp, and whether memory health repair or dashboard start/stop was invoked. -- `native_plan`: `mode`, `provider`, `coverage`, `counts`, action count, and hygiene-candidate counts from `tracedecay memory curate` or `/curate`. +- `native_plan`: `mode`, `provider`, `coverage`, `counts`, action count, and hygiene-candidate counts from `tracedecay memory curate` or `POST /api/plugins/holographic/curate`. - `adds`: candidate durable facts with source spans, category, entities, trust, and duplicate-search result. - `updates`: fact ids, old/new summary, evidence, confidence, and why update beats add. - `merges`: winner/loser ids, similarity evidence, retained provenance, optional `merged_content`, and why separate facts are redundant. diff --git a/docs/PLUGIN-VALIDATION.md b/docs/PLUGIN-VALIDATION.md new file mode 100644 index 00000000..587c71e7 --- /dev/null +++ b/docs/PLUGIN-VALIDATION.md @@ -0,0 +1,325 @@ +# Plugin and Skill Validation + +How the bundled agent plugins (`cursor-plugin/`, `codex-plugin/`) and their +skills are validated, where each check runs, and how to extend the system +without breaking the contracts. + +This document covers the validation of the *agent integration bundles* — the +Cursor plugin, the Codex plugin, and their skills, hooks, rules, and MCP +registrations. It is unrelated to the language-extractor plugin runtime +described in [`PLUGINS-DESIGN.md`](PLUGINS-DESIGN.md). + +--- + +## Why this exists + +The plugin bundles are consumed by external hosts (Cursor, Codex, Claude Code) +whose loaders are strict and whose failure mode is usually *silent*: a manifest +key with a typo, a skill frontmatter field the host doesn't allow, or an MCP +config that doesn't match the host's schema simply causes the component to not +load. Nothing in `cargo build` catches that. The validation layers below turn +those silent failures into test failures. + +--- + +## Validation layers + +The checks form layers, from cheapest to most end-to-end. Layers 1–5 run +locally under `cargo nextest run` (and therefore also in CI's normal test +job); layer 6 covers what can't live in the Rust test harness. + +### 1. Schema validation (cargo test) + +JSON artifacts in the bundles are validated against vendored JSON Schemas in +`tests/fixtures/cursor-schemas/`: + +| Artifact | Schema | Test | +|---|---|---| +| `cursor-plugin/.cursor-plugin/plugin.json` | `plugin.schema.json` | `tests/agent_suite/plugin_manifest_schema_test.rs` | +| `codex-plugin/.codex-plugin/plugin.json` | `plugin.schema.json` + `interface` extension | `tests/agent_suite/plugin_manifest_schema_test.rs` | +| Marketplace index | `marketplace.schema.json` | vendored for refresh parity; this repo ships no marketplace index today | +| `cursor-plugin/mcp.json` | `mcp.schema.json` | `tests/agent_suite/plugin_config_schema_test.rs` | +| `cursor-plugin/hooks/hooks.json` and `codex-plugin/hooks/hooks.json` | `hooks.schema.json` | `tests/agent_suite/plugin_config_schema_test.rs` | + +The tests use the `jsonschema` crate (dev-dependency only, no network +resolvers — the schemas are self-contained draft-07, and the shipped binary +never validates schemas at runtime). + +Beyond schema shape, `tests/agent_suite/plugin_manifest_schema_test.rs` also asserts that +every component path a manifest declares (`skills/`, `hooks/hooks.json`, +`rules/*.mdc`, …) resolves to a real file or directory in the bundle, and +that both bundles share the same plugin `name`. The config-schema tests +include negative cases proving the mcp/hooks schemas actually reject +malformed configs (missing `command`, unknown fields, typo'd event names). + +The Cursor plugin schema declares `additionalProperties: false`, and Codex +marketplaces read an `interface` display-metadata block that Cursor's schema +doesn't define. The Codex manifest is therefore validated against the Cursor +schema plus exactly that one extra key, derived in the test. + +### 2. Skill contract tests (cargo test) + +`tests/agent_suite/plugin_skill_contract_test.rs` enforces the per-host skill +contract over every `SKILL.md` in both bundles: + +- **Frontmatter allowlists per host.** Codex skills may only use the keys + accepted by Codex's `quick_validate.py` (`name`, `description`, + `allowed-tools`, `license`, `metadata`); Cursor skills additionally allow + `disable-model-invocation`. `name` and `description` are required + everywhere. +- **Size budgets.** Skill bodies stay under 500 lines; descriptions stay under + 320 characters / 45 words; a bundle's total preloaded name+description + metadata stays under 6,000 characters so skill discovery never crowds the + host's context window. +- **Trigger-first descriptions.** Every description must contain trigger + language ("Use when …"), because hosts only show agents the metadata before + the body is loaded. A body-only "When to Use" section is rejected. +- **Supported resource layout.** A skill directory may only contain + `SKILL.md` plus the supported resource directories (`agents/`, `scripts/`, + `references/`, `assets/`). +- **Byte-copy install parity.** Installing the Cursor or Codex integration + into a temp home must produce a byte-identical copy of the source skill + tree — this catches install-time mutation and missing `include_str!` + registrations (see [Adding a skill](#adding-a-skill-correctly)). + +On top of the shared contract, `tests/agent_suite/skill_lint_cursor_test.rs` lints the +Cursor bundle with rules adapted from community SKILL.md linters (skillmark, +skilldoctor, skillkit) and Cursor's skills docs: + +- **File hygiene:** no BOM, CRLF, tabs, or trailing whitespace; exactly one + trailing newline; balanced code fences; no placeholder text; non-empty + body. +- **Heading conventions:** exactly one H1; no skipped heading levels; a + slash-form H1 (`# /slug`) must match the skill `name` and requires + `disable-model-invocation: true`. +- **Name/description quality:** no reserved `claude`/`anthropic` prefixes; + descriptions ≥ 50 chars, unique across the bundle, ending in terminal + punctuation, with no angle brackets. +- **Reference resolution:** relative links, bundled-resource mentions, + `tracedecay:` cross-skill references, backticked `/slug` invocations, + and every `tracedecay_` tool identifier must resolve (tool names are + checked against the live `tracedecay::mcp::get_tool_definitions()` list); + `paths` globs must be relative, forward-slash, without `..`. + +### 3. Cross-bundle sync (cargo test) + +`cursor-plugin/` is the **source of truth**. The Codex bundle is a mirror of +the Cursor skills, embedded via `include_str!` in `src/agents/codex.rs` and +checked by the unit test `codex_skills_match_the_cursor_source_for_parity`: +every model-invocable Cursor skill (the `hooks::CURSOR_PLUGIN_SKILLS` list in +`src/hooks.rs`) must exist in the Codex bundle, and content divergence is +only allowed through explicit per-skill allowlists in that test. Cursor-only +skills — the `tracedecay-*` slash dispatchers — are exempt from mirroring. + +Practical consequence: **never edit a `codex-plugin/skills/*/SKILL.md` by +hand.** Edit the Cursor source and propagate, or the parity test fails. + +On top of the skill-level parity, `tests/agent_suite/plugin_bundle_sync_test.rs` enforces +disk-level cross-bundle sync through three declarative tables: the bundle +list, a top-level manifest assigning every bundle entry a policy +(`SyncedSkills`, `HostSpecific { reason }`, or `OnlyIn { bundles, reason }`), +and a skill exception table (`OnlyIn`, `DivergentBody`, +`DivergentFrontmatter`). The default is strict — every skill ships in every +bundle with a byte-identical tree — and any deviation needs a documented +exception. The tables are self-cleaning: an undeclared divergence fails, and +so does a *stale* exception (an `OnlyIn` that no longer matches, or a +declared divergence that no longer diverges). The exception table mirrors the +codex.rs allowlists — if the two drift apart, one of the tests fails and +names the other — and the set of skills shared by every bundle must equal +`hooks::CURSOR_PLUGIN_SKILLS`. The assertions are bundle-count agnostic: a +future ecosystem bundle joins the check by adding one `Bundle` row plus its +manifest and exception entries. + +### 4. Rendered-output and manifest-path validation (cargo test) + +Beyond validating the *source* bundles, install-time output is validated. + +**Manifest location.** Cursor requires the manifest at +`.cursor-plugin/plugin.json` inside the plugin root (per +[cursor.com/docs/plugins](https://cursor.com/docs/plugins) and the official +[cursor/plugins](https://github.com/cursor/plugins) marketplace repo). This +repo already conforms — `cursor-plugin/.cursor-plugin/plugin.json` in source, +and `src/agents/cursor.rs` renders it to +`~/.cursor/plugins/local/tracedecay/.cursor-plugin/plugin.json`. The layout is +pinned by existing assertions in `tests/agent_suite/agent_test.rs` and +`tests/agent_suite/update_plugin_test.rs`. Note that plain `ls` hides the +dot-directory; use `ls -a` before concluding a bundle has no manifest. + +**Rendered output.** Installers stamp `CARGO_PKG_VERSION` into the manifest +and rewrite MCP/hook commands to the resolved absolute `tracedecay` binary +path; source bundles keep `"version": "0.0.0"` and a bare `tracedecay` +command. Rendered-output validation in +`tests/agent_suite/update_plugin_test.rs` +(`cursor_install_renders_structurally_valid_bundle` and friends) installs +into a temp home and checks the rendered artifacts: the rendered manifest is +validated against the vendored plugin schema (full draft-07 validation, same +`jsonschema` crate as layer 1), every source file appears in the rendered install, +no unresolved `${...}` placeholders survive in rendered JSON (except the +intentional `${workspaceFolder}` MCP arg), and hook/MCP commands reference +the shell-quoted absolute binary path. This complements the byte-copy skill +parity in layer 2. + +### 5. Claude Code portability (cargo test) + +`tests/agent_suite/skill_lint_claude_test.rs` lints every skill in both bundles against +Claude Code / Agent Skills portability rules, so a future `claude-plugin/` +bundle would be a re-packaging exercise rather than a rewrite. The rules +(sources cited in the test's module docs) include: frontmatter keys limited +to Claude-Code-documented fields, kebab-case `name` matching the directory +(≤ 64 chars, no XML tags, no reserved words `anthropic`/`claude`), +`description` non-empty with no angle brackets (≤ 1,024 chars, and +`description` + `when_to_use` ≤ 1,536 chars — Claude Code truncates listings +beyond that), and the shared 6,000-char per-bundle metadata budget. + +One Cursor-required field conflicts with the strict Agent Skills open spec — +`disable-model-invocation`. Claude Code itself supports it, so it is a +*documented skip* (`CROSS_ECOSYSTEM_CONFLICT_FIELDS` in the test), with a +stale-allowlist guard that fails if a documented conflict field stops being +used. Any *new* nonconformant field fails the strict-spec test, and the Codex +bundle must stay 100% spec-clean. + +### 6. Checks outside the Rust test harness + +Most validation deliberately lives in `cargo test`, because the existing +`ci.yml` `test` job already runs the full suite on every PR — a check that can +be a `#[test]` needs no new YAML. CI-only additions are limited to what cargo +can't do: + +- **Schema-validation workflow** + (`.github/workflows/plugin-validation.yml`, `manifest-schema` job). Mirrors + the official `cursor/plugins` marketplace validation: `ajv` compiles all + four vendored schemas (so a broken schema edit fails even when no manifest + changed), validates the Cursor manifest against `plugin.schema.json`, and + parse-checks every `*.json` in both bundles. The Codex manifest is only + parse-checked here (its layout differs from Cursor's); its semantics are + covered by the Rust tests. The workflow is path-filtered to bundle, schema, + and plugin-test paths, so it shows as *skipped* on unrelated PRs — account + for that before making it a required check. +- **MCP conformance smoke** (`scripts/mcp-conformance-smoke.sh`, run in CI by + the `mcp-conformance-smoke` job of the same workflow). Drives a real + `tracedecay serve` process through the official MCP Inspector CLI + (pinned version, warm-cache offline), which embeds the official TypeScript + SDK client. This adds what the Rust `tests/mcp_suite/` cannot: a + protocol-version-negotiation handshake with a newer client, SDK-side (Zod) + shape validation of capabilities and every tool's `inputSchema`, and the + real client lifecycle ordering. Seven checks run against a hermetic + throwaway fixture project (redirected HOME, ~6 s warm). It needs a built + binary and npx, which is why it isn't a plain `#[test]`. Run it directly, + or with `TRACEDECAY_BIN=target/debug/tracedecay` to pin the binary. The + official `@modelcontextprotocol/conformance` suite was evaluated and + rejected for now — it only connects over streamable HTTP and + `tracedecay serve` is stdio-only; revisit if an HTTP transport lands. + +--- + +## Vendored schemas: provenance and refresh + +The Cursor schemas live in `tests/fixtures/cursor-schemas/`. They are +*vendored*, not fetched at test time: tests must pass offline and must not +break when an upstream URL moves. Each schema carries an `$id` recording the +upstream identity it mirrors (e.g. +`https://cursor.com/schemas/cursor-plugin/plugin.json`). + +The four schemas have two kinds of provenance: + +- **`plugin.schema.json` and `marketplace.schema.json`** are copies from the + official [cursor/plugins](https://github.com/cursor/plugins) marketplace + repository, which publishes them and validates its own plugins against them + in CI. Refresh by diffing against the current upstream copy and recording + the upstream commit hash in your commit message. +- **`mcp.schema.json` and `hooks.schema.json`** are *derived*, not copied: + Cursor publishes no standalone machine-readable schema for `mcp.json` or + `hooks.json` (the official plugin schema types those inline fields as bare + objects). They were written from Cursor's field references at + [cursor.com/docs/context/mcp](https://cursor.com/docs/context/mcp) and + [cursor.com/docs/hooks](https://cursor.com/docs/hooks), cross-checked + against the hooks configs shipped by official plugins in `cursor/plugins`. + Each schema's top-level `description` records the derivation details and + date. Hook event names are enumerated from the documented list, so a Cursor + release that adds new events requires re-vendoring. + +After any refresh, run the schema tests; if the bundles no longer validate, +fix the bundles in the same change — a schema refresh that breaks the shipped +manifests is a real finding, not test noise. (Exactly this happened when the +schemas were first vendored: the manifests carried an `author.url` key the +official schema rejects.) + +--- + +## Adding a skill correctly + +1. **Create the Cursor source skill:** a new directory + `cursor-plugin/skills//SKILL.md` with `name` and `description` + frontmatter. Keep the description trigger-first ("Use when …"), under 320 + characters and 45 words; keep the body under 500 lines. Use only allowed + frontmatter keys (see layer 2 above). Slash-command skills set + `disable-model-invocation: true` and use a `tracedecay-` slug. +2. **Register the embed:** add the file to the `EMBEDDED_PLUGIN_FILES` + `include_str!` list in `src/agents/cursor.rs`, and — if the skill is + model-invocable — to `hooks::CURSOR_PLUGIN_SKILLS` in `src/hooks.rs`. The + byte-copy parity test fails if the installed bundle and the source tree + diverge. +3. **Mirror to Codex (if model-invocable):** add the skill to + `codex-plugin/skills/` and to the `include_str!` list in + `src/agents/codex.rs`. Keep it byte-identical to the Cursor source unless + you add an entry to the divergence allowlist in + `codex_skills_match_the_cursor_source_for_parity` with a reason. +4. **Watch the metadata budget:** the summed name+description metadata per + bundle must stay under 6,000 characters. If your addition tips it over, + tighten descriptions rather than raising the budget. +5. **Run the checks:** + + ```bash + cargo nextest run -E 'binary(=agent_suite)' + cargo nextest run codex_skills_match_the_cursor_source_for_parity + ``` + +--- + +## Adding a new ecosystem bundle + +To ship a bundle for another agent host (the way `codex-plugin/` mirrors +`cursor-plugin/`): + +1. **Create the bundle directory** at the repo root (`-plugin/`) with + the host's manifest layout and a `README.md` explaining install and any + host-specific caveats. +2. **Add the integration** in `src/agents/.rs`, embedding bundle files + with `include_str!` so the installed output is generated from the checked-in + source, and register it in `src/agents/mod.rs`. +3. **Treat `cursor-plugin/` as the skill source of truth.** Mirror skills + rather than forking them, and add a parity test in the new integration + module modeled on `codex_skills_match_the_cursor_source_for_parity`, + including a divergence allowlist for justified host-specific edits. +4. **Extend the contract tests:** add the host's frontmatter allowlist and a + contract assertion in + `tests/agent_suite/plugin_skill_contract_test.rs`, plus a byte-copy + install-parity test for the generated bundle. Note that + `tests/agent_suite/` is a single test binary: new modules must be + registered in `tests/agent_suite/main.rs`. +5. **Vendor the host's schemas** (if it publishes any) under + `tests/fixtures/-schemas/` and validate the bundle's JSON artifacts + against them, following the same offline-vendoring rules as the Cursor + schemas. +6. **Wire it into the sync/CI layers:** add a `Bundle` row (plus any + divergence exceptions) in `tests/agent_suite/plugin_bundle_sync_test.rs`, and extend + the CI schema-validation workflow's path filters if the new bundle lives + outside the existing globs. + +--- + +## Quick reference: what runs where + +| Check | Location | Runs in | +|---|---|---| +| Plugin manifest schema + component paths | `tests/agent_suite/plugin_manifest_schema_test.rs` + `tests/fixtures/cursor-schemas/` | `cargo test` (and thereby CI) | +| mcp.json / hooks.json schema validation | `tests/agent_suite/plugin_config_schema_test.rs` | `cargo test` | +| Skill frontmatter contract + size budgets | `tests/agent_suite/plugin_skill_contract_test.rs` | `cargo test` | +| Install byte-copy parity | `tests/agent_suite/plugin_skill_contract_test.rs` | `cargo test` | +| Cursor→Codex skill parity | `src/agents/codex.rs` unit tests | `cargo test` | +| Cross-bundle disk-level sync | `tests/agent_suite/plugin_bundle_sync_test.rs` | `cargo test` | +| Manifest path + rendered output | `tests/agent_suite/agent_test.rs`, `tests/agent_suite/update_plugin_test.rs` | `cargo test` | +| Cursor skill lint rules | `tests/agent_suite/skill_lint_cursor_test.rs` | `cargo test` | +| Claude Code portability rules | `tests/agent_suite/skill_lint_claude_test.rs` | `cargo test` | +| Schema-validation workflow (ajv) | `.github/workflows/plugin-validation.yml` | CI only | +| MCP conformance smoke | `scripts/mcp-conformance-smoke.sh` | manual + CI (`plugin-validation.yml`) | diff --git a/scripts/mcp-conformance-smoke.sh b/scripts/mcp-conformance-smoke.sh new file mode 100755 index 00000000..deccdb4a --- /dev/null +++ b/scripts/mcp-conformance-smoke.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# MCP conformance smoke test: drives `tracedecay serve` (stdio) with the +# official MCP Inspector CLI (@modelcontextprotocol/inspector --cli). +# +# Why this exists on top of tests/mcp_suite/ (which already exercises +# initialize/tools/call with hand-crafted JSON-RPC): the Inspector embeds the +# official TypeScript SDK client, so every call here additionally proves +# - protocol-version negotiation with a *newer* client (the 0.22.0 client +# requests protocolVersion 2025-11-25; the server answers 2024-11-05 and +# the SDK accepts it — the Rust tests only ever send 2024-11-05), +# - SDK-side Zod validation of the initialize result, capability shapes, +# and every tool's inputSchema in tools/list, +# - the notifications/initialized + logging/setLevel lifecycle a real +# client performs. +# +# Requirements: node >= 18 + npx (network on first run to fetch the pinned +# inspector into the npx cache; warm runs are offline), git, and a tracedecay +# binary. Runs against a throwaway fixture project with HOME/XDG redirected +# into the temp dir, so it never touches the user's real tracedecay state. +# +# Usage: +# scripts/mcp-conformance-smoke.sh # auto-detect binary +# TRACEDECAY_BIN=target/debug/tracedecay scripts/mcp-conformance-smoke.sh + +set -euo pipefail + +INSPECTOR_VERSION="${INSPECTOR_VERSION:-0.22.0}" +CALL_TIMEOUT_SECS="${CALL_TIMEOUT_SECS:-60}" + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [[ -z "${TRACEDECAY_BIN:-}" ]]; then + target_dir="${CARGO_TARGET_DIR:-$repo_root/target}" + for candidate in "$target_dir/debug/tracedecay" "$target_dir/release/tracedecay"; do + if [[ -x "$candidate" ]]; then + TRACEDECAY_BIN="$candidate" + break + fi + done +fi +if [[ -z "${TRACEDECAY_BIN:-}" ]]; then + TRACEDECAY_BIN="$(command -v tracedecay || true)" +fi +if [[ -z "$TRACEDECAY_BIN" || ! -x "$TRACEDECAY_BIN" ]]; then + echo "error: no tracedecay binary found; build one or set TRACEDECAY_BIN" >&2 + exit 2 +fi +TRACEDECAY_BIN="$(readlink -f "$TRACEDECAY_BIN")" +echo "using tracedecay binary: $TRACEDECAY_BIN ($("$TRACEDECAY_BIN" --version))" +echo "using inspector: @modelcontextprotocol/inspector@$INSPECTOR_VERSION" + +work_dir="$(mktemp -d "${TMPDIR:-/tmp}/mcp-smoke.XXXXXX")" +trap 'rm -rf "$work_dir"' EXIT + +# Resolve the effective npm cache before HOME is redirected below, so npx +# keeps reusing the warm inspector install instead of re-downloading. +npm_config_cache="${npm_config_cache:-$(npm config get cache)}" +export npm_config_cache + +# Hermetic fixture: tiny indexed Rust project + redirected tracedecay state. +fixture="$work_dir/proj" +mkdir -p "$fixture/src" "$work_dir/home" +printf 'fn main() { println!("hello"); }\n' > "$fixture/src/main.rs" +git -C "$fixture" init --quiet + +export HOME="$work_dir/home" +export XDG_DATA_HOME="$HOME/.local/share" +export XDG_CONFIG_HOME="$HOME/.config" + +(cd "$fixture" && "$TRACEDECAY_BIN" init >/dev/null 2>&1) +"$TRACEDECAY_BIN" disable-upload-counter >/dev/null 2>&1 || true + +inspect() { + # Run from the fixture so the spawned server's cwd matches the indexed + # project (otherwise tool results gain a cwd-mismatch warning block). + (cd "$fixture" && timeout "$CALL_TIMEOUT_SECS" \ + npx -y "@modelcontextprotocol/inspector@$INSPECTOR_VERSION" --cli \ + "$TRACEDECAY_BIN" serve -p "$fixture" "$@") +} + +# json_assert +json_assert() { + node -e ' + const fs = require("fs"); + const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + if (!eval(process.argv[2])) process.exit(1); + ' "$1" "$2" +} + +failures=0 +fail() { + echo "FAIL $1" >&2 + failures=$((failures + 1)) +} +ok() { + echo "ok $1" +} + +# 1. tools/list succeeds through the SDK client (implies the full initialize +# handshake + version negotiation + Zod validation of every tool schema). +tools_a="$work_dir/tools-a.json" +if inspect --method tools/list > "$tools_a" 2>"$work_dir/tools-a.err"; then + ok "tools/list (SDK handshake + schema validation)" + if json_assert "$tools_a" 'Array.isArray(j.tools) && j.tools.length > 5 && j.tools.every(t => t.name && t.inputSchema && t.inputSchema.type === "object")'; then + ok "tools/list has tools with object inputSchemas" + else + fail "tools/list has tools with object inputSchemas" + fi + if json_assert "$tools_a" 'j.tools.some(t => t.name === "tracedecay_search")'; then + ok "tools/list includes tracedecay_search" + else + fail "tools/list includes tracedecay_search" + fi +else + cat "$work_dir/tools-a.err" >&2 + fail "tools/list (SDK handshake + schema validation)" +fi + +# 2. Determinism: a second run must be byte-identical. +tools_b="$work_dir/tools-b.json" +if inspect --method tools/list > "$tools_b" 2>/dev/null && cmp -s "$tools_a" "$tools_b"; then + ok "tools/list is deterministic across runs" +else + fail "tools/list is deterministic across runs" +fi + +# 3. tools/call round-trip against the indexed fixture. +call_out="$work_dir/call.json" +if inspect --method tools/call --tool-name tracedecay_search --tool-arg query=main > "$call_out" 2>/dev/null && + json_assert "$call_out" 'Array.isArray(j.content) && j.content.some(c => c.type === "text" && c.text.includes("Search Results") && c.text.includes("main"))'; then + ok "tools/call tracedecay_search finds main()" +else + fail "tools/call tracedecay_search finds main()" +fi + +# 4. resources/list exposes the status resource. +res_out="$work_dir/resources.json" +if inspect --method resources/list > "$res_out" 2>/dev/null && + json_assert "$res_out" 'Array.isArray(j.resources) && j.resources.some(r => r.uri === "tracedecay://status")'; then + ok "resources/list exposes tracedecay://status" +else + fail "resources/list exposes tracedecay://status" +fi + +# 5. Error path: unknown tool must fail with a nonzero exit code. +if inspect --method tools/call --tool-name definitely_not_a_tool >/dev/null 2>&1; then + fail "tools/call unknown tool exits nonzero" +else + ok "tools/call unknown tool exits nonzero" +fi + +if [[ "$failures" -gt 0 ]]; then + echo "mcp-conformance-smoke: $failures check(s) failed" >&2 + exit 1 +fi +echo "mcp-conformance-smoke: all checks passed" diff --git a/src/agents/codex.rs b/src/agents/codex.rs index fa99cd69..ebcd0335 100644 --- a/src/agents/codex.rs +++ b/src/agents/codex.rs @@ -1601,8 +1601,8 @@ mod tests { /// `disable-model-invocation` surface), so the Codex bundle ships exactly /// the *model-invocable* Cursor skills — the same set the Cursor plugin /// advertises via [`crate::hooks::CURSOR_PLUGIN_SKILLS`]. The Cursor-only - /// slash dispatchers (`tracedecay-*`) and explicit-invoke memory skills are - /// intentionally not mirrored: their workflows are covered by these skills. + /// slash dispatchers (`tracedecay-*`) are intentionally not mirrored: + /// their workflows are covered by these skills. #[test] fn codex_bundle_ships_exactly_the_model_invocable_cursor_skills() { let mut shipped: Vec = CODEX_EMBEDDED_PLUGIN_FILES diff --git a/src/config.rs b/src/config.rs index b6388fd7..dc75004b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -142,10 +142,38 @@ pub fn has_project_database(project_root: &Path) -> bool { /// `~/.tracedecay` unless `TRACEDECAY_DATA_DIR` explicitly overrides it. pub fn user_data_dir() -> Option { if let Some(path) = std::env::var_os(USER_DATA_DIR_ENV).filter(|path| !path.is_empty()) { - return Some(PathBuf::from(path)); + return Some(canonicalize_data_dir(PathBuf::from(path))); } let home = dirs::home_dir()?; - Some(home.join(TRACEDECAY_DIR)) + Some(canonicalize_data_dir(home.join(TRACEDECAY_DIR))) +} + +fn canonicalize_data_dir(path: PathBuf) -> PathBuf { + if !path.is_absolute() { + return path; + } + canonicalize_path_or_existing_parent(&path) +} + +fn canonicalize_path_or_existing_parent(path: &Path) -> PathBuf { + if let Ok(canonical) = path.canonicalize() { + return canonical; + } + + let mut current = path; + let mut missing_suffix = PathBuf::new(); + while let Some(name) = current.file_name() { + missing_suffix = Path::new(name).join(missing_suffix); + let Some(parent) = current.parent() else { + break; + }; + current = parent; + if let Ok(canonical_parent) = current.canonicalize() { + return canonical_parent.join(missing_suffix); + } + } + + path.to_path_buf() } /// Reads the `TRACEDECAY_` environment variable. @@ -575,12 +603,38 @@ pub fn is_excluded(file_path: &str, config: &TraceDecayConfig) -> bool { mod tests { use super::{ db_filename, get_project_db_path, get_tracedecay_dir, is_excluded, is_excluded_dir, - is_ignored_by_explicit_global_excludes, is_ignored_by_git, is_included, TraceDecayConfig, + is_ignored_by_explicit_global_excludes, is_ignored_by_git, is_included, user_data_dir, + TraceDecayConfig, USER_DATA_DIR_ENV, }; + use std::ffi::OsString; use std::fs; use std::process::Command; use tempfile::TempDir; + static USER_DATA_DIR_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + struct EnvRestore { + key: &'static str, + previous: Option, + } + + impl EnvRestore { + fn set(key: &'static str, value: impl AsRef) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, previous } + } + } + + impl Drop for EnvRestore { + fn drop(&mut self) { + match self.previous.take() { + Some(previous) => std::env::set_var(self.key, previous), + None => std::env::remove_var(self.key), + } + } + } + #[test] fn test_data_dir_defaults_to_tracedecay_for_new_installs() { let root = TempDir::new().unwrap(); @@ -604,6 +658,23 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn user_data_dir_canonicalizes_symlinked_existing_parent() { + let _lock = USER_DATA_DIR_ENV_LOCK.lock().unwrap(); + let root = TempDir::new().unwrap(); + let real_home = root.path().join("real-home"); + let linked_home = root.path().join("linked-home"); + fs::create_dir_all(&real_home).unwrap(); + std::os::unix::fs::symlink(&real_home, &linked_home).unwrap(); + let _env = EnvRestore::set(USER_DATA_DIR_ENV, linked_home.join(".tracedecay")); + + assert_eq!( + user_data_dir().unwrap(), + real_home.canonicalize().unwrap().join(".tracedecay") + ); + } + #[test] fn test_db_filename_tracks_dir_brand() { assert_eq!( diff --git a/src/daemon/service.rs b/src/daemon/service.rs index f554a215..1cfba1f3 100644 --- a/src/daemon/service.rs +++ b/src/daemon/service.rs @@ -943,13 +943,13 @@ mod tests { let _data_dir_guard = EnvVarGuard::set(crate::config::USER_DATA_DIR_ENV, profile.path()); let status = super::service_status(&PathBuf::from("/tmp/tracedecay.sock")); + let expected_log = crate::config::user_data_dir() + .expect("user data dir") + .join("daemon.err.log"); assert!(status.contains("service-detail: launchctl print gui/")); assert!(status.contains("/com.tracedecay.daemon")); - assert!(status.contains(&format!( - "logs: tail -f \"{}\"", - profile.path().join("daemon.err.log").display() - ))); + assert!(status.contains(&format!("logs: tail -f \"{}\"", expected_log.display()))); } #[cfg(unix)] @@ -1375,19 +1375,22 @@ mod tests { crate::config::USER_DATA_DIR_ENV, profile.path().join(".tracedecay"), ); + let expected_socket = crate::config::user_data_dir() + .expect("user data dir") + .join("daemon.sock"); { let _cwd_guard = CurrentDirGuard::set(project_a.path()); assert_eq!( super::default_socket_path().expect("default socket path"), - profile.path().join(".tracedecay/daemon.sock") + expected_socket ); } { let _cwd_guard = CurrentDirGuard::set(project_b.path()); assert_eq!( super::default_socket_path().expect("default socket path"), - profile.path().join(".tracedecay/daemon.sock") + expected_socket ); } diff --git a/tests/agent_suite/agent_test.rs b/tests/agent_suite/agent_test.rs index b7deaaf9..cfa3437b 100644 --- a/tests/agent_suite/agent_test.rs +++ b/tests/agent_suite/agent_test.rs @@ -4760,7 +4760,15 @@ fn test_healthcheck_cursor_local_install_checks_project_config() { #[tokio::test(flavor = "multi_thread")] async fn test_cursor_healthcheck_warns_on_literal_workspace_folder_transcript_path() { + // TraceDecay::init and healthcheck read the user data dir from the + // process env, which other tests pin via EnvVarGuard — serialize and pin + // like every other TraceDecay::init test in this suite. + let _env_lock = AGENT_ENV_LOCK.lock().await; let home = TempDir::new().unwrap(); + let data_dir = home.path().join(".tracedecay"); + std::fs::create_dir_all(&data_dir).unwrap(); + let data_dir = data_dir.canonicalize().unwrap(); + let _data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, data_dir); let project = TempDir::new().unwrap(); CursorIntegration .install(&make_install_ctx(home.path())) diff --git a/tests/agent_suite/main.rs b/tests/agent_suite/main.rs index 52f911d6..b06d5b28 100644 --- a/tests/agent_suite/main.rs +++ b/tests/agent_suite/main.rs @@ -17,8 +17,14 @@ mod managed_skill_archive_test; mod managed_skills_test; mod memory_digest_test; mod opencode_agent_test; +mod plugin_bundle_sync_test; +mod plugin_config_schema_test; +mod plugin_manifest_schema_test; mod plugin_skill_contract_test; +mod plugin_validation_support; mod prompt_rules_parity_test; +mod skill_lint_claude_test; +mod skill_lint_cursor_test; mod skill_targets_test; mod skill_usage_test; mod tool_skill_coverage_test; diff --git a/tests/agent_suite/plugin_bundle_sync_test.rs b/tests/agent_suite/plugin_bundle_sync_test.rs new file mode 100644 index 00000000..421456bd --- /dev/null +++ b/tests/agent_suite/plugin_bundle_sync_test.rs @@ -0,0 +1,424 @@ +//! Cross-bundle sync enforcement for the shipped plugin source bundles. +//! +//! `cursor-plugin/` and `codex-plugin/` must not silently drift: every content +//! unit is either present and byte-identical in all bundles, or covered by a +//! declarative exception below that documents why it diverges or is absent. +//! +//! Division of labour with existing tests (do not duplicate them here): +//! - `tests/agent_suite/plugin_skill_contract_test.rs` — per-host frontmatter +//! schema and skill-creator design budgets for each bundle in isolation. +//! - `src/agents/cursor.rs` unit tests +//! (`embedded_file_list_covers_the_whole_source_bundle`) and +//! `src/agents/codex.rs` unit tests +//! (`codex_embedded_file_list_covers_the_whole_source_bundle`) — the +//! private `EMBEDDED_PLUGIN_FILES` / `CODEX_EMBEDDED_PLUGIN_FILES` +//! registries must cover exactly the on-disk bundle trees. +//! - `src/agents/codex.rs` `codex_skills_match_the_cursor_source_for_parity` +//! — the original two-bundle skill parity check with its own allowlists. +//! The skill exceptions below mirror those allowlists; if the two tables +//! drift apart, one of the two tests fails and points at the other. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use tracedecay::hooks::CURSOR_PLUGIN_SKILLS; + +use crate::plugin_validation_support::{relative_files_under, repo_path}; + +/// One shipped plugin source bundle, rooted at the repo top level. +struct Bundle { + name: &'static str, + root: &'static str, +} + +/// Every ecosystem bundle shipped from this repo. +const BUNDLES: &[Bundle] = &[ + Bundle { + name: "cursor", + root: "cursor-plugin", + }, + Bundle { + name: "codex", + root: "codex-plugin", + }, +]; + +/// The directory of shared content units governed by [`SKILL_SYNC_EXCEPTIONS`]. +const SKILLS_DIR: &str = "skills"; + +/// Sync policy for a top-level bundle entry (file or directory). +enum TopLevelPolicy { + /// Present in every bundle; child units must be byte-identical across + /// bundles unless excepted in [`SKILL_SYNC_EXCEPTIONS`]. + SyncedSkills, + /// Present in every bundle, but the content is host-specific by design. + HostSpecific { reason: &'static str }, + /// Present only in the listed bundles. + OnlyIn { + bundles: &'static [&'static str], + reason: &'static str, + }, +} + +/// Declarative manifest of every top-level entry a bundle may contain. +/// [`bundle_top_level_entries_match_the_sync_manifest`] enforces exact +/// equality in both directions, so a new content category (or a stale row) +/// cannot slip in without a documented policy. +const TOP_LEVEL_MANIFEST: &[(&str, TopLevelPolicy)] = &[ + ( + ".cursor-plugin", + TopLevelPolicy::OnlyIn { + bundles: &["cursor"], + reason: "Cursor requires the manifest at .cursor-plugin/plugin.json", + }, + ), + ( + ".codex-plugin", + TopLevelPolicy::OnlyIn { + bundles: &["codex"], + reason: "Codex requires the manifest at .codex-plugin/plugin.json", + }, + ), + ( + "mcp.json", + TopLevelPolicy::OnlyIn { + bundles: &["cursor"], + reason: "Cursor's manifest points at a root mcp.json for MCP server config", + }, + ), + ( + ".mcp.json", + TopLevelPolicy::OnlyIn { + bundles: &["codex"], + reason: "Codex reads MCP server config from a dotted .mcp.json at the bundle root", + }, + ), + ( + "README.md", + TopLevelPolicy::HostSpecific { + reason: "install and usage instructions are written per host", + }, + ), + ( + "hooks", + TopLevelPolicy::HostSpecific { + reason: "cursor ships hook-cursor-* commands; the codex source bundle ships an \ + empty {\"hooks\": {}} stub and the installer bakes commands at render \ + time", + }, + ), + ( + "agents", + TopLevelPolicy::OnlyIn { + bundles: &["cursor"], + reason: "subagent definitions are a Cursor-only plugin surface", + }, + ), + ( + "rules", + TopLevelPolicy::OnlyIn { + bundles: &["cursor"], + reason: "always-applied rules are a Cursor-only plugin surface; Codex gets \ + equivalent steering via session context (src/hooks.rs)", + }, + ), + ("skills", TopLevelPolicy::SyncedSkills), +]; + +/// How a skill is allowed to deviate from full cross-bundle byte parity. +enum SkillSyncRule { + /// Shipped only in the listed bundles (must match actual presence + /// exactly, so a stale exception fails). + OnlyIn { + bundles: &'static [&'static str], + reason: &'static str, + }, +} + +/// Documented exceptions to "every skill ships in every bundle, +/// byte-identical". Skills absent from this table get the strict default. +/// Keep entries narrow: this table is for real host-surface differences such +/// as Cursor-only slash dispatchers, not for stale historical divergences. +const SKILL_SYNC_EXCEPTIONS: &[(&[&str], SkillSyncRule)] = &[( + &[ + "tracedecay-audit-safety", + "tracedecay-check-health", + "tracedecay-clean-dead-code", + "tracedecay-compare-branches", + "tracedecay-curate-memory", + "tracedecay-draft-commit", + "tracedecay-find-impact", + "tracedecay-fix-build", + "tracedecay-map-architecture", + "tracedecay-port-code", + "tracedecay-recall-memory", + "tracedecay-review-diff", + "tracedecay-test-changes", + ], + SkillSyncRule::OnlyIn { + bundles: &["cursor"], + reason: "Cursor-only slash dispatchers (disable-model-invocation: true) that \ + hand off to the shared workflow skills; Codex auto-discovers the \ + workflow skills directly", + }, +)]; + +#[test] +fn bundle_top_level_entries_match_the_sync_manifest() { + assert_only_in_lists_name_real_bundles(); + for bundle in BUNDLES { + let actual = sorted_dir_entry_names(&repo_path(bundle.root)); + let mut expected: Vec = TOP_LEVEL_MANIFEST + .iter() + .filter(|(_, policy)| policy_applies_to(policy, bundle.name)) + .map(|&(entry, _)| entry.to_string()) + .collect(); + expected.sort(); + assert_eq!( + actual, expected, + "{}/ top-level entries drifted from TOP_LEVEL_MANIFEST in \ + tests/agent_suite/plugin_bundle_sync_test.rs; declare new content units \ + with a sync policy (or remove stale manifest rows)", + bundle.root + ); + } +} + +#[test] +fn skills_are_synced_across_bundles_or_declared_exceptions() { + let presence = skill_presence_by_bundle(); + let exceptions = skill_exception_index(); + + for &skill in exceptions.keys() { + assert!( + presence.contains_key(skill), + "SKILL_SYNC_EXCEPTIONS names `{skill}`, which no bundle ships; remove the \ + stale exception" + ); + } + + let every_bundle: BTreeSet<&'static str> = BUNDLES.iter().map(|bundle| bundle.name).collect(); + for (skill, shipped_in) in &presence { + match exceptions.get(skill.as_str()) { + Some(SkillSyncRule::OnlyIn { bundles, reason }) => { + let declared: BTreeSet<&'static str> = bundles.iter().copied().collect(); + assert_eq!( + shipped_in, &declared, + "skill `{skill}` is declared OnlyIn {declared:?} ({reason}) but ships \ + in {shipped_in:?}; fix the bundles or the exception" + ); + } + None => { + assert_eq!( + shipped_in, &every_bundle, + "skill `{skill}` must ship in every bundle (or be declared OnlyIn in \ + SKILL_SYNC_EXCEPTIONS with a reason) but ships only in {shipped_in:?}" + ); + assert_skill_content_synced(skill, exceptions.get(skill.as_str())); + } + } + } +} + +/// The cross-bundle shared skill set must equal the runtime skill index the +/// hooks advertise (`hooks::CURSOR_PLUGIN_SKILLS`). +#[test] +fn skills_shared_by_every_bundle_match_the_runtime_skill_index() { + let bundle_count = BUNDLES.len(); + let shared: Vec = skill_presence_by_bundle() + .into_iter() + .filter(|(_, shipped_in)| shipped_in.len() == bundle_count) + .map(|(skill, _)| skill) + .collect(); + let mut expected: Vec = CURSOR_PLUGIN_SKILLS + .iter() + .map(|skill| (*skill).to_string()) + .collect(); + expected.sort(); + assert_eq!( + shared, expected, + "the skills shared by every bundle must be exactly \ + hooks::CURSOR_PLUGIN_SKILLS (the model-invocable workflow set)" + ); +} + +/// Every exception must carry a non-empty written reason: the manifest is +/// the documentation of *why* a unit is allowed to diverge, and an empty +/// reason defeats it. +#[test] +fn every_sync_exception_documents_a_reason() { + for (entry, policy) in TOP_LEVEL_MANIFEST { + match policy { + TopLevelPolicy::SyncedSkills => {} + TopLevelPolicy::HostSpecific { reason } | TopLevelPolicy::OnlyIn { reason, .. } => { + assert!( + !reason.trim().is_empty(), + "TOP_LEVEL_MANIFEST entry `{entry}` needs a written reason" + ); + } + } + } + for (skills, rule) in SKILL_SYNC_EXCEPTIONS { + let reason = match rule { + SkillSyncRule::OnlyIn { reason, .. } => reason, + }; + assert!( + !reason.trim().is_empty(), + "SKILL_SYNC_EXCEPTIONS entry {skills:?} needs a written reason" + ); + } +} + +fn policy_applies_to(policy: &TopLevelPolicy, bundle: &str) -> bool { + match policy { + TopLevelPolicy::SyncedSkills | TopLevelPolicy::HostSpecific { .. } => true, + TopLevelPolicy::OnlyIn { bundles, .. } => bundles.contains(&bundle), + } +} + +fn assert_only_in_lists_name_real_bundles() { + let known: BTreeSet<&'static str> = BUNDLES.iter().map(|bundle| bundle.name).collect(); + let check = |context: String, bundles: &[&str]| { + for name in bundles { + assert!( + known.contains(name), + "{context} names unknown bundle `{name}`; known bundles are {known:?}" + ); + } + }; + for (entry, policy) in TOP_LEVEL_MANIFEST { + match policy { + TopLevelPolicy::SyncedSkills | TopLevelPolicy::HostSpecific { .. } => {} + TopLevelPolicy::OnlyIn { bundles, .. } => { + check(format!("TOP_LEVEL_MANIFEST entry `{entry}`"), bundles); + } + } + } + for (skills, rule) in SKILL_SYNC_EXCEPTIONS { + match rule { + SkillSyncRule::OnlyIn { bundles, .. } => { + check(format!("SKILL_SYNC_EXCEPTIONS entry {skills:?}"), bundles); + } + } + } +} + +fn sorted_dir_entry_names(dir: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", dir.display())) + .map(|entry| { + entry + .expect("read dir entry") + .file_name() + .to_str() + .expect("bundle entry names should be utf-8") + .to_string() + }) + .collect(); + names.sort(); + names +} + +/// Maps each skill name to the set of bundles that ship it. Also asserts the +/// skills directories contain only skill directories (a stray file there +/// belongs to no unit and would escape the sync check). +fn skill_presence_by_bundle() -> BTreeMap> { + let mut presence: BTreeMap> = BTreeMap::new(); + for bundle in BUNDLES { + let skills_root = repo_path(bundle.root).join(SKILLS_DIR); + for entry in std::fs::read_dir(&skills_root) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", skills_root.display())) + { + let path = entry.expect("read skills dir entry").path(); + assert!( + path.is_dir(), + "{} is a stray file; {}/{SKILLS_DIR}/ may contain only skill directories", + path.display(), + bundle.root + ); + let skill = path + .file_name() + .and_then(|name| name.to_str()) + .expect("skill directory names should be utf-8") + .to_string(); + presence.entry(skill).or_default().insert(bundle.name); + } + } + presence +} + +fn skill_exception_index() -> BTreeMap<&'static str, &'static SkillSyncRule> { + let mut index = BTreeMap::new(); + for (skills, rule) in SKILL_SYNC_EXCEPTIONS { + for &skill in *skills { + assert!( + index.insert(skill, rule).is_none(), + "SKILL_SYNC_EXCEPTIONS lists `{skill}` more than once" + ); + } + } + index +} + +/// Compares one skill's directory tree across every bundle against the first +/// bundle's copy: identical file sets, and byte-identical file contents +/// except where `rule` documents an intentional SKILL.md divergence. +fn assert_skill_content_synced(skill: &str, rule: Option<&&SkillSyncRule>) { + let reference = &BUNDLES[0]; + let reference_dir = repo_path(reference.root).join(SKILLS_DIR).join(skill); + let reference_files = relative_files_under(&reference_dir); + + for bundle in &BUNDLES[1..] { + let bundle_dir = repo_path(bundle.root).join(SKILLS_DIR).join(skill); + assert_eq!( + relative_files_under(&bundle_dir), + reference_files, + "skill `{skill}` ships different file sets in {} and {}", + reference.root, + bundle.root + ); + for relative in &reference_files { + let reference_file = reference_dir.join(relative); + let bundle_file = bundle_dir.join(relative); + if relative == Path::new("SKILL.md") { + assert_skill_md_synced(skill, rule, &reference_file, &bundle_file); + } else { + assert!( + read_bytes(&bundle_file) == read_bytes(&reference_file), + "{} must be byte-identical to {}", + bundle_file.display(), + reference_file.display() + ); + } + } + } +} + +fn assert_skill_md_synced( + skill: &str, + rule: Option<&&SkillSyncRule>, + reference_file: &Path, + bundle_file: &Path, +) { + match rule { + None => { + assert!( + read_bytes(bundle_file) == read_bytes(reference_file), + "{} must be byte-identical to {} (add `{skill}` to \ + SKILL_SYNC_EXCEPTIONS with a reason if a host-specific version is \ + intended)", + bundle_file.display(), + reference_file.display() + ); + } + Some(SkillSyncRule::OnlyIn { .. }) => { + unreachable!("OnlyIn skills are never content-compared across bundles") + } + } +} + +fn read_bytes(path: &Path) -> Vec { + std::fs::read(path).unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())) +} diff --git a/tests/agent_suite/plugin_config_schema_test.rs b/tests/agent_suite/plugin_config_schema_test.rs new file mode 100644 index 00000000..50a6776e --- /dev/null +++ b/tests/agent_suite/plugin_config_schema_test.rs @@ -0,0 +1,118 @@ +//! Validates the plugin bundles' MCP and hooks configuration files against +//! Cursor's config schemas. +//! +//! Cursor's official cursor/plugins repository publishes JSON schemas plus an +//! ajv validation workflow, but only for `plugin.json` and `marketplace.json` +//! (its `plugin.schema.json` types the inline `hooks` / `mcpServers` fields +//! as bare objects). There is no standalone published schema for `mcp.json` +//! or `hooks.json`, so the schemas vendored at +//! `tests/fixtures/cursor-schemas/{mcp,hooks}.schema.json` are derived from +//! Cursor's official field references: +//! +//! - (mcp.json server fields) +//! - (hooks.json events and per-script options) +//! +//! cross-checked against the hooks.json files shipped by official plugins in +//! (commit 0452e08). See each schema's +//! top-level `description` for provenance details. The `plugin.json` +//! manifests themselves are covered by `plugin_manifest_schema_test.rs`. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use jsonschema::Validator; +use serde_json::{json, Value}; + +use crate::plugin_validation_support::{ + assert_schema_valid, compile_schema, read_json_file, repo_path, +}; + +const MCP_SCHEMA: &str = include_str!("../fixtures/cursor-schemas/mcp.schema.json"); +const HOOKS_SCHEMA: &str = include_str!("../fixtures/cursor-schemas/hooks.schema.json"); + +fn compile(schema_body: &str) -> Validator { + let schema: Value = serde_json::from_str(schema_body).expect("vendored schema parses"); + compile_schema(&schema) +} + +/// Validates the config at `relative` if it exists. Returns whether it did. +/// Required files assert existence at their call sites; optional bundle +/// equivalents (e.g. a future codex-plugin/mcp.json) get validated the moment +/// someone adds them. +fn validate_if_present(validator: &Validator, relative: &str) -> bool { + let path = repo_path(relative); + if !path.exists() { + return false; + } + assert_schema_valid(validator, &read_json_file(&path), &path); + true +} + +#[test] +fn cursor_bundle_mcp_config_matches_the_mcp_schema() { + let validator = compile(MCP_SCHEMA); + assert!( + validate_if_present(&validator, "cursor-plugin/mcp.json"), + "cursor-plugin/mcp.json must exist" + ); +} + +#[test] +fn cursor_bundle_hooks_config_matches_the_hooks_schema() { + let validator = compile(HOOKS_SCHEMA); + assert!( + validate_if_present(&validator, "cursor-plugin/hooks/hooks.json"), + "cursor-plugin/hooks/hooks.json must exist" + ); +} + +/// The Codex bundle reuses the same hooks.json shape. It currently ships an +/// empty hook map (Codex wires lifecycle differently), and has no mcp.json; +/// both are validated here as soon as they appear. +#[test] +fn codex_bundle_configs_match_the_schemas_when_present() { + let hooks_validator = compile(HOOKS_SCHEMA); + assert!( + validate_if_present(&hooks_validator, "codex-plugin/hooks/hooks.json"), + "codex-plugin/hooks/hooks.json must exist" + ); + + let mcp_validator = compile(MCP_SCHEMA); + validate_if_present(&mcp_validator, "codex-plugin/mcp.json"); +} + +/// Guards against the vendored schemas degenerating into accept-everything: +/// each must reject representative malformed configs. +#[test] +fn vendored_schemas_reject_malformed_configs() { + let mcp_validator = compile(MCP_SCHEMA); + let bad_mcp_configs = [ + // stdio server missing its required command + json!({ "mcpServers": { "s": { "args": ["serve"] } } }), + // remote server with an unknown field + json!({ "mcpServers": { "s": { "url": "https://example.com/mcp", "cmd": "x" } } }), + // top-level key typo + json!({ "mcpservers": {} }), + ]; + for config in &bad_mcp_configs { + assert!( + !mcp_validator.is_valid(config), + "mcp schema unexpectedly accepted: {config}" + ); + } + + let hooks_validator = compile(HOOKS_SCHEMA); + let bad_hooks_configs = [ + // typo'd hook event name (event enum is the main guard this schema adds) + json!({ "version": 1, "hooks": { "afterShellExecutionn": [{ "command": "x" }] } }), + // hook definition missing its command + json!({ "version": 1, "hooks": { "stop": [{ "timeout": 5 }] } }), + // unsupported config version + json!({ "version": 2, "hooks": {} }), + ]; + for config in &bad_hooks_configs { + assert!( + !hooks_validator.is_valid(config), + "hooks schema unexpectedly accepted: {config}" + ); + } +} diff --git a/tests/agent_suite/plugin_manifest_schema_test.rs b/tests/agent_suite/plugin_manifest_schema_test.rs new file mode 100644 index 00000000..5a7ba726 --- /dev/null +++ b/tests/agent_suite/plugin_manifest_schema_test.rs @@ -0,0 +1,108 @@ +//! Validates the source plugin bundle manifests against Cursor's official +//! published JSON schema. +//! +//! The schema is vendored at `tests/fixtures/cursor-schemas/` from the +//! cursor/plugins repository (commit 4a91a6e, "Add plugin validation +//! workflow") so validation runs offline in `cargo test`: +//! +//! +//! The Codex bundle manifest follows the same shape plus a Codex-specific +//! `interface` marketplace block, so it is checked against the Cursor schema +//! extended with that one key. Rendered (installed) manifests are covered by +//! `tests/agent_suite/update_plugin_test.rs`. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::path::Path; + +use serde_json::{json, Value}; + +use crate::plugin_validation_support::{ + assert_schema_valid, compile_schema, read_json_file, repo_path, +}; + +const PLUGIN_SCHEMA: &str = include_str!("../fixtures/cursor-schemas/plugin.schema.json"); + +/// Component paths declared in a manifest, with the manifest fields that +/// declared them. Only string and string-array fields are path references; +/// inline objects (`hooks`, `mcpServers`) carry their config in place. +fn declared_component_paths(manifest: &Value) -> Vec<(String, String)> { + let mut paths = Vec::new(); + for field in [ + "rules", + "agents", + "skills", + "commands", + "hooks", + "mcpServers", + ] { + match manifest.get(field) { + None => {} + Some(Value::String(path)) => paths.push((field.to_string(), path.clone())), + Some(Value::Array(items)) => { + for item in items { + if let Value::String(path) = item { + paths.push((field.to_string(), path.clone())); + } + } + } + Some(_) => {} // inline hooks / mcpServers objects + } + } + paths +} + +fn assert_component_paths_resolve(manifest: &Value, bundle_root: &Path, manifest_path: &Path) { + for (field, declared) in declared_component_paths(manifest) { + assert!( + !declared.starts_with('/') && !declared.split('/').any(|part| part == ".."), + "{} field `{field}` declares `{declared}`; the marketplace submission \ + checklist requires relative paths without `..`", + manifest_path.display() + ); + let resolved = bundle_root.join(declared.trim_start_matches("./")); + assert!( + resolved.exists(), + "{} field `{field}` declares `{declared}` but {} does not exist", + manifest_path.display(), + resolved.display() + ); + } +} + +#[test] +fn cursor_bundle_manifest_matches_the_official_cursor_plugin_schema() { + let schema: Value = serde_json::from_str(PLUGIN_SCHEMA).expect("schema fixture parses"); + let validator = compile_schema(&schema); + + let manifest_path = repo_path("cursor-plugin/.cursor-plugin/plugin.json"); + let manifest = read_json_file(&manifest_path); + assert_schema_valid(&validator, &manifest, &manifest_path); + assert_component_paths_resolve(&manifest, &repo_path("cursor-plugin"), &manifest_path); +} + +#[test] +fn codex_bundle_manifest_matches_the_cursor_schema_plus_interface_extension() { + let mut schema: Value = serde_json::from_str(PLUGIN_SCHEMA).expect("schema fixture parses"); + // Codex marketplaces read an `interface` block (display metadata) that + // Cursor's schema does not define; with `additionalProperties: false` + // the stock schema would reject it, so allow exactly that one extra key. + schema["properties"]["interface"] = json!({ "type": "object" }); + let validator = compile_schema(&schema); + + let manifest_path = repo_path("codex-plugin/.codex-plugin/plugin.json"); + let manifest = read_json_file(&manifest_path); + assert_schema_valid(&validator, &manifest, &manifest_path); + assert_component_paths_resolve(&manifest, &repo_path("codex-plugin"), &manifest_path); +} + +/// The schema's `name` pattern is what the marketplace submission checklist +/// enforces; both bundles must agree on the plugin name so cross-bundle +/// tooling (marketplace entries, cache paths) can key on one identifier. +#[test] +fn bundle_manifests_share_the_plugin_name() { + let cursor = read_json_file(&repo_path("cursor-plugin/.cursor-plugin/plugin.json")); + let codex = read_json_file(&repo_path("codex-plugin/.codex-plugin/plugin.json")); + assert_eq!(cursor["name"], "tracedecay"); + assert_eq!(codex["name"], "tracedecay"); +} diff --git a/tests/agent_suite/plugin_skill_contract_test.rs b/tests/agent_suite/plugin_skill_contract_test.rs index ee475548..f69cf080 100644 --- a/tests/agent_suite/plugin_skill_contract_test.rs +++ b/tests/agent_suite/plugin_skill_contract_test.rs @@ -8,13 +8,14 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; +use std::path::Path; use crate::common::{EnvVarGuard, PROCESS_ENV_LOCK}; +use crate::plugin_validation_support::{ + is_kebab_case_skill_name, load_skill_docs, relative_files_under, repo_path, SkillDoc, +}; use tempfile::TempDir; use tracedecay::agents::{expected_tool_perms, get_integration, InstallContext}; -use tracedecay::automation::skill_frontmatter::{parse_skill_frontmatter, SkillFrontmatterValue}; use tracedecay::config::USER_DATA_DIR_ENV; const CODEX_SKILL_ROOT: &str = "codex-plugin/skills"; @@ -48,13 +49,6 @@ const CURSOR_ALLOWED_FRONTMATTER: &[&str] = &[ ]; const CODEX_GENERATED_MEMORY_SKILL: &str = "agent-managed-memory"; -#[derive(Debug)] -struct SkillDoc { - path: PathBuf, - body: String, - frontmatter: BTreeMap, -} - #[test] fn codex_plugin_skills_match_codex_skill_creator_quick_validate_rules() { let skills = load_skill_docs(CODEX_SKILL_ROOT); @@ -75,7 +69,7 @@ fn generated_codex_plugin_skills_are_byte_copies_of_the_source_bundle() { .install(&install_ctx(home.path())) .expect("install generated Codex plugin bundle"); - let source_root = skills_source_root(CODEX_SKILL_ROOT); + let source_root = repo_path(CODEX_SKILL_ROOT); let installed_root = home.path().join("plugins/tracedecay/skills"); assert!( installed_root @@ -116,7 +110,7 @@ fn generated_cursor_plugin_skills_are_byte_copies_of_the_source_bundle() { .install(&install_ctx(home.path())) .expect("install generated Cursor plugin bundle"); - let source_root = skills_source_root(CURSOR_SKILL_ROOT); + let source_root = repo_path(CURSOR_SKILL_ROOT); let installed_root = home.path().join(".cursor/plugins/local/tracedecay/skills"); assert_eq!( skill_dir_names(&installed_root), @@ -154,14 +148,14 @@ fn produced_plugin_skills_follow_skill_creator_design_advice() { skill.path.display() ); - let line_count = skill.body.lines().count(); + let line_count = skill.raw.lines().count(); assert!( line_count <= MAX_SKILL_MD_LINES, "{} has {line_count} lines; split details into direct references before exceeding {MAX_SKILL_MD_LINES}", skill.path.display() ); assert!( - !skill.body.to_ascii_lowercase().contains("\n## when to use"), + !skill.raw.to_ascii_lowercase().contains("\n## when to use"), "{} must keep trigger guidance in description metadata, not a body-only When to Use section", skill.path.display() ); @@ -172,41 +166,6 @@ fn produced_plugin_skills_follow_skill_creator_design_advice() { } } -fn skills_source_root(root: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join(root) -} - -fn load_skill_docs(root: &str) -> Vec { - let skills_root = skills_source_root(root); - let mut paths = std::fs::read_dir(&skills_root) - .unwrap_or_else(|err| { - panic!( - "failed to read bundled skills at {}: {err}", - skills_root.display() - ) - }) - .map(|entry| entry.expect("read skill dir entry").path()) - .filter(|path| path.is_dir()) - .map(|path| path.join("SKILL.md")) - .collect::>(); - paths.sort(); - - paths - .into_iter() - .map(|path| { - let body = std::fs::read_to_string(&path) - .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())); - let frontmatter = parse_skill_frontmatter(&body) - .unwrap_or_else(|err| panic!("{}: {err}", path.display())); - SkillDoc { - path, - body, - frontmatter, - } - }) - .collect() -} - /// Serializes the generated-bundle tests, which mutate process-wide env vars. fn install_env_lock() -> tokio::sync::MutexGuard<'static, ()> { PROCESS_ENV_LOCK.blocking_lock() @@ -299,29 +258,6 @@ fn assert_skill_trees_byte_identical_except( } } -fn relative_files_under(root: &Path) -> Vec { - let mut files = Vec::new(); - let mut stack = vec![root.to_path_buf()]; - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(&dir) - .unwrap_or_else(|err| panic!("failed to read {}: {err}", dir.display())) - { - let path = entry.expect("read skill tree entry").path(); - if path.is_dir() { - stack.push(path); - } else { - files.push( - path.strip_prefix(root) - .expect("collected paths live under root") - .to_path_buf(), - ); - } - } - } - files.sort(); - files -} - fn assert_codex_quick_validate_equivalent(skill: &SkillDoc) { assert_allowed_frontmatter(skill, CODEX_QUICK_VALIDATE_ALLOWED_FRONTMATTER); assert_required_skill_creator_frontmatter(skill); @@ -378,7 +314,7 @@ fn assert_required_skill_creator_frontmatter(skill: &SkillDoc) { skill.path.display() ); assert!( - is_skill_creator_name(name), + is_kebab_case_skill_name(name), "{} skill name must be hyphen-case lowercase letters, digits, and hyphens, \ without leading/trailing/consecutive hyphens", skill.path.display() @@ -450,16 +386,6 @@ fn assert_scalar(field: &str, value: &str, path: &Path) { ); } -fn is_skill_creator_name(name: &str) -> bool { - !name.is_empty() - && !name.starts_with('-') - && !name.ends_with('-') - && !name.contains("--") - && name - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') -} - /// Agents choose skills from metadata alone, so each description must carry /// an imperative "Use ..." trigger sentence: either leading the description /// or following a short capability summary (e.g. "Find code by concept ... diff --git a/tests/agent_suite/plugin_validation_support.rs b/tests/agent_suite/plugin_validation_support.rs new file mode 100644 index 00000000..e864c1f5 --- /dev/null +++ b/tests/agent_suite/plugin_validation_support.rs @@ -0,0 +1,151 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use jsonschema::Validator; +use serde_json::Value; +use tracedecay::automation::skill_frontmatter::{parse_skill_frontmatter, SkillFrontmatterValue}; + +/// A path relative to the repository root (`CARGO_MANIFEST_DIR`). +pub fn repo_path(relative: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join(relative) +} + +pub fn read_json_file(path: &Path) -> Value { + let body = fs::read_to_string(path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())); + serde_json::from_str(&body) + .unwrap_or_else(|err| panic!("failed to parse JSON {}: {err}", path.display())) +} + +/// Compiles a vendored draft-07 schema with format assertions enabled. +pub fn compile_schema(schema: &Value) -> Validator { + jsonschema::options() + .should_validate_formats(true) + .build(schema) + .expect("vendored schema should compile") +} + +/// Asserts `instance` validates against `validator`, reporting every +/// violation with its JSON pointer. +pub fn assert_schema_valid(validator: &Validator, instance: &Value, instance_path: &Path) { + let errors = validator + .iter_errors(instance) + .map(|err| format!(" {}: {err}", err.instance_path())) + .collect::>(); + assert!( + errors.is_empty(), + "{} violates the vendored schema:\n{}", + instance_path.display(), + errors.join("\n") + ); +} + +/// One bundled `SKILL.md`, parsed once for every lint/contract consumer. +#[derive(Debug)] +pub struct SkillDoc { + /// The skill directory name (which the contracts force `name` to match). + pub name: String, + /// Path to the `SKILL.md` file. + pub path: PathBuf, + /// Full file contents, frontmatter included. + pub raw: String, + /// Contents after the closing `---` frontmatter fence. + pub body: String, + pub frontmatter: BTreeMap, +} + +/// Loads every `//SKILL.md` under a repo-relative skills root, +/// sorted by path. Panics on unreadable/unparsable skills and asserts the +/// root is non-empty. +pub fn load_skill_docs(root: &str) -> Vec { + let skills_root = repo_path(root); + let mut dirs = fs::read_dir(&skills_root) + .unwrap_or_else(|err| { + panic!( + "failed to read bundled skills at {}: {err}", + skills_root.display() + ) + }) + .map(|entry| entry.expect("read skill dir entry").path()) + .filter(|path| path.is_dir()) + .collect::>(); + dirs.sort(); + assert!( + !dirs.is_empty(), + "expected skill directories under {}", + skills_root.display() + ); + + dirs.into_iter() + .map(|dir| { + let name = dir + .file_name() + .and_then(|name| name.to_str()) + .expect("skill directory name should be utf-8") + .to_string(); + let path = dir.join("SKILL.md"); + let raw = fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())); + let frontmatter = parse_skill_frontmatter(&raw) + .unwrap_or_else(|err| panic!("{}: {err}", path.display())); + let body = body_after_frontmatter(&raw).to_string(); + SkillDoc { + name, + path, + raw, + body, + frontmatter, + } + }) + .collect() +} + +/// Returns the markdown body following the closing `---` frontmatter fence. +pub fn body_after_frontmatter(raw: &str) -> &str { + let mut offset = 0usize; + for (index, line) in raw.split_inclusive('\n').enumerate() { + offset += line.len(); + if index > 0 && line.trim() == "---" { + return &raw[offset..]; + } + } + "" +} + +/// Skill-name rule shared by the Agent Skills spec, Codex's +/// `quick_validate.py`, and Cursor: lowercase alphanumerics and hyphens, +/// without leading/trailing/consecutive hyphens. +pub fn is_kebab_case_skill_name(name: &str) -> bool { + !name.is_empty() + && !name.starts_with('-') + && !name.ends_with('-') + && !name.contains("--") + && name + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} + +/// Every regular file under `root`, relative to it, sorted. +pub fn relative_files_under(root: &Path) -> Vec { + let mut files = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in fs::read_dir(&dir) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", dir.display())) + { + let path = entry.expect("read tree entry").path(); + if path.is_dir() { + stack.push(path); + } else { + files.push( + path.strip_prefix(root) + .expect("collected paths live under root") + .to_path_buf(), + ); + } + } + } + files.sort(); + files +} diff --git a/tests/agent_suite/skill_lint_claude_test.rs b/tests/agent_suite/skill_lint_claude_test.rs new file mode 100644 index 00000000..0296c55f --- /dev/null +++ b/tests/agent_suite/skill_lint_claude_test.rs @@ -0,0 +1,305 @@ +//! Claude Code / Agent Skills portability lint for the bundled skill +//! collections (`cursor-plugin/skills/` and `codex-plugin/skills/`). +//! +//! These tests keep the shared skills close to Claude Code's documented skill +//! rules so a Claude bundle can reuse them without a rewrite. +//! +//! Rule sources (fetched 2026-07-02): +//! - Claude Code skills reference (frontmatter field table, 1,536-char +//! listing cap, command-name rules): +//! - Agent Skills open specification (allowed fields, name/description +//! constraints): +//! - Claude platform Agent Skills docs (64-char name, 1,024-char description, +//! no XML tags, reserved words "anthropic"/"claude"): +//! +//! - Anthropic skill-creator validator (`quick_validate.py`: kebab-case name, +//! no angle brackets in description, allowed-field whitelist): +//! +//! - Ground-truth layouts from Anthropic's published plugins +//! (`frontend-design`, `mcp-server-dev`): `.claude-plugin/plugin.json` plus +//! `skills//SKILL.md`, with `license` and `version` frontmatter in +//! shipping skills. +//! +//! Cross-ecosystem conflicts (documented skips, not failures): Cursor requires +//! `disable-model-invocation: true` on command-style skills. Claude Code +//! supports that field natively, but the strict Agent Skills open spec (and +//! Anthropic's `quick_validate.py` packaging validator) rejects it. See +//! [`CROSS_ECOSYSTEM_CONFLICT_FIELDS`] and the compatibility matrix in +//! `docs/PLUGIN-VALIDATION.md` (layer 5). + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use tracedecay::automation::skill_frontmatter::SkillFrontmatterValue; + +use crate::plugin_validation_support::{is_kebab_case_skill_name, load_skill_docs, SkillDoc}; + +const SKILL_ROOTS: &[&str] = &["cursor-plugin/skills", "codex-plugin/skills"]; + +/// Frontmatter fields Claude Code recognizes, per the field table at +/// code.claude.com/docs/en/skills, plus the Agent Skills open-spec fields +/// (`license`, `compatibility`, `metadata`) and `version`, which Anthropic's +/// own published plugin skills ship (e.g. mcp-server-dev's build-mcp-server). +const CLAUDE_CODE_ALLOWED_FRONTMATTER: &[&str] = &[ + "agent", + "allowed-tools", + "argument-hint", + "arguments", + "compatibility", + "context", + "description", + "disable-model-invocation", + "disallowed-tools", + "effort", + "hooks", + "license", + "metadata", + "model", + "name", + "paths", + "shell", + "user-invocable", + "version", + "when_to_use", +]; + +/// Frontmatter fields the strict Agent Skills open spec allows +/// (agentskills.io/specification), which is also the whitelist enforced by +/// Anthropic's skill-creator `quick_validate.py` packaging validator. +const AGENT_SKILLS_SPEC_ALLOWED_FRONTMATTER: &[&str] = &[ + "allowed-tools", + "compatibility", + "description", + "license", + "metadata", + "name", +]; + +/// Fields our bundles use that the strict open spec rejects, kept anyway +/// because a host ecosystem requires them. Each entry is a documented skip: +/// the spec-conformance test tolerates exactly these fields and nothing else. +/// +/// - `disable-model-invocation`: Cursor command-style skills (the +/// `/tracedecay-*` commands) must set this to stay +/// manual-only. Claude Code documents and supports the same field, so a +/// future claude-plugin bundle can carry it unchanged; only spec-strict +/// packagers (`quick_validate.py`, the Claude API skill upload) reject it. +const CROSS_ECOSYSTEM_CONFLICT_FIELDS: &[&str] = &["disable-model-invocation"]; + +/// platform.claude.com: skill names uploaded to the Claude API cannot contain +/// the reserved words "anthropic" or "claude". +const CLAUDE_RESERVED_NAME_WORDS: &[&str] = &["anthropic", "claude"]; + +/// Claude platform limit: description must be 1-1024 characters. +const CLAUDE_MAX_DESCRIPTION_CHARS: usize = 1024; +/// Claude platform limit: name must be at most 64 characters. +const CLAUDE_MAX_NAME_CHARS: usize = 64; +/// Claude Code truncates the combined `description` + `when_to_use` text at +/// 1,536 characters in the skill listing; staying under means no data loss. +const CLAUDE_CODE_LISTING_CAP_CHARS: usize = 1536; + +#[test] +fn bundled_skills_use_only_frontmatter_claude_code_recognizes() { + for skill in load_all_skill_docs() { + let unknown = skill + .frontmatter + .keys() + .filter(|key| !CLAUDE_CODE_ALLOWED_FRONTMATTER.contains(&key.as_str())) + .collect::>(); + assert!( + unknown.is_empty(), + "{} uses frontmatter keys {unknown:?} that Claude Code does not document; \ + allowed keys are {CLAUDE_CODE_ALLOWED_FRONTMATTER:?}", + skill.path.display() + ); + } +} + +#[test] +fn bundled_skill_names_satisfy_claude_naming_rules() { + for skill in load_all_skill_docs() { + let name = required_scalar(&skill, "name"); + + // Claude Code derives the /command from the directory name and the + // open spec requires `name` to match it, so both must conform. + assert_eq!( + name, + skill.name, + "{} frontmatter name must match its directory so the Claude Code \ + command name and the spec-required name agree", + skill.path.display() + ); + assert!( + name.len() <= CLAUDE_MAX_NAME_CHARS, + "{} name is {} chars; Claude allows at most {CLAUDE_MAX_NAME_CHARS}", + skill.path.display(), + name.len() + ); + assert!( + is_kebab_case_skill_name(name), + "{} name must be lowercase letters, digits, and hyphens without \ + leading/trailing/consecutive hyphens", + skill.path.display() + ); + assert!( + !name.contains(['<', '>']), + "{} name cannot contain XML tags", + skill.path.display() + ); + for reserved in CLAUDE_RESERVED_NAME_WORDS { + assert!( + !name.contains(reserved), + "{} name contains reserved word {reserved:?}, which the Claude \ + platform rejects", + skill.path.display() + ); + } + } +} + +#[test] +fn bundled_skill_descriptions_satisfy_claude_description_rules() { + for skill in load_all_skill_docs() { + let description = required_scalar(&skill, "description"); + + assert!( + !description.trim().is_empty(), + "{} description must be non-empty", + skill.path.display() + ); + assert!( + description.len() <= CLAUDE_MAX_DESCRIPTION_CHARS, + "{} description is {} chars; Claude allows at most {CLAUDE_MAX_DESCRIPTION_CHARS}", + skill.path.display(), + description.len() + ); + // quick_validate.py and the Claude platform reject angle brackets + // anywhere in the description (XML-tag guard). + assert!( + !description.contains(['<', '>']), + "{} description cannot contain angle brackets", + skill.path.display() + ); + + let when_to_use = skill + .frontmatter + .get("when_to_use") + .and_then(SkillFrontmatterValue::as_scalar) + .unwrap_or(""); + assert!( + description.len() + when_to_use.len() <= CLAUDE_CODE_LISTING_CAP_CHARS, + "{} combined description + when_to_use exceeds Claude Code's \ + {CLAUDE_CODE_LISTING_CAP_CHARS}-char listing cap and would be truncated", + skill.path.display() + ); + } +} + +/// Strict Agent Skills spec conformance with documented skips. +/// +/// Every field outside the open-spec whitelist must be one of the known +/// cross-ecosystem conflicts in [`CROSS_ECOSYSTEM_CONFLICT_FIELDS`]; anything +/// else is a new portability regression and fails. The Codex bundle must be +/// fully spec-clean because Codex's own validator is the spec whitelist. +#[test] +fn open_spec_conflicts_are_limited_to_documented_cursor_requirements() { + for root in SKILL_ROOTS { + let is_codex_bundle = root.starts_with("codex-plugin"); + for skill in load_skill_docs(root) { + let extras = skill + .frontmatter + .keys() + .filter(|key| !AGENT_SKILLS_SPEC_ALLOWED_FRONTMATTER.contains(&key.as_str())) + .collect::>(); + + if is_codex_bundle { + assert!( + extras.is_empty(), + "{} must stay strictly Agent-Skills-spec conformant (Codex \ + validates with the spec whitelist) but uses {extras:?}", + skill.path.display() + ); + continue; + } + + let undocumented = extras + .iter() + .filter(|key| !CROSS_ECOSYSTEM_CONFLICT_FIELDS.contains(&key.as_str())) + .collect::>(); + assert!( + undocumented.is_empty(), + "{} uses spec-nonconformant frontmatter {undocumented:?} that is \ + not a documented cross-ecosystem conflict; either drop the field \ + or document it in CROSS_ECOSYSTEM_CONFLICT_FIELDS and \ + docs/PLUGIN-VALIDATION.md", + skill.path.display() + ); + } + } +} + +/// The documented skips must stay real: if the Cursor bundle stops using a +/// conflict field, the allowlist entry (and the notes matrix) is stale. +#[test] +fn documented_conflict_fields_are_actually_used_by_the_cursor_bundle() { + let cursor_skills = load_skill_docs("cursor-plugin/skills"); + for field in CROSS_ECOSYSTEM_CONFLICT_FIELDS { + assert!( + cursor_skills + .iter() + .any(|skill| skill.frontmatter.contains_key(*field)), + "documented conflict field {field:?} is no longer used by any Cursor \ + skill; remove it from CROSS_ECOSYSTEM_CONFLICT_FIELDS and update \ + docs/PLUGIN-VALIDATION.md" + ); + } +} + +/// Claude Code preloads model-invocable skill metadata into its skill listing. +/// Keep the aggregate near the Cursor/Codex contract budget so the listing +/// stays small. +#[test] +fn model_invocable_skill_metadata_fits_a_claude_listing_budget() { + const MAX_PRELOADED_METADATA_CHARS: usize = 6_000; + for root in SKILL_ROOTS { + let total: usize = load_skill_docs(root) + .iter() + .filter(|skill| { + skill + .frontmatter + .get("disable-model-invocation") + .and_then(SkillFrontmatterValue::as_scalar) + != Some("true") + }) + .map(|skill| { + required_scalar(skill, "name").len() + required_scalar(skill, "description").len() + }) + .sum(); + assert!( + total <= MAX_PRELOADED_METADATA_CHARS, + "{root} model-invocable skill metadata totals {total} chars; keep it \ + under {MAX_PRELOADED_METADATA_CHARS} so a Claude Code skill listing \ + is never truncated" + ); + } +} + +fn load_all_skill_docs() -> Vec { + let mut skills = Vec::new(); + for root in SKILL_ROOTS { + skills.extend(load_skill_docs(root)); + } + skills +} + +fn required_scalar<'a>(skill: &'a SkillDoc, field: &str) -> &'a str { + skill + .frontmatter + .get(field) + .and_then(SkillFrontmatterValue::as_scalar) + .unwrap_or_else(|| { + panic!( + "{} is missing required scalar frontmatter field {field}", + skill.path.display() + ) + }) +} diff --git a/tests/agent_suite/skill_lint_cursor_test.rs b/tests/agent_suite/skill_lint_cursor_test.rs new file mode 100644 index 00000000..469088dd --- /dev/null +++ b/tests/agent_suite/skill_lint_cursor_test.rs @@ -0,0 +1,382 @@ +//! Cursor-specific lint for the bundled `cursor-plugin/skills/` SKILL.md +//! files, ported from community/official skill linters so enforcement runs +//! offline inside `cargo test` (no node/python CI dependency). +//! +//! Rule sources: +//! - skillmark (): broken file +//! references (E031), BOM/structural hygiene (E032-E034), angle brackets in +//! frontmatter values (E036), reserved name prefixes (E037), short +//! descriptions (W003), placeholder text (W006), heading presence (W009). +//! - skilldoctor (): empty +//! body, trailing whitespace. +//! - skillkit (): skipped heading +//! levels, consistent structure. +//! - Cursor docs (): `disable-model-invocation` +//! slash-command semantics and `paths` glob scoping. +//! +//! Repo-specific reference-integrity rules (same spirit as skillmark E031, +//! applied to this bundle's conventions): `tracedecay:` cross-skill +//! references, backticked `/skill` invocations, and `tracedecay_*` MCP tool +//! mentions must all resolve against the bundle / the live MCP tool list. +//! +//! `tests/agent_suite/plugin_skill_contract_test.rs` already enforces the +//! frontmatter key whitelist, name/folder match and charset, description +//! budgets and trigger language, the 500-line body cap, resource-dir layout, +//! and install byte-parity. Those rules are intentionally NOT duplicated here. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::collections::{BTreeMap, BTreeSet}; + +use regex::Regex; +use tracedecay::automation::skill_frontmatter::SkillFrontmatterValue; +use tracedecay::mcp::get_tool_definitions; + +use crate::plugin_validation_support::{load_skill_docs, SkillDoc}; + +const CURSOR_SKILL_ROOT: &str = "cursor-plugin/skills"; + +/// skillmark W003 flags descriptions under 50 chars as too short to convey +/// what the skill does and when to trigger it. +const MIN_DESCRIPTION_CHARS: usize = 50; + +/// skillmark E037: reserved vendor prefixes a skill name must not claim. +const RESERVED_NAME_PREFIXES: &[&str] = &["claude", "anthropic"]; + +/// skillmark W006: placeholder fragments that mark unfinished authoring. +/// Plain TODO/FIXME words are deliberately not listed: several bundled skills +/// legitimately discuss TODO/FIXME markers (`tracedecay_todos`). +const PLACEHOLDER_FRAGMENTS: &[&str] = &["{{", "}}", "tktk", "lorem ipsum", " {} + Some(first) => violations.push(format!( + "{at}: body must open with an H1 title, found {first:?}" + )), + None => continue, // empty body reported by the hygiene test + } + + let headings = unfenced_headings(&skill.body); + let h1_count = headings.iter().filter(|(level, _)| *level == 1).count(); + if h1_count != 1 { + violations.push(format!("{at}: expected exactly one H1, found {h1_count}")); + } + + // skillkit best-practices: no skipped heading levels (h2 -> h4). + let mut prev_level = 0usize; + for (level, text) in &headings { + if prev_level > 0 && *level > prev_level + 1 { + violations.push(format!( + "{at}: heading {text:?} skips from h{prev_level} to h{level}" + )); + } + prev_level = *level; + } + + // Cursor docs: `disable-model-invocation: true` makes a skill a slash + // command. The bundle titles those skills `# /`; a slash-form H1 + // on a model-invocable skill (or one naming a different slug) would + // document an invocation that does not exist. + if let Some((_, title)) = headings.iter().find(|(level, _)| *level == 1) { + if let Some(slug) = title.strip_prefix('/') { + if slug != skill.name { + violations.push(format!( + "{at}: H1 slash title `/{slug}` does not match skill name {:?}", + skill.name + )); + } + if scalar(skill, "disable-model-invocation") != Some("true") { + violations.push(format!( + "{at}: slash-form H1 requires disable-model-invocation: true" + )); + } + } + } + } + + assert_no_violations("heading conventions", &violations); +} + +#[test] +fn cursor_skill_names_and_descriptions_meet_lint_quality_bar() { + let skills = load_skill_docs(CURSOR_SKILL_ROOT); + let mut violations = Vec::new(); + let mut descriptions_seen: BTreeMap = BTreeMap::new(); + + for skill in &skills { + let at = skill.path.display(); + // skillmark E037. + for prefix in RESERVED_NAME_PREFIXES { + if skill.name.starts_with(prefix) { + violations.push(format!("{at}: name uses reserved prefix {prefix:?}")); + } + } + + let Some(description) = scalar(skill, "description") else { + continue; // required-field enforcement lives in the contract test + }; + // skillmark W003. + if description.chars().count() < MIN_DESCRIPTION_CHARS { + violations.push(format!( + "{at}: description is shorter than {MIN_DESCRIPTION_CHARS} chars" + )); + } + // skillmark E036: the contract test only checks Codex descriptions + // for angle brackets; Cursor metadata is injected into prompts too. + if description.contains(['<', '>']) { + violations.push(format!("{at}: description contains angle brackets")); + } + if !description.ends_with(['.', '!', '?']) { + violations.push(format!( + "{at}: description must end with terminal punctuation" + )); + } + // Duplicate descriptions make model routing between skills ambiguous + // (the agent picks skills from metadata alone). + if let Some(other) = descriptions_seen.insert(description.to_string(), skill.name.clone()) { + violations.push(format!( + "{at}: description duplicates skill {other:?} exactly" + )); + } + } + + assert_no_violations("name/description quality", &violations); +} + +#[test] +fn cursor_skill_references_resolve() { + let skills = load_skill_docs(CURSOR_SKILL_ROOT); + let skill_names: BTreeSet<&str> = skills.iter().map(|skill| skill.name.as_str()).collect(); + let tool_names = mcp_tool_names(); + let mut violations = Vec::new(); + + let link_re = Regex::new(r"\[[^\]]*\]\(([^)]+)\)").unwrap(); + let resource_re = + Regex::new(r"\b(?:agents|scripts|references|assets)/[A-Za-z0-9][A-Za-z0-9._/-]*").unwrap(); + let skill_ref_re = Regex::new(r"tracedecay:([a-z0-9][a-z0-9-]*)").unwrap(); + let slash_ref_re = Regex::new(r"`/([a-z0-9][a-z0-9-]*)`").unwrap(); + let tool_ref_re = Regex::new(r"tracedecay_[a-z_]+").unwrap(); + let mut skill_refs_seen = 0usize; + let mut tool_refs_seen = 0usize; + + for skill in &skills { + let at = skill.path.display(); + let skill_dir = skill.path.parent().expect("skill path has parent"); + + // skillmark E031: relative markdown link targets must exist. + for capture in link_re.captures_iter(&skill.raw) { + let target = capture[1].trim(); + let target = target.split_once(' ').map_or(target, |(path, _title)| path); + if target.starts_with("http://") + || target.starts_with("https://") + || target.starts_with("mailto:") + || target.starts_with('#') + { + continue; + } + if target.starts_with('/') { + violations.push(format!("{at}: link target {target:?} is an absolute path")); + } else if !skill_dir.join(target.split('#').next().unwrap()).exists() { + violations.push(format!("{at}: broken relative link {target:?}")); + } + } + + // skillmark W024 inverse: a mentioned bundled-resource path + // (scripts/x.sh, references/y.md, ...) must actually be shipped. + for found in resource_re.find_iter(&skill.body) { + let mentioned = found.as_str().trim_end_matches(['.', ',', ';', ':']); + if !skill_dir.join(mentioned).exists() { + violations.push(format!( + "{at}: mentions bundled resource {mentioned:?} which does not exist" + )); + } + } + + // Bundle convention: `tracedecay:` hands off to another + // bundled skill; a stale slug strands the agent mid-workflow. + for capture in skill_ref_re.captures_iter(&skill.raw) { + skill_refs_seen += 1; + let slug = &capture[1]; + if !skill_names.contains(slug) { + violations.push(format!( + "{at}: references skill tracedecay:{slug} which is not bundled" + )); + } + } + + // Cursor docs: `/name` invokes a skill by name; a backticked slash + // reference must resolve to a bundled skill. + for capture in slash_ref_re.captures_iter(&skill.raw) { + let slug = &capture[1]; + if !skill_names.contains(slug) { + violations.push(format!( + "{at}: references slash command /{slug} which is not a bundled skill" + )); + } + } + + // Stale tool references: every `tracedecay_*` identifier must be a + // live MCP tool (or a documented non-tool artifact). + for found in tool_ref_re.find_iter(&skill.raw) { + tool_refs_seen += 1; + let identifier = found.as_str().trim_end_matches('_'); + if !tool_names.contains(identifier) && !NON_TOOL_IDENTIFIERS.contains(&identifier) { + violations.push(format!( + "{at}: mentions MCP tool {identifier} which the server does not define" + )); + } + } + + // Cursor docs scope `paths` globs to workspace-relative matching; + // absolute paths and parent escapes can never match. + if let Some(SkillFrontmatterValue::Block(_)) = skill.frontmatter.get("paths") { + let globs = skill.frontmatter["paths"] + .as_list_items() + .unwrap_or_default(); + for glob in &globs { + if glob.starts_with('/') || glob.contains('\\') || glob.contains("..") { + violations.push(format!( + "{at}: paths glob {glob:?} must be a relative forward-slash glob" + )); + } + } + } + } + + // Self-check: the bundle is known to cross-reference skills and mention + // MCP tools heavily; zero matches would mean the extraction regexes + // rotted and the rules above passed vacuously. + assert!( + skill_refs_seen > 0 && tool_refs_seen > 0, + "reference extraction found no tracedecay: or tracedecay_ mentions; \ + the lint regexes are broken" + ); + assert_no_violations("reference integrity", &violations); +} + +fn first_content_line(body: &str) -> Option<&str> { + body.lines() + .map(str::trim_end) + .find(|line| !line.is_empty()) +} + +/// ATX headings outside code fences, as (level, text-after-hashes). +fn unfenced_headings(body: &str) -> Vec<(usize, String)> { + let mut in_fence = false; + let mut headings = Vec::new(); + for line in body.lines() { + if line.trim_start().starts_with("```") { + in_fence = !in_fence; + continue; + } + if in_fence || !line.starts_with('#') { + continue; + } + let level = line.bytes().take_while(|byte| *byte == b'#').count(); + let rest = &line[level..]; + if level <= 6 { + if let Some(text) = rest.strip_prefix(' ') { + headings.push((level, text.trim().to_string())); + } + } + } + headings +} + +fn scalar<'a>(skill: &'a SkillDoc, field: &str) -> Option<&'a str> { + skill + .frontmatter + .get(field) + .and_then(SkillFrontmatterValue::as_scalar) +} + +fn mcp_tool_names() -> BTreeSet { + let mut names = get_tool_definitions() + .into_iter() + .map(|definition| definition.name) + .collect::>(); + // Host-gated: filtered out of the definition list when the `ast-grep` + // CLI is absent, but still a real tool skills may reference. + names.insert("tracedecay_ast_grep_rewrite".to_string()); + names +} + +fn assert_no_violations(rule_family: &str, violations: &[String]) { + assert!( + violations.is_empty(), + "cursor skill lint ({rule_family}) found {} violation(s):\n{}", + violations.len(), + violations.join("\n") + ); +} diff --git a/tests/agent_suite/update_plugin_test.rs b/tests/agent_suite/update_plugin_test.rs index 76f116fb..6a5e0d66 100644 --- a/tests/agent_suite/update_plugin_test.rs +++ b/tests/agent_suite/update_plugin_test.rs @@ -10,10 +10,12 @@ use std::path::{Path, PathBuf}; +use serde_json::json; use tempfile::TempDir; use tracedecay::agents::{get_integration, InstallContext, UpdatePluginOutcome}; use crate::common::{EnvVarGuard, PROCESS_ENV_LOCK}; +use crate::plugin_validation_support::{assert_schema_valid, compile_schema, relative_files_under}; const OLD_BIN: &str = "/old/bin/tracedecay"; const NEW_BIN: &str = "/new/bin/tracedecay"; @@ -113,27 +115,6 @@ fn write_retired_codex_skill(plugin_dir: &Path, name: &str) { .unwrap(); } -/// Every regular file under `root`, relative to it, sorted. -fn file_listing(root: &Path) -> Vec { - fn walk(dir: &Path, root: &Path, out: &mut Vec) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - walk(&path, root, out); - } else { - out.push(path.strip_prefix(root).unwrap().to_path_buf()); - } - } - } - let mut out = Vec::new(); - walk(root, root, &mut out); - out.sort(); - out -} - // --------------------------------------------------------------------------- // Hermes // --------------------------------------------------------------------------- @@ -293,16 +274,10 @@ fn cursor_update_plugin_refreshes_bundle_and_preserves_user_config() { // Generated bundle re-baked: plugin-owned mcp.json command, hook command // paths, and the manifest version stamp. assert!(text(&plugin_dir.join("mcp.json")).contains(NEW_BIN)); - // Template decision pin: the rebaked MCP config must keep the - // workspace-scoped `--path ${workspaceFolder}` args. Normal Cursor windows - // expand the variable; hosts that pass it literally (headless - // agent-session scopes) are handled by serve's unexpanded-template - // fallback, not by dropping the argument from the template. - let rebaked_mcp = read_json(&plugin_dir.join("mcp.json")); - assert_eq!( - rebaked_mcp["mcpServers"]["tracedecay"]["args"], - serde_json::json!(["serve", "--path", "${workspaceFolder}"]) - ); + // The `--path ${workspaceFolder}` args pin is asserted by + // `assert_cursor_rendered_bundle_valid`, which + // `cursor_update_plugin_rerenders_structurally_valid_bundle` runs against + // this same update flow. assert!(text(&plugin_dir.join("hooks/hooks.json")).contains(NEW_BIN)); assert!( text(&plugin_dir.join(".cursor-plugin/plugin.json")).contains(env!("CARGO_PKG_VERSION")) @@ -668,8 +643,324 @@ fn config_only_integrations_report_config_only_and_write_nothing() { "{id} should be config-only" ); assert!( - file_listing(home.path()).is_empty(), + relative_files_under(home.path()).is_empty(), "{id} update_plugin wrote files into the home dir" ); } } + +// --------------------------------------------------------------------------- +// Rendered-output structural validation +// +// The install/update-plugin renderers rewrite bundle commands to the absolute +// tracedecay binary path and stamp the package version. The tests above prove +// user config survives a refresh; this section proves the RENDERED artifacts +// themselves are structurally sound: manifests stay schema-shaped, hook +// commands are absolute and shell-quoted, no template placeholder survives +// rendering except the one intentional `${workspaceFolder}` in Cursor's +// mcp.json args, and no source-bundle file is silently dropped. +// --------------------------------------------------------------------------- + +/// A source plugin bundle directory in the repo (`cursor-plugin/`, +/// `codex-plugin/`). +fn repo_bundle_dir(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join(name) +} + +/// A vendored Cursor schema under `tests/fixtures/cursor-schemas/`, loaded by +/// path so the fixtures stay the single source of truth for manifest shape. +fn load_vendored_schema(file_name: &str) -> serde_json::Value { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/cursor-schemas") + .join(file_name); + read_json(&path) +} + +/// The exact shell-quoted form the renderer bakes into hook commands: +/// single-quoted on POSIX, double-quoted on Windows (matching +/// `agents::hook_command`, which the test binary's platform selects). +fn quoted_bin(bin: &str) -> String { + if cfg!(windows) { + format!("\"{bin}\"") + } else { + format!("'{bin}'") + } +} + +/// A rendered hook command must be exactly ` ` — quoting guards paths with spaces, and an absolute path +/// guards against PATH-dependent hooks. +fn assert_rendered_hook_command(command: &str, bin: &str, subcommand_prefix: &str) { + assert!( + Path::new(bin).has_root(), + "hook binary path {bin:?} must be absolute" + ); + let quoted = quoted_bin(bin); + let suffix = command.strip_prefix("ed).unwrap_or_else(|| { + panic!("hook command {command:?} must start with the quoted binary path {quoted:?}") + }); + let subcommand = suffix.strip_prefix(' ').unwrap_or_else(|| { + panic!("hook command {command:?} must separate binary and subcommand with a space") + }); + assert!( + subcommand.starts_with(subcommand_prefix) && !subcommand.trim().is_empty(), + "hook command {command:?} must invoke a {subcommand_prefix}* subcommand" + ); +} + +/// Collects every string value containing a `${...}` placeholder from the +/// rendered JSON files under `install_dir`, as +/// `(relative file, JSON pointer, value)`. Only JSON files are scanned: +/// they are the rendered config surfaces, while markdown (README, skills) +/// legitimately documents the placeholder syntax. +fn rendered_json_placeholders(install_dir: &Path) -> Vec<(String, String, String)> { + fn walk(value: &serde_json::Value, pointer: &str, out: &mut Vec<(String, String)>) { + match value { + serde_json::Value::String(s) if s.contains("${") => { + out.push((pointer.to_string(), s.clone())); + } + serde_json::Value::Array(items) => { + for (index, item) in items.iter().enumerate() { + walk(item, &format!("{pointer}/{index}"), out); + } + } + serde_json::Value::Object(map) => { + for (key, item) in map { + walk(item, &format!("{pointer}/{key}"), out); + } + } + _ => {} + } + } + let mut found = Vec::new(); + for relative in relative_files_under(install_dir) { + if relative.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + let file = relative.to_string_lossy().replace('\\', "/"); + let mut in_file = Vec::new(); + walk(&read_json(&install_dir.join(&relative)), "", &mut in_file); + found.extend( + in_file + .into_iter() + .map(|(pointer, value)| (file.clone(), pointer, value)), + ); + } + found +} + +/// Every file shipped in the source bundle must appear in the rendered +/// install — a renderer that skips a file drops it silently, because install +/// wipes the previous managed files first. The rendered dir may hold extras +/// (managed skill overlay, user files); source ⊆ rendered is the contract. +fn assert_source_bundle_fully_rendered(source_dir: &Path, install_dir: &Path) { + let source = relative_files_under(source_dir); + assert!( + !source.is_empty(), + "source bundle {} should not be empty", + source_dir.display() + ); + let rendered = relative_files_under(install_dir); + let missing: Vec<&PathBuf> = source + .iter() + .filter(|relative| !rendered.contains(relative)) + .collect(); + assert!( + missing.is_empty(), + "files present in {} but missing from rendered install {}: {missing:?}", + source_dir.display(), + install_dir.display() + ); +} + +/// Full structural validation of a rendered Cursor plugin bundle. +fn assert_cursor_rendered_bundle_valid(plugin_dir: &Path, bin: &str) { + // Rendered mcp.json: absolute command, and the args pin — `serve --path + // ${workspaceFolder}` is the one placeholder that must survive rendering. + // Normal Cursor windows expand the variable at session start; hosts that + // pass it through literally (headless agent-session scopes) are handled + // by serve's unexpanded-template fallback, not by dropping the argument + // from the template. + let mcp = read_json(&plugin_dir.join("mcp.json")); + let server = &mcp["mcpServers"]["tracedecay"]; + assert_eq!(server["type"], "stdio"); + assert_eq!(server["command"], json!(bin)); + assert!( + Path::new(bin).has_root(), + "rendered MCP command {bin:?} must be an absolute path" + ); + assert_eq!( + server["args"], + json!(["serve", "--path", "${workspaceFolder}"]), + "rendered mcp.json args must keep the workspaceFolder placeholder pin" + ); + + // Rendered manifest: still valid against the vendored official plugin + // schema, with the version stamped to this binary's package version. + let manifest_path = plugin_dir.join(".cursor-plugin/plugin.json"); + let manifest = read_json(&manifest_path); + let plugin_schema = compile_schema(&load_vendored_schema("plugin.schema.json")); + assert_schema_valid(&plugin_schema, &manifest, &manifest_path); + assert_eq!(manifest["name"], "tracedecay"); + assert_eq!( + manifest["version"], + json!(env!("CARGO_PKG_VERSION")), + "rendered manifest version must match the binary's package version" + ); + + // Rendered hooks.json: every event handler runs the quoted absolute + // binary with a hook-cursor-* subcommand. + let hooks = read_json(&plugin_dir.join("hooks/hooks.json")); + let events = hooks["hooks"] + .as_object() + .expect("rendered hooks.json must contain a hooks object"); + assert!( + !events.is_empty(), + "rendered hooks.json must register events" + ); + for (event, entries) in events { + let entries = entries + .as_array() + .unwrap_or_else(|| panic!("hook event {event} must hold an array")); + assert!(!entries.is_empty(), "hook event {event} must not be empty"); + for entry in entries { + let command = entry["command"] + .as_str() + .unwrap_or_else(|| panic!("hook event {event} entry must carry a command")); + assert_rendered_hook_command(command, bin, "hook-cursor-"); + } + } + + // No placeholder survives rendering anywhere else in the JSON surfaces. + assert_eq!( + rendered_json_placeholders(plugin_dir), + vec![( + "mcp.json".to_string(), + "/mcpServers/tracedecay/args/2".to_string(), + "${workspaceFolder}".to_string() + )], + "the mcp.json args pin is the only placeholder allowed in rendered JSON" + ); + + // Nothing from the source bundle was silently dropped. + assert_source_bundle_fully_rendered(&repo_bundle_dir("cursor-plugin"), plugin_dir); +} + +/// Full structural validation of a rendered Codex plugin bundle. +fn assert_codex_rendered_bundle_valid(plugin_dir: &Path, bin: &str) { + // Rendered manifest: version stamped to this binary's package version. + let manifest = read_json(&plugin_dir.join(".codex-plugin/plugin.json")); + assert_eq!(manifest["name"], "tracedecay"); + assert_eq!( + manifest["version"], + json!(env!("CARGO_PKG_VERSION")), + "rendered manifest version must match the binary's package version" + ); + + // Rendered hooks.json: Codex nests handlers in matcher groups; every + // handler command is the quoted absolute binary plus a hook-codex-* + // subcommand. + let hooks = read_json(&plugin_dir.join("hooks/hooks.json")); + let events = hooks["hooks"] + .as_object() + .expect("rendered hooks.json must contain a hooks object"); + assert!( + !events.is_empty(), + "rendered hooks.json must register events" + ); + for (event, groups) in events { + let groups = groups + .as_array() + .unwrap_or_else(|| panic!("hook event {event} must hold an array of groups")); + assert!(!groups.is_empty(), "hook event {event} must not be empty"); + for group in groups { + let handlers = group["hooks"] + .as_array() + .unwrap_or_else(|| panic!("hook event {event} group must carry handlers")); + for handler in handlers { + let command = handler["command"] + .as_str() + .unwrap_or_else(|| panic!("hook event {event} handler must carry a command")); + assert_rendered_hook_command(command, bin, "hook-codex-"); + } + } + } + + // Codex has no intentional placeholder: nothing may survive rendering. + assert_eq!( + rendered_json_placeholders(plugin_dir), + Vec::<(String, String, String)>::new(), + "no placeholder may survive rendering in the Codex bundle" + ); + + // Nothing from the source bundle was silently dropped. + assert_source_bundle_fully_rendered(&repo_bundle_dir("codex-plugin"), plugin_dir); +} + +#[test] +fn cursor_install_renders_structurally_valid_bundle() { + let home = TempDir::new().unwrap(); + let cursor = get_integration("cursor").unwrap(); + cursor.install(&ctx(home.path(), NEW_BIN)).unwrap(); + assert_cursor_rendered_bundle_valid( + &home.path().join(".cursor/plugins/local/tracedecay"), + NEW_BIN, + ); +} + +#[test] +fn cursor_update_plugin_rerenders_structurally_valid_bundle() { + let home = TempDir::new().unwrap(); + let cursor = get_integration("cursor").unwrap(); + cursor.install(&ctx(home.path(), OLD_BIN)).unwrap(); + + let outcome = cursor.update_plugin(&ctx(home.path(), NEW_BIN)).unwrap(); + assert!(matches!(outcome, UpdatePluginOutcome::Refreshed(_))); + assert_cursor_rendered_bundle_valid( + &home.path().join(".cursor/plugins/local/tracedecay"), + NEW_BIN, + ); +} + +#[test] +fn codex_install_renders_structurally_valid_bundle() { + let home = TempDir::new().unwrap(); + let codex = get_integration("codex").unwrap(); + codex.install(&ctx(home.path(), NEW_BIN)).unwrap(); + + let plugin_dir = codex_bootstrap_dir(home.path()); + assert_codex_rendered_bundle_valid(&plugin_dir, NEW_BIN); + + // Global-scope MCP rendering: absolute command, plain `serve` args, and + // the global-DB env flag. + let mcp = read_json(&plugin_dir.join(".mcp.json")); + let server = &mcp["mcpServers"]["tracedecay"]; + assert_eq!(server["type"], "stdio"); + assert_eq!(server["command"], json!(NEW_BIN)); + assert_eq!(server["args"], json!(["serve"])); + assert_eq!(server["env"], json!({ "TRACEDECAY_ENABLE_GLOBAL_DB": "1" })); +} + +#[test] +fn codex_local_install_renders_project_scoped_mcp() { + let home = TempDir::new().unwrap(); + let project = TempDir::new().unwrap(); + let codex = get_integration("codex").unwrap(); + codex + .install_local(&ctx(home.path(), NEW_BIN), project.path()) + .unwrap(); + + let plugin_dir = codex_bootstrap_dir(project.path()); + assert_codex_rendered_bundle_valid(&plugin_dir, NEW_BIN); + + // Project-local scope renders relative-path serve args and drops the + // global-DB env flag. + let mcp = read_json(&plugin_dir.join(".mcp.json")); + let server = &mcp["mcpServers"]["tracedecay"]; + assert_eq!(server["command"], json!(NEW_BIN)); + assert_eq!(server["args"], json!(["serve", "--path", "."])); + assert!( + server.get("env").is_none(), + "project-local installs must not enable the global DB" + ); +} diff --git a/tests/core_cli_suite/cli_non_interactive_test.rs b/tests/core_cli_suite/cli_non_interactive_test.rs index e7824108..183a26df 100644 --- a/tests/core_cli_suite/cli_non_interactive_test.rs +++ b/tests/core_cli_suite/cli_non_interactive_test.rs @@ -24,14 +24,7 @@ use tracedecay::storage::{ use tracedecay::tracedecay::{TraceDecay, TraceDecayOpenOptions}; fn canonical_temp_path(path: &Path) -> PathBuf { - #[cfg(windows)] - { - path.to_path_buf() - } - #[cfg(not(windows))] - { - path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) - } + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) } fn profile_root(home: &Path) -> PathBuf { diff --git a/tests/fixtures/cursor-schemas/hooks.schema.json b/tests/fixtures/cursor-schemas/hooks.schema.json new file mode 100644 index 00000000..685e2ae8 --- /dev/null +++ b/tests/fixtures/cursor-schemas/hooks.schema.json @@ -0,0 +1,116 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://cursor.com/schemas/cursor-plugin/hooks.json", + "title": "Cursor Hooks Configuration", + "description": "Schema for hooks.json — Cursor's hooks configuration (plugin hooks/hooks.json, .cursor/hooks.json, ~/.cursor/hooks.json). Cursor does not publish a standalone machine-readable schema for hooks.json (the official plugin.schema.json in cursor/plugins types the inline `hooks` field as a bare object), so this schema is derived from the official reference at https://cursor.com/docs/hooks (Configuration file, Global Configuration Options, Per-Script Configuration Options) as of 2026-07-02, cross-checked against the hooks.json files shipped in official plugins in the cursor/plugins repository. Hook event names are enumerated from the documented list; a Cursor release adding new events requires re-vendoring.", + "type": "object", + "required": ["hooks"], + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "version": { + "type": "integer", + "const": 1, + "description": "Config schema version. Optional; defaults to 1." + }, + "hooks": { + "type": "object", + "description": "Map of hook event name to an array of hook definitions.", + "additionalProperties": false, + "properties": { + "sessionStart": { "$ref": "#/$defs/hookArray" }, + "sessionEnd": { "$ref": "#/$defs/hookArray" }, + "preToolUse": { "$ref": "#/$defs/hookArray" }, + "postToolUse": { "$ref": "#/$defs/hookArray" }, + "postToolUseFailure": { "$ref": "#/$defs/hookArray" }, + "subagentStart": { "$ref": "#/$defs/hookArray" }, + "subagentStop": { "$ref": "#/$defs/hookArray" }, + "beforeShellExecution": { "$ref": "#/$defs/hookArray" }, + "afterShellExecution": { "$ref": "#/$defs/hookArray" }, + "beforeMCPExecution": { "$ref": "#/$defs/hookArray" }, + "afterMCPExecution": { "$ref": "#/$defs/hookArray" }, + "beforeReadFile": { "$ref": "#/$defs/hookArray" }, + "afterFileEdit": { "$ref": "#/$defs/hookArray" }, + "beforeSubmitPrompt": { "$ref": "#/$defs/hookArray" }, + "preCompact": { "$ref": "#/$defs/hookArray" }, + "stop": { "$ref": "#/$defs/hookArray" }, + "afterAgentResponse": { "$ref": "#/$defs/hookArray" }, + "afterAgentThought": { "$ref": "#/$defs/hookArray" }, + "beforeTabFileRead": { "$ref": "#/$defs/hookArray" }, + "afterTabFileEdit": { "$ref": "#/$defs/hookArray" }, + "workspaceOpen": { "$ref": "#/$defs/hookArray" } + } + } + }, + "$defs": { + "hookArray": { + "type": "array", + "items": { "$ref": "#/$defs/hookDefinition" } + }, + "hookDefinition": { + "oneOf": [ + { "$ref": "#/$defs/commandHook" }, + { "$ref": "#/$defs/promptHook" } + ] + }, + "commonHookOptions": { + "timeout": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Execution timeout in seconds." + }, + "loop_limit": { + "type": ["number", "null"], + "description": "Per-script loop limit for stop/subagentStop hooks. null removes the limit; default is 5." + }, + "failClosed": { + "type": "boolean", + "description": "When true, hook failures (crash, timeout, invalid JSON) block the action instead of allowing it through." + }, + "matcher": { + "type": "string", + "minLength": 1, + "description": "Filter pattern controlling when the hook runs; the matched value depends on the hook event (tool type, subagent type, shell command text, ...)." + } + }, + "commandHook": { + "type": "object", + "description": "Command-based hook: executes a shell script that receives JSON on stdin and returns JSON on stdout.", + "required": ["command"], + "additionalProperties": false, + "properties": { + "type": { + "const": "command", + "description": "Hook execution type. Optional; \"command\" is the default." + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Script path or shell command. Relative paths resolve against the hook source root (plugin root, project root, or ~/.cursor)." + }, + "timeout": { "$ref": "#/$defs/commonHookOptions/timeout" }, + "loop_limit": { "$ref": "#/$defs/commonHookOptions/loop_limit" }, + "failClosed": { "$ref": "#/$defs/commonHookOptions/failClosed" }, + "matcher": { "$ref": "#/$defs/commonHookOptions/matcher" } + } + }, + "promptHook": { + "type": "object", + "description": "Prompt-based hook: an LLM evaluates a natural-language condition and returns { ok, reason? }.", + "required": ["type", "prompt"], + "additionalProperties": false, + "properties": { + "type": { "const": "prompt" }, + "prompt": { + "type": "string", + "minLength": 1, + "description": "Natural-language condition for the LLM to evaluate." + }, + "timeout": { "$ref": "#/$defs/commonHookOptions/timeout" }, + "loop_limit": { "$ref": "#/$defs/commonHookOptions/loop_limit" }, + "failClosed": { "$ref": "#/$defs/commonHookOptions/failClosed" }, + "matcher": { "$ref": "#/$defs/commonHookOptions/matcher" } + } + } + } +} diff --git a/tests/fixtures/cursor-schemas/marketplace.schema.json b/tests/fixtures/cursor-schemas/marketplace.schema.json new file mode 100644 index 00000000..70eba0f5 --- /dev/null +++ b/tests/fixtures/cursor-schemas/marketplace.schema.json @@ -0,0 +1,77 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://cursor.com/schemas/cursor-plugin/marketplace.json", + "title": "Cursor Plugin Marketplace", + "description": "Schema for .cursor-plugin/marketplace.json — defines a marketplace that indexes one or more Cursor plugins.", + "type": "object", + "required": ["name", "plugins"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Unique identifier for the marketplace." + }, + "owner": { + "$ref": "#/$defs/owner", + "description": "The marketplace owner or organisation." + }, + "metadata": { + "type": "object", + "additionalProperties": true, + "properties": { + "description": { + "type": "string", + "description": "Short description of the marketplace." + } + }, + "description": "Arbitrary metadata about the marketplace." + }, + "plugins": { + "type": "array", + "items": { "$ref": "#/$defs/pluginEntry" }, + "description": "List of plugins available in the marketplace." + } + }, + "$defs": { + "owner": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Owner or organisation name." + }, + "email": { + "type": "string", + "format": "email", + "description": "Contact email address." + } + } + }, + "pluginEntry": { + "type": "object", + "required": ["name", "source"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$", + "description": "Plugin identifier matching the plugin's name in its plugin.json." + }, + "source": { + "type": "string", + "minLength": 1, + "description": "Path to the plugin directory (relative to the marketplace root) or a remote URL." + }, + "description": { + "type": "string", + "description": "Short description of the plugin." + } + } + } + } +} diff --git a/tests/fixtures/cursor-schemas/mcp.schema.json b/tests/fixtures/cursor-schemas/mcp.schema.json new file mode 100644 index 00000000..14f1bc95 --- /dev/null +++ b/tests/fixtures/cursor-schemas/mcp.schema.json @@ -0,0 +1,103 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://cursor.com/schemas/cursor-plugin/mcp.json", + "title": "Cursor MCP Server Configuration", + "description": "Schema for mcp.json — Cursor's MCP server configuration (plugin-root mcp.json, .cursor/mcp.json, ~/.cursor/mcp.json). Cursor does not publish a standalone machine-readable schema for mcp.json (the official plugin.schema.json in cursor/plugins types the inline `mcpServers` field as a bare object), so this schema is derived from the official field reference at https://cursor.com/docs/context/mcp (STDIO server configuration, remote servers, static OAuth) as of 2026-07-02. Field values may contain Cursor config interpolation variables such as ${env:NAME} and ${workspaceFolder}.", + "type": "object", + "required": ["mcpServers"], + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "mcpServers": { + "type": "object", + "description": "Map of server name to MCP server definition.", + "additionalProperties": { "$ref": "#/$defs/mcpServer" } + } + }, + "$defs": { + "stringMap": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "mcpServer": { + "oneOf": [ + { "$ref": "#/$defs/stdioServer" }, + { "$ref": "#/$defs/remoteServer" } + ] + }, + "stdioServer": { + "type": "object", + "description": "Local command-line (stdio) MCP server.", + "required": ["command"], + "additionalProperties": false, + "properties": { + "type": { + "const": "stdio", + "description": "Server connection type. Optional in practice; when present must be \"stdio\" for command-based servers." + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Command to start the server executable. Must be on the system path or a full path." + }, + "args": { + "type": "array", + "items": { "type": "string" }, + "description": "Arguments passed to the command." + }, + "env": { + "$ref": "#/$defs/stringMap", + "description": "Environment variables for the server process." + }, + "envFile": { + "type": "string", + "minLength": 1, + "description": "Path to an environment file to load additional variables (stdio servers only)." + } + } + }, + "remoteServer": { + "type": "object", + "description": "Remote MCP server reached over SSE or streamable HTTP.", + "required": ["url"], + "additionalProperties": false, + "properties": { + "type": { + "enum": ["sse", "http", "streamable-http"], + "description": "Server connection type. Optional; inferred from the endpoint when omitted." + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL of the SSE or HTTP endpoint." + }, + "headers": { + "$ref": "#/$defs/stringMap", + "description": "HTTP headers sent to the server (e.g. Authorization)." + }, + "auth": { + "type": "object", + "description": "Static OAuth client credentials for providers without dynamic client registration.", + "required": ["CLIENT_ID"], + "additionalProperties": false, + "properties": { + "CLIENT_ID": { + "type": "string", + "minLength": 1, + "description": "OAuth 2.0 Client ID from the MCP provider." + }, + "CLIENT_SECRET": { + "type": "string", + "description": "OAuth 2.0 Client Secret, for confidential clients." + }, + "scopes": { + "type": "array", + "items": { "type": "string" }, + "description": "OAuth scopes to request. Discovered from the provider when omitted." + } + } + } + } + } + } +} diff --git a/tests/fixtures/cursor-schemas/plugin.schema.json b/tests/fixtures/cursor-schemas/plugin.schema.json new file mode 100644 index 00000000..d4c539e0 --- /dev/null +++ b/tests/fixtures/cursor-schemas/plugin.schema.json @@ -0,0 +1,140 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://cursor.com/schemas/cursor-plugin/plugin.json", + "title": "Cursor Plugin Manifest", + "description": "Schema for .cursor-plugin/plugin.json — defines a single Cursor plugin's metadata, components, and configuration.", + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$", + "description": "Unique plugin identifier in kebab-case (lowercase alphanumeric with hyphens and periods)." + }, + "displayName": { + "type": "string", + "description": "Human-readable display name for the plugin." + }, + "description": { + "type": "string", + "description": "Short description of what the plugin does." + }, + "version": { + "type": "string", + "description": "Semantic version of the plugin (e.g. \"1.2.3\")." + }, + "author": { + "$ref": "#/$defs/author", + "description": "The plugin author." + }, + "publisher": { + "type": "string", + "minLength": 1, + "description": "Publisher or organisation name." + }, + "homepage": { + "type": "string", + "format": "uri", + "description": "URL to the plugin's homepage." + }, + "repository": { + "type": "string", + "format": "uri", + "description": "URL to the plugin's source code repository." + }, + "license": { + "type": "string", + "description": "SPDX license identifier (e.g. \"MIT\", \"Apache-2.0\")." + }, + "logo": { + "type": "string", + "description": "Path to a logo image (relative to the plugin root) or an absolute URL." + }, + "keywords": { + "type": "array", + "items": { "type": "string" }, + "description": "Keywords for discovery and search." + }, + "category": { + "type": "string", + "description": "Plugin category for marketplace classification." + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "description": "Tags for filtering and discovery." + }, + "commands": { + "$ref": "#/$defs/stringOrStringArray", + "description": "Glob pattern(s) or path(s) to command files." + }, + "agents": { + "$ref": "#/$defs/stringOrStringArray", + "description": "Glob pattern(s) or path(s) to agent definition files." + }, + "skills": { + "$ref": "#/$defs/stringOrStringArray", + "description": "Glob pattern(s) or path(s) to skill files." + }, + "rules": { + "$ref": "#/$defs/stringOrStringArray", + "description": "Glob pattern(s) or path(s) to rule files." + }, + "hooks": { + "oneOf": [ + { "type": "string" }, + { "type": "object" } + ], + "description": "Path to a hooks configuration file, or an inline hooks object." + }, + "mcpServers": { + "$ref": "#/$defs/mcpServers", + "description": "MCP server configuration — a path, an inline config object, or an array of either." + } + }, + "$defs": { + "author": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Author name." + }, + "email": { + "type": "string", + "format": "email", + "description": "Author email address." + } + } + }, + "stringOrStringArray": { + "oneOf": [ + { "type": "string" }, + { + "type": "array", + "items": { "type": "string" } + } + ] + }, + "mcpServers": { + "oneOf": [ + { "type": "string" }, + { "type": "object" }, + { + "type": "array", + "items": { + "oneOf": [ + { "type": "string" }, + { "type": "object" } + ] + } + } + ] + } + } +} diff --git a/tests/storage_suite/branch_drift_test.rs b/tests/storage_suite/branch_drift_test.rs index ab6f3038..2b85d9b0 100644 --- a/tests/storage_suite/branch_drift_test.rs +++ b/tests/storage_suite/branch_drift_test.rs @@ -57,14 +57,7 @@ impl Drop for HomeEnvGuard { } fn canonical_temp_path(path: &Path) -> PathBuf { - #[cfg(windows)] - { - path.to_path_buf() - } - #[cfg(not(windows))] - { - path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) - } + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) } fn git_output(project: &Path, args: &[&str]) -> Output { diff --git a/tests/storage_suite/migrate_inventory_test.rs b/tests/storage_suite/migrate_inventory_test.rs index 2369461b..24dcb450 100644 --- a/tests/storage_suite/migrate_inventory_test.rs +++ b/tests/storage_suite/migrate_inventory_test.rs @@ -16,14 +16,7 @@ use tracedecay::migrate::manifest::{ }; fn canonical_temp_path(path: &Path) -> PathBuf { - #[cfg(windows)] - { - path.to_path_buf() - } - #[cfg(not(windows))] - { - path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) - } + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) } fn with_env_vars(vars: &[(&str, Option<&Path>)], f: impl FnOnce() -> T) -> T { diff --git a/tests/storage_suite/migration_manifest_test.rs b/tests/storage_suite/migration_manifest_test.rs index 69ee5e26..a8769e5d 100644 --- a/tests/storage_suite/migration_manifest_test.rs +++ b/tests/storage_suite/migration_manifest_test.rs @@ -43,14 +43,7 @@ fn manifest_for(protocol: MigrationProtocol, migration_id: &str) -> MigrationMan } fn canonical_temp_path(path: &Path) -> PathBuf { - #[cfg(windows)] - { - path.to_path_buf() - } - #[cfg(not(windows))] - { - path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) - } + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) } #[cfg(unix)] diff --git a/tests/storage_suite/profile_storage_migration_test.rs b/tests/storage_suite/profile_storage_migration_test.rs index a97459f6..6c3dd5ce 100644 --- a/tests/storage_suite/profile_storage_migration_test.rs +++ b/tests/storage_suite/profile_storage_migration_test.rs @@ -70,14 +70,21 @@ impl Drop for HomeEnvGuard { } fn canonical_temp_path(path: &Path) -> PathBuf { - #[cfg(windows)] - { - path.to_path_buf() - } - #[cfg(not(windows))] - { - path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) - } + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +fn normalize_test_path(path: &Path) -> String { + path.to_string_lossy() + .replace('\\', "/") + .trim_start_matches("//?/") + .to_string() +} + +fn assert_path_eq(actual: impl AsRef, expected: impl AsRef) { + assert_eq!( + normalize_test_path(actual.as_ref()), + normalize_test_path(expected.as_ref()) + ); } fn portable_relpath(path: &str) -> String { @@ -488,8 +495,8 @@ async fn trace_decay_init_uses_profile_shard_when_enrolled() { let cg = TraceDecay::init(&project).await.unwrap(); - assert_eq!(cg.store_layout().data_root, shard_root); - assert_eq!(cg.db_path(), shard_root.join("tracedecay.db")); + assert_path_eq(&cg.store_layout().data_root, &shard_root); + assert_path_eq(cg.db_path(), shard_root.join("tracedecay.db")); assert!(shard_root.join("config.json").is_file()); assert!(shard_root.join(STORE_MANIFEST_FILENAME).is_file()); assert!( @@ -756,8 +763,8 @@ async fn trace_decay_open_branch_uses_profile_shard_branch_db() { .await .unwrap(); - assert_eq!(cg.store_layout().data_root, shard_root); - assert_eq!(cg.db_path(), branch_db); + assert_path_eq(&cg.store_layout().data_root, &shard_root); + assert_path_eq(cg.db_path(), &branch_db); assert_eq!(cg.serving_branch(), Some("feature/profile")); } diff --git a/tests/storage_suite/storage_resolver_test.rs b/tests/storage_suite/storage_resolver_test.rs index f0fd089d..d39daabf 100644 --- a/tests/storage_suite/storage_resolver_test.rs +++ b/tests/storage_suite/storage_resolver_test.rs @@ -77,14 +77,21 @@ fn write_enrollment(root: &Path) { } fn canonical_temp_path(path: &Path) -> PathBuf { - #[cfg(windows)] - { - path.to_path_buf() - } - #[cfg(not(windows))] - { - path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) - } + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +fn normalize_test_path(path: &Path) -> String { + path.to_string_lossy() + .replace('\\', "/") + .trim_start_matches("//?/") + .to_string() +} + +fn assert_path_eq(actual: impl AsRef, expected: impl AsRef) { + assert_eq!( + normalize_test_path(actual.as_ref()), + normalize_test_path(expected.as_ref()) + ); } fn test_home(dir: &TempDir) -> PathBuf { @@ -366,7 +373,7 @@ async fn config_path_uses_profile_shard_when_enrolled() { ) .unwrap(); - assert_eq!(get_config_path(&project), shard_root.join("config.json")); + assert_path_eq(get_config_path(&project), shard_root.join("config.json")); assert_eq!( load_config(&project).unwrap().root_dir, "profile-shard-config" @@ -384,9 +391,9 @@ async fn config_path_defaults_to_profile_shard_without_enrollment() { let _home_guard = HomeGuard::set(&home); let project_id = default_profile_project_id(&project); - assert_eq!( + assert_path_eq( get_config_path(&project), - profile_root.join(format!("projects/{project_id}/config.json")) + profile_root.join(format!("projects/{project_id}/config.json")), ); } @@ -562,21 +569,21 @@ async fn resolved_project_store_helpers_route_profile_sharded_session_artifacts( let _home_guard = HomeGuard::set(&home); write_enrollment(&project); - assert_eq!( + assert_path_eq( resolve_project_session_db_path(&project).unwrap(), - profile_root.join("projects/proj_123/sessions.db") + profile_root.join("projects/proj_123/sessions.db"), ); - assert_eq!( + assert_path_eq( resolve_response_handle_root(&project).unwrap(), - profile_root.join("projects/proj_123/response-handles") + profile_root.join("projects/proj_123/response-handles"), ); - assert_eq!( + assert_path_eq( resolve_lcm_payload_root(&project).unwrap(), - profile_root.join("projects/proj_123/lcm-payloads") + profile_root.join("projects/proj_123/lcm-payloads"), ); - assert_eq!( + assert_path_eq( project_session_db_path(&project), - profile_root.join("projects/proj_123/sessions.db") + profile_root.join("projects/proj_123/sessions.db"), ); } @@ -591,13 +598,13 @@ async fn resolved_project_store_helpers_default_to_profile_sharded_artifact_path let _home_guard = HomeGuard::set(&home); let project_id = default_profile_project_id(&project); - assert_eq!( + assert_path_eq( resolve_project_session_db_path(&project).unwrap(), - profile_root.join(format!("projects/{project_id}/sessions.db")) + profile_root.join(format!("projects/{project_id}/sessions.db")), ); - assert_eq!( + assert_path_eq( project_session_db_path(&project), - profile_root.join(format!("projects/{project_id}/sessions.db")) + profile_root.join(format!("projects/{project_id}/sessions.db")), ); } @@ -643,8 +650,8 @@ async fn trace_decay_init_defaults_to_profile_shard_without_repo_marker() { let cg = TraceDecay::init(&project).await.unwrap(); assert_eq!(cg.store_layout().storage_mode, StorageMode::ProfileSharded); - assert_eq!(cg.store_layout().data_root, shard_root); - assert_eq!(cg.db_path(), shard_root.join("tracedecay.db")); + assert_path_eq(&cg.store_layout().data_root, &shard_root); + assert_path_eq(cg.db_path(), shard_root.join("tracedecay.db")); assert_eq!(discover_project_root(&child), Some(project.clone())); assert!(!project.join(".tracedecay").exists()); assert!(shard_root.join("config.json").exists()); @@ -874,7 +881,7 @@ async fn trace_decay_open_uses_profile_shard_paths_from_enrollment_marker() { let opened = TraceDecay::open(&project).await.unwrap(); - assert_eq!(opened.db_path(), shard_root.join("tracedecay.db")); + assert_path_eq(opened.db_path(), shard_root.join("tracedecay.db")); assert_eq!(opened.get_config().root_dir, project.to_string_lossy()); assert_eq!(opened.serving_branch(), Some("main")); }