diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f8736e4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +# Continuous integration: lint, typecheck, tests, build, and a packaged-CLI +# smoke test. Runs fully offline (no provider credentials, no model calls). +name: ci + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +env: + # Pinned Node version used by all product builds (see docs/release-process.md). + NODE_VERSION: "22.13.1" + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js (pinned) + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies (locked) + run: npm ci + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test + + - name: Build (tsc) + run: npm run build + + - name: Build portable CLI bundle + run: npm run build:cli + + - name: Smoke-test the portable CLI (outside the repository) + run: node scripts/smoke-test.mjs --target dist/cli/agent-skill-verifier.cjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..88bfed2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,230 @@ +# Tag-driven release: builds standalone executables on native runners for +# every supported platform, validates the archives and checksums, then +# publishes a GitHub Release draft-first. +# +# Dry run: trigger manually (workflow_dispatch). A dispatch run builds and +# validates everything and uploads workflow artifacts, but NEVER creates a +# tag or a Release (the release job only runs for tag pushes). +name: release + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + +# Least privilege by default; only the `release` job below gets write access. +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + # Pinned Node version for reproducible release builds. The Node SEA procedure + # used by scripts/build-standalone.mjs follows this version's documentation. + NODE_VERSION: "22.13.1" + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js (pinned) + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Validate tag format and package-version match + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + run: node scripts/release-check.mjs --tag-only --tag "$GITHUB_REF_NAME" + + - name: Install dependencies (locked) + run: npm ci + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test + + - name: Build portable CLI bundle + run: npm run build:cli + + - name: Smoke-test the portable CLI + run: node scripts/smoke-test.mjs --target dist/cli/agent-skill-verifier.cjs + + build-platform: + needs: verify + strategy: + matrix: + include: + - os: windows-latest + target: windows-x64 + - os: ubuntu-latest + target: linux-x64 + - os: macos-15-intel + target: macos-x64 + - os: macos-14 + target: macos-arm64 + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js (pinned) + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies (locked) + run: npm ci + + - name: Build and package the standalone executable (Node SEA) + run: node scripts/package-release.mjs --standalone-only + + - name: Validate the archive and manifest + run: node scripts/release-check.mjs --require-commit-match + + - name: Smoke-test the standalone executable (outside the repository) + run: node scripts/smoke-test.mjs + + - name: Upload platform archive + uses: actions/upload-artifact@v4 + with: + name: asv-${{ matrix.target }} + path: | + dist/release/*.zip + dist/release/*.tar.gz + if-no-files-found: error + retention-days: 7 + + build-portable: + needs: verify + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js (pinned) + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies (locked) + run: npm ci + + - name: Package the portable Node bundle + run: node scripts/package-release.mjs --portable-only + + - name: Validate the archive and manifest + run: node scripts/release-check.mjs --require-commit-match + + - name: Smoke-test the portable bundle (outside the repository) + run: node scripts/smoke-test.mjs --target dist/cli/agent-skill-verifier.cjs + + - name: Upload portable archive + uses: actions/upload-artifact@v4 + with: + name: asv-node + path: dist/release/*.zip + if-no-files-found: error + retention-days: 7 + + release: + # Publication happens ONLY for pushed version tags — never for + # workflow_dispatch dry runs and never from pull requests. + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: [verify, build-platform, build-portable] + runs-on: ubuntu-latest + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js (pinned) + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Download all build artifacts + uses: actions/download-artifact@v4 + with: + pattern: asv-* + path: dist/release + merge-multiple: true + + - name: Inspect downloaded assets + run: ls -l dist/release + + - name: Validate tag, archives, manifests, and expected asset set + run: > + node scripts/release-check.mjs + --tag "$GITHUB_REF_NAME" + --require-commit-match + --require-assets windows-x64,linux-x64,macos-x64,macos-arm64,node + + - name: Generate checksums + run: node scripts/generate-checksums.mjs dist/release + + - name: Verify checksums + run: node scripts/generate-checksums.mjs dist/release --verify + + - name: Generate release notes + run: node scripts/release-notes.mjs > dist/release-notes.md + + - name: Ensure the release does not already exist + run: | + if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "::error::Release $GITHUB_REF_NAME already exists — refusing to overwrite." + exit 1 + fi + + - name: Create draft release + run: | + VERSION="${GITHUB_REF_NAME#v}" + gh release create "$GITHUB_REF_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --draft \ + --title "Agent Skill Verifier v${VERSION}" \ + --notes-file dist/release-notes.md \ + --verify-tag + + - name: Upload assets to the draft (no clobbering) + run: | + gh release upload "$GITHUB_REF_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + dist/release/*.zip \ + dist/release/*.tar.gz \ + dist/release/SHA256SUMS.txt \ + dist/release/SHA256SUMS.json + + - name: Confirm every expected asset was uploaded + run: | + gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" \ + --json assets --jq '.assets[].name' | sort > uploaded.txt + ls dist/release | grep -E '\.(zip|tar\.gz)$|^SHA256SUMS' | sort > expected.txt + echo "Expected:"; cat expected.txt + echo "Uploaded:"; cat uploaded.txt + diff expected.txt uploaded.txt + + - name: Publish the release + run: | + gh release edit "$GITHUB_REF_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --draft=false --latest + + - name: Show the published release + run: gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" diff --git a/.github/workflows/skill-eval.yml b/.github/workflows/skill-eval.yml index 6329acf..bb9a8c2 100644 --- a/.github/workflows/skill-eval.yml +++ b/.github/workflows/skill-eval.yml @@ -11,6 +11,9 @@ on: pull_request: workflow_dispatch: +permissions: + contents: read + jobs: verify: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index acb6061..b7f4ff4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ dist/ reports/* !reports/.gitkeep +# CLI verification output and scratch space +.agent-skill-verification/ +tmp/ + # Logs *.log npm-debug.log* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b739775 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,72 @@ +# Changelog + +All notable changes to Agent Skill Verifier are documented here. The format is +based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the +project follows semantic versioning. + +## 0.1.0 — 2026-07-17 + +First downloadable release: the repository's verification harness is now a +real cross-platform CLI product, **agent-skill-verifier**. + +### Added + +- **`verify` command** — runs every evaluation case N times against a model + adapter, applies the four structural validators (schema, citations, + unsupported claims, tool calls), enforces the quality gate + (threshold + per-case floors + optional flaky-rate ceiling), and writes the + full report bundle. Non-interactive by design; safe for CI. +- **`validate` command** — static checks for skill contracts, evaluation + cases (duplicate ids, expected statuses, citation files), adapter names, + numeric ranges, and output-path safety. Executes no runs. +- **`replay` command** — schema-validated inspection of stored replay + artifacts (input, output, tool trace, validation verdict). No model call; + artifacts are never modified. +- **`report` command** — converts the canonical `summary.json` into + terminal, JSON, JUnit XML, or self-contained HTML without rerunning. +- **Canonical result schema 1.0.0** (`summary.json`) — versioned, + machine-readable, schema-validated before writing; unsupported metrics are + `null`, never fabricated. +- **Report bundle** — `summary.json`, `junit.xml`, `report.html` (fully + self-contained), `events.jsonl`, `metrics.json`, and one replay artifact + per run under `replays/`. +- **Stable exit codes** — 0 passed · 1 gate failed · 2 invalid input · + 3 adapter unavailable · 4 runtime failure · 5 timeout/cancelled · + 6 report/artifact failure. +- **Project configuration** — `skill-verification.yaml|yml|json` with + documented precedence (CLI flags → config file → defaults). Evaluation + cases may be YAML or JSON. +- **Standalone executables** for Windows x64, Linux x64, macOS x64, and + macOS arm64 built with the official Node.js Single Executable Application + mechanism (pinned Node 22.13.1), plus a portable Node bundle. No Node + installation or `node_modules` required for the standalone binaries. +- **Release engineering** — per-archive `release-manifest.json` with + per-file SHA-256, `SHA256SUMS.txt`/`SHA256SUMS.json`, archive content + validation, packaged-binary smoke tests, and a draft-first tag-driven + release workflow that verifies every expected asset before publishing. +- **CI** — lint, typecheck, 116 offline tests, build, and packaged-CLI smoke + test on every push/PR with `contents: read` permissions. + +### Changed + +- Path resolution is workspace-rooted (the CLI pins it to the current working + directory), making the harness usable outside this repository. +- The repository README now documents the CLI product; the original + template/tutorial documentation moved to `docs/reference-implementation.md` + unchanged in substance. + +### Security + +- Output confined to the working directory; sanitized replay file names; + HTML/XML escaping for hostile skill content; secrets only via environment + variables; release workflow uses least-privilege tokens and never + overwrites releases or assets. See `docs/threat-model.md`. + +### Known limitations + +- Binaries are **not code-signed**; OS trust prompts are expected on first + run. Checksums verify integrity, not publisher identity. +- `replay` inspects recorded artifacts; deterministic model re-execution is + not claimed. +- Live-adapter (`llm`) verification is non-deterministic by nature; mock + latency/token/cost metrics are labeled estimates. diff --git a/README.md b/README.md index 90f12ce..4738d18 100644 --- a/README.md +++ b/README.md @@ -1,836 +1,339 @@ -# Agent Skill Verification Template +# Agent Skill Verifier -> A production-oriented template for building observable, replayable, and -> verification-gated AI agent skills. +> A model-independent quality gate for AI agent skills. +Verify agent skills through repeatable eval runs, replayable artifacts, +structured reports, and CI-friendly exit codes. + +[![ci](https://github.com/HelloThisWorld/agent-skill-verification-template/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/HelloThisWorld/agent-skill-verification-template/actions/workflows/ci.yml) [![skill-eval](https://github.com/HelloThisWorld/agent-skill-verification-template/actions/workflows/skill-eval.yml/badge.svg?branch=main)](https://github.com/HelloThisWorld/agent-skill-verification-template/actions/workflows/skill-eval.yml) -![status](https://img.shields.io/badge/status-MVP-blue) -![offline](https://img.shields.io/badge/default%20demo-offline-success) -![language](https://img.shields.io/badge/TypeScript-Node.js-informational) ![license](https://img.shields.io/badge/license-MIT-green) -Related projects: -- [Open Mind](https://github.com/HelloThisWorld/open-mind): generates source-traceable codebase artifacts -- [open-mind-mcp-server](https://github.com/HelloThisWorld/open-mind-mcp-server): exposes those artifacts as MCP tools for agents -- [agent-skill-verification-template](https://github.com/HelloThisWorld/agent-skill-verification-template): tests agent skills/tools with evals, metrics, traces, and replay artifacts - -Most agent skills are evaluated like black boxes: run the prompt, eyeball the -final answer, and hope it behaves consistently next time. This template treats an -agent skill as a **production software component** — something you test repeatedly, -validate structurally, trace, measure, replay on failure, and gate before release. - -The default demo runs **fully offline** with a deterministic mock model. No API -keys, no network, no paid services. - -> **Building your own skill?** Follow the -> [step-by-step tutorial](#tutorial-build-and-verify-a-new-skill-from-scratch) -> below — it walks a second skill (**`glossary`**: Wikipedia lookups rendered as -> web pages) from an empty folder to a `PASSED` verification report, one -> checkpoint at a time. - -

- Agent Skill Verification report showing a 100% pass rate, all validators green, across 7 test cases -

- ---- - -## The problem - -An agent skill should not ship just because it worked once in a demo. -Final-output inspection is not enough. Reliable skills need: - -- **repeated evaluation** (behavior varies run to run), -- **structured validation** (schema, source-grounding, tool usage — not vibes), -- **traces and metrics** (so you can debug and track regressions), -- **replay artifacts** (so a failure is reproducible, not a mystery), -- **quality gates** (so regressions fail the build, not production). - -## What this repo demonstrates - -- A Claude-style **skill structure** (`skills/codebase-understanding/`, `skills/glossary/`) -- A machine-readable **skill contract** (input/output/tool/citation rules) -- A **from-scratch tutorial**: build and verify a second skill (`glossary`) end to end -- A **model adapter** abstraction (mock, flaky, stub — and a **live** OpenAI-compatible adapter) -- An **eval harness** that runs each case N times -- **Source-grounding validation** (every claim must cite `file:line`) -- **Structured logs** (JSONL), **metrics** (Prometheus text), and **trace-like spans** -- **Replay artifacts** for every failed run -- A polished, self-contained **static HTML report** -- An optional **OpenTelemetry / Prometheus / Grafana** stack (demo-level) -- A **CI quality gate** (GitHub Actions) - -Honesty note: features that are stubs or demo-level are labeled as such here and -in `docs/`. Nothing in this README is overclaimed. - ---- - -## Quickstart - ```bash -npm install -npm run eval -# then open the generated report: -open reports/latest/report.html # macOS -# start reports/latest/report.html # Windows -# xdg-open reports/latest/report.html# Linux +agent-skill-verifier verify \ + --skill ./skills/calendar \ + --cases ./evals/calendar.yaml \ + --runs 10 \ + --threshold 0.90 ``` -To see failures, replay artifacts, and a failed gate in action: +One command runs every evaluation case N times against a model adapter, +validates each run structurally (schema, source-grounded citations, +unsupported claims, tool usage), and fails the build when the pass rate drops +below your threshold. The default adapters are fully offline — no API keys, no +network, no flaky external dependencies in CI. -```bash -npm run eval:flaky -``` +This repository is both the **CLI product** and a complete +**reference implementation**: the [reference guide](docs/reference-implementation.md) +walks through building an observable, replayable, verification-gated skill +from scratch. --- -## Tutorial: build and verify a new skill from scratch - -> A follow-along walkthrough. Every step names the exact file to create, the -> exact command to run, and a **checkpoint** telling you what you should see -> before moving on. The worked example is the **`glossary`** skill that ships in -> this repo: input `glossary ` (e.g. `glossary Mexico`) → look the term up -> on Wikipedia → output a source-grounded definition **rendered as a web page**, -> verified 10× per term and gated in CI. Every file mentioned below exists in -> the repo, so you can read along — or delete them and rebuild from scratch. +## What it verifies -You will fill in the template's pipeline left to right: +Every run of a skill is checked by four validators: -``` -Contract ─► Fixtures ─► Tools ─► Model Adapter ─► Test Cases ─► Eval ─► Report + Gate - (step 1) (step 2) (step 3) (step 4) (step 5) (step 6) (steps 7–9) -``` +| Validator | Checks | +|-----------|--------| +| `schema` | the output is structurally valid (status, answer, claims, tool calls) | +| `citation` | every claim cites a real file and line, and cited lines actually support the claim | +| `unsupported_claim` | no forbidden or ungrounded claims (hallucination guard) | +| `tool_call` | required tools were used, in the contract-declared order | -### Step 0 — install and confirm the baseline is green +A **quality gate** passes only when the overall pass rate clears your +threshold *and* every case clears its own floor (`minPassRate`), with an +optional flaky-case-rate ceiling. -```bash -npm install -npm run eval # runs the built-in codebase-understanding skill -``` +## Download -**Checkpoint** — the terminal ends with `Result: PASSED` (7 cases × 10 runs). -If it does not, fix your environment first (Node >= 18.18) before continuing. +Grab the [latest release](https://github.com/HelloThisWorld/agent-skill-verification-template/releases/latest): -### Step 1 — declare WHAT the skill must do (the contract) +| Platform | Asset | +|----------|-------| +| Windows x64 | `agent-skill-verifier-v-windows-x64.zip` | +| Linux x64 | `agent-skill-verifier-v-linux-x64.tar.gz` | +| macOS x64 (Intel) | `agent-skill-verifier-v-macos-x64.tar.gz` | +| macOS arm64 (Apple silicon) | `agent-skill-verifier-v-macos-arm64.tar.gz` | +| Any platform with Node.js ≥ 18.18 | `agent-skill-verifier-v-node.zip` (portable) | -Create the skill folder with four files: +Every release ships `SHA256SUMS.txt`. Checksums detect corrupted or tampered +downloads; they do not prove publisher identity. **Binaries are not +code-signed**, so Windows SmartScreen / macOS Gatekeeper may warn on first run. -``` -skills/glossary/ - skill-contract.json ← machine-readable contract (validated with zod on load) - SKILL.md ← human-readable description (Claude-style frontmatter) - verification-rules.md ← how outputs are graded, in prose - examples.md ← concrete input/output pairs -``` +### Windows -The contract is the heart of the template: it defines what a *correct answer* -looks like without saying anything about how a model produces one. The key -fields of [`skills/glossary/skill-contract.json`](skills/glossary/skill-contract.json): - -```jsonc -{ - "name": "glossary", - "input": { "fields": [{ "name": "question", "type": "string", "required": true }] }, - "output": { "statusValues": ["answered", "insufficient_evidence", "refused"], - "requires": ["status", "answer", "claims", "toolCalls"] }, - "tools": [ { "name": "wikipedia_search", "required": true }, - { "name": "wikipedia_fetch", "required": true } ], - "toolOrder": ["wikipedia_search", "wikipedia_fetch"], - "citationRequirement": "Every claim must cite {file, line}; when answered, the cited line must carry the queried term.", - "failureBehavior": "Unknown terms return insufficient_evidence with no claims. Never fabricate.", - "fixtureRoot": "fixtures/wikipedia" -} +```powershell +Expand-Archive agent-skill-verifier-v0.1.0-windows-x64.zip -DestinationPath agent-skill-verifier +cd agent-skill-verifier +Get-FileHash ..\agent-skill-verifier-v0.1.0-windows-x64.zip -Algorithm SHA256 # compare with SHA256SUMS.txt +.\agent-skill-verifier.exe --version ``` -| Field | What it controls | -| --- | --- | -| `input` / `output` | The I/O shape the **schema validator** enforces. | -| `tools` + `toolOrder` | Which tools must exist and their required order (**tool-call validator**). | -| `citationRequirement` | The grounding rule the **citation validator** enforces. | -| `unsupportedClaimPolicy` / `failureBehavior` | The honesty policy (**unsupported-claim validator**). | -| `fixtureRoot` | The only directory the skill's tools read; citations resolve against the repo root. | -| `promptVersion` / `toolSchemaVersion` | Version stamps recorded on every run for traceability. | - -**Checkpoint** — the contract loads and validates: +### Linux ```bash -npx tsx -e "import('./src/core/skill-contract.ts').then(({loadSkillContract}) => { const c = loadSkillContract('glossary'); console.log('contract OK:', c.name, c.version) })" -# contract OK: glossary 1.0.0 +curl -fsSLO https://github.com/HelloThisWorld/agent-skill-verification-template/releases/download/v0.1.0/agent-skill-verifier-v0.1.0-linux-x64.tar.gz +curl -fsSLO https://github.com/HelloThisWorld/agent-skill-verification-template/releases/download/v0.1.0/SHA256SUMS.txt +sha256sum -c --ignore-missing SHA256SUMS.txt +tar -xzf agent-skill-verifier-v0.1.0-linux-x64.tar.gz +./agent-skill-verifier --version ``` -### Step 2 — prepare the fixtures (the evidence the skill will cite) - -Skills in this template are **source-grounded**: every claim must cite a -`file:line` under the contract's `fixtureRoot`, and the validators re-read those -files on every run. For a Wikipedia skill that creates a tension — live pages -change and would break reproducibility — so `glossary` resolves it the way the -whole template works: **touch the network once, then verify offline forever.** - -[`scripts/build-glossary-cache.mjs`](scripts/build-glossary-cache.mjs) fetches -each term's article intro from English Wikipedia (MediaWiki action API) and -writes one citable snapshot per term: +### macOS ```bash -npm run glossary:build-cache -``` - -``` -ok Mexico -> Mexico (4623 chars) -ok South Africa -> South Africa (3690 chars) -ok Switzerland -> Switzerland (3864 chars) -... -Snapshots: 32/32 on disk (fetched 32, skipped 0). -``` - -Wikipedia rate-limits bursts (HTTP 429), so the script throttles, retries with -backoff, and **resumes** — re-running skips snapshots already on disk -(`--force` re-fetches everything). Each `fixtures/wikipedia/.html` embeds -a machine-readable `glossary-data` JSON block plus a `lede` line containing the -**exact query term verbatim**, so the citation the adapter produces is always a -supported one — even if Wikipedia's canonical title or opening phrasing ever -differs from the query term. - -**Checkpoint** — `fixtures/wikipedia/` holds 32 `.html` snapshots plus -`index.json`. **Commit them**: fixtures are inputs, not build products -(`reports/` is gitignored; `fixtures/` deliberately is not). - -### Step 3 — implement the tools - -Tools are the only way a skill touches the outside world. Each implements the -`Tool` interface from [`src/tools/tool-registry.ts`](src/tools/tool-registry.ts): - -```ts -export interface Tool { - name: string; - description: string; - execute(args: A, ctx: ToolContext): R; // ctx.fixtureRoot = the sandbox -} -``` - -The glossary skill gets two, mirroring `repo_search`/`read_file`: - -- [`wikipedia-search-tool.ts`](src/tools/wikipedia-search-tool.ts) — discovery. - Searches the snapshot cache and returns `{title, file, line, text}` matches - **ready to be used directly as citations**, best-matching article first. -- [`wikipedia-fetch-tool.ts`](src/tools/wikipedia-fetch-tool.ts) — confirmation. - Reads one snapshot and returns its structured article data plus `ledeLine`, - the 1-indexed citable line that carries the query term. - -Register them in the skill-aware factory in `tool-registry.ts`: - -```ts -export function createToolRegistry(skillName: string, fixtureRoot: string): ToolRegistry { - switch (skillName) { - case "glossary": - return createGlossaryToolRegistry(fixtureRoot); // wikipedia_search + wikipedia_fetch - default: - return createDefaultToolRegistry(fixtureRoot); // repo_search + read_file - } -} +curl -fsSLO https://github.com/HelloThisWorld/agent-skill-verification-template/releases/download/v0.1.0/agent-skill-verifier-v0.1.0-macos-x64.tar.gz # or -macos-arm64 +curl -fsSLO https://github.com/HelloThisWorld/agent-skill-verification-template/releases/download/v0.1.0/SHA256SUMS.txt +shasum -a 256 -c --ignore-missing SHA256SUMS.txt +tar -xzf agent-skill-verifier-v0.1.0-macos-x64.tar.gz +./agent-skill-verifier --version ``` -The registry records every invocation (order, arguments, timing, success), which -is what feeds the tool-call validator and the replay artifacts. +The binaries are ad-hoc signed at best; on first run macOS may require +*System Settings → Privacy & Security → Open Anyway*. -**Checkpoint** — invoke a tool directly: +### Portable (any platform with Node.js ≥ 18.18) ```bash -npx tsx -e "import('./src/tools/wikipedia-search-tool.ts').then(({wikipediaSearchTool}) => { const r = wikipediaSearchTool.execute({query:'Mexico'},{fixtureRoot:'fixtures/wikipedia'}); console.log(r.summary, '| top:', r.files[0]) })" -# 40 match(es) across 2 snapshot(s) for "Mexico" | top: fixtures/wikipedia/Mexico.html +unzip agent-skill-verifier-v0.1.0-node.zip -d agent-skill-verifier +node agent-skill-verifier/agent-skill-verifier.cjs --version +# or use the bundled launchers: agent-skill-verifier.cmd (Windows) / agent-skill-verifier (POSIX) ``` -(Two snapshots match because the United States article also mentions Mexico — -the ranking puts the exact-title article first, which is what the adapter uses.) - -### Step 4 — implement the model adapter (HOW, measured separately) - -An adapter is the only place that knows how a model is called. It implements -`ModelAdapter.generate(ctx) → SkillOutput` from -[`src/models/model-adapter.ts`](src/models/model-adapter.ts). One property keeps -the eval honest: **adapters receive only the question, the contract, and the -tools — never the test case's expected answer, required symbols, or forbidden -claims.** The model cannot peek at the grading key. - -[`src/models/glossary-adapter.ts`](src/models/glossary-adapter.ts) is the -offline, deterministic reference implementation: - -1. Parse the term out of `glossary `. -2. Call `wikipedia_search` with the term. -3. **No matching snapshot?** Return `insufficient_evidence` with zero claims. -4. Otherwise `wikipedia_fetch` the best match and build the answer. -5. Attach one claim citing `{file: , line: }` — recomputed - from the fixtures on every run, never hard-coded. +## CLI commands -Register it in `model-adapter.ts` (both the name list and the factory): - -```ts -export const SUPPORTED_MODELS = [ "mock", "mock-flaky", "glossary", "glossary-flaky", /* stubs */ ] as const; - -// in createAdapter(): -case "glossary": { - const { GlossaryAdapter } = await import("./glossary-adapter.js"); - return new GlossaryAdapter(); -} -``` - -Also build the **flaky twin** (`glossary-flaky`, same file). It produces the -correct output first, then deterministically perturbs it per run seed — dropped -citations, shifted line numbers, an invalid status, reversed tool order, an -invented uncited claim. You will use it in step 8 to prove the harness actually -catches bad outputs; a verifier you have never seen fail is not evidence. - -### Step 5 — write the test cases (the grading key) - -Happy-path cases live in [`testcases/glossary.json`](testcases/glossary.json) — -one per term, generated from the snapshot index so paths and symbols always -match the cache (`node scripts/gen-glossary-testcases.mjs`): - -```json -{ - "id": "gl_Mexico", - "input": { "question": "glossary Mexico" }, - "expectedStatus": "answered", - "requiredSymbols": ["Mexico"], - "forbiddenClaims": [], - "requiredTools": ["wikipedia_search", "wikipedia_fetch"], - "expectedCitationFiles": ["fixtures/wikipedia/Mexico.html"] -} +```text +agent-skill-verifier verify run the eval suite and write the report bundle +agent-skill-verifier validate statically check a skill + cases (no runs executed) +agent-skill-verifier replay inspect a stored replay artifact (no model call) +agent-skill-verifier report convert summary.json to terminal/json/junit/html +agent-skill-verifier --help usage +agent-skill-verifier --version version ``` -| Field | Meaning | -| --- | --- | -| `expectedStatus` | The correct status for this input. | -| `requiredSymbols` | Must appear verbatim on a cited line (here: the term itself). | -| `forbiddenClaims` | Substrings that must NOT appear — hallucination tripwires. | -| `requiredTools` | Tools that must show up in the recorded calls. | -| `expectedCitationFiles` | Files that must be cited when answered. | -| `minPassRate` | Optional per-case floor; defaults to the global threshold. | - -Negative cases live in -[`testcases/glossary-negative.json`](testcases/glossary-negative.json) — a -skill-specific `testcases/-negative.json` overrides the shared -`negative-cases.json`, so the glossary skill is not graded against codebase -questions. Fictional terms must be declined, not invented: - -```json -{ - "id": "gl_neg_wakanda", - "input": { "question": "glossary Wakanda" }, - "expectedStatus": "insufficient_evidence", - "forbiddenClaims": ["is a country", "capital", "borders"], - "requiredTools": ["wikipedia_search"] -} -``` - -### Step 6 — run the eval +### verify ```bash -npm run glossary # = tsx src/cli/run-glossary.ts -``` - -This is the standard harness (`runEval` from `src/core/eval-runner.ts`) plus a -skill-specific final stage that renders the web-page deliverable. Expected -output: - -``` -Running glossary eval — model=glossary runs=10 threshold=0.9 - -================= Glossary Eval Summary ================= - Skill: glossary v1.0.0 - Model: glossary (offline-deterministic) - Test cases: 34 - Runs per case: 10 - Total runs: 340 - Pass rate: 100.0% - Citation valid rate: 100.0% - Tool error rate: 0.0% - Result: PASSED - Report: reports/latest/report.html - Web pages: 32 pages in reports/latest/glossary/ (open index.html) -======================================================== -``` - -Reading it: 34 cases = 32 terms + 2 negatives; each ran 10× (repeated runs are -the point — a skill that works once is not verified). The gate passes only when -the overall pass rate clears `--threshold` **and** every case clears its own -floor. - -**Checkpoint** — `Result: PASSED`, exit code 0. - -### Step 7 — inspect what came out - -| File | What it is | -| --- | --- | -| `reports/latest/report.html` | Self-contained verification report (no server, no CDN). | -| `reports/latest/glossary/index.html` | **The deliverable** — glossary index, one tile per term. | -| `reports/latest/glossary/.html` | One rendered web page per term. | -| `reports/latest/summary.json` | Machine-readable source of truth for the run set. | -| `reports/latest/metrics.prom` | Prometheus-format metrics. | -| `reports/latest/structured-events.jsonl` | Structured event log (JSONL). | -| `reports/latest/replay-artifacts/` | One JSON per failed run (empty on a green run). | - -The verification report — all four validators green across 340 runs: - -

- Glossary verification report: PASSED, 100% pass rate across 340 runs, per-case table all green -

- -The web-page deliverable the skill was asked to produce — an index of all 32 -terms, each tile showing its own verification verdict: - -

- Rendered glossary index page listing 32 country term tiles, each with a PASSED badge and pass rate -

- -Each term page renders the grounded snapshot: definition, source link, and the -exact citation (`fixtures/wikipedia/Portugal.html:9`) the validators checked: - -

- Rendered term page for Portugal with flag, definition, fact sheet, PASSED badge, and the file:line citation -

- -### Step 8 — prove failures are caught - -A green report only means something if the same pipeline turns red on bad -output. That is what the flaky adapter is for: - -```bash -npm run glossary:flaky -``` - -``` - Pass rate: 43.2% - Result: FAILED +agent-skill-verifier verify \ + --skill ./fixtures/valid-skill \ + --cases ./fixtures/evals.yaml \ + --runs 10 \ + --threshold 0.90 \ + --output ./.agent-skill-verification ``` -

- Failing glossary report: 47.6% pass rate, FAILED gate, degraded validator rates, red per-case rows -

- -The failure breakdown maps every seeded perturbation back to the validator that -caught it — and produces one replay artifact per failed run (193 here), each -containing the exact input, output, tool trace, and validation verdict: - -| Seeded failure mode | Caught by | -| --- | --- | -| Citations stripped from claims | citation + unsupported-claim | -| Citation line shifted by +7 | citation (`citation_does_not_support_claim`) | -| Invalid status value (`"maybe"`) | schema | -| Tool calls reversed | tool-call (`tool_order_violation`) | -| Invented uncited claim appended | unsupported-claim | - -**Checkpoint** — `Result: FAILED`, a non-empty failure breakdown, and JSON files -under `reports/latest-flaky/replay-artifacts/`. +Key options: `--adapter ` (default `mock`), `--seed ` for +deterministic mock runs, `--timeout-ms ` overall budget, `--json` for +machine-readable output (never contains ANSI codes), `--quiet` / `--verbose`, +`--no-fail-on-threshold` to always exit 0 on completed runs, +`--non-interactive` (accepted for CI clarity — the CLI never prompts). -### Step 9 — lock it in (unit tests + CI gate) +All paths are resolved relative to the current working directory. The output +directory must stay inside it. -[`tests/glossary.test.ts`](tests/glossary.test.ts) pins the behaviors that must -never regress: term parsing, exact-article-first search ranking, the -multi-word-term citation rule (the lede must carry "Bosnia and Herzegovina" -verbatim), renderer output, a 100% eval pass with negatives declining, and the -flaky adapter's failure mix being deterministic. +### validate ```bash -npm run test # Test Files 4 passed · Tests 28 passed -npm run build # tsc type-checks the whole harness +agent-skill-verifier validate --skill ./fixtures/valid-skill --cases ./fixtures/evals.yaml ``` -The CI workflow (`.github/workflows/skill-eval.yml`) runs the eval and **fails -the build** when the gate fails — regressions stop here, not in production. -Tighten per-case floors with `minPassRate` on individual test cases as your -skill matures. +Checks the skill contract, evaluation-case schema, duplicate case ids, +expected citation files, adapter name, threshold/runs ranges, and output-path +safety — without executing a single evaluation run. -### Recap — what you created - -| Artifact | File(s) | Step | -| --- | --- | --- | -| Contract + docs | `skills/glossary/*` | 1 | -| Offline fixtures | `fixtures/wikipedia/*` (+ builder script) | 2 | -| Tools | `src/tools/wikipedia-*.ts` + registry entry | 3 | -| Adapters | `src/models/glossary-adapter.ts` + factory entry | 4 | -| Test cases | `testcases/glossary.json`, `testcases/glossary-negative.json` | 5 | -| CLI + deliverable | `src/cli/run-glossary.ts`, `src/skills/glossary/*` | 6–7 | -| Regression tests | `tests/glossary.test.ts` | 9 | - -The harness itself — eval loop, four validators, telemetry, reporting, replay, -gate — required **no changes** beyond registering the new skill's tools and -adapter. That is the template working as intended. - ---- - -## Example terminal output - -Terminal output of npm run eval showing a 100% pass rate and a PASSED result - -``` -Running eval — skill=codebase-understanding model=mock runs=10 threshold=0.9 - -==================== Eval Summary ==================== - Skill: codebase-understanding v1.0.0 - Model: mock (offline-deterministic) - Test cases: 7 - Runs per case: 10 - Total runs: 70 - Pass rate: 100.0% - Schema valid rate: 100.0% - Citation valid rate: 100.0% - Unsupported claim rate:0.0% - Tool error rate: 0.0% - P95 latency: 143 ms (estimated) - Result: PASSED - Report: reports/latest/report.html -====================================================== -``` - -The `mock-flaky` adapter instead produces a mixed pass rate, a `FAILED` result, -a failure breakdown, and one replay artifact per failed run. - -## Report - -The eval writes a single self-contained `report.html` (no server, no CDN). The -images here are rendered from real run data; open `reports/latest/report.html` -after a run to explore the live version, including a link to every replay artifact. - -The passing `npm run eval` report is shown near the top of this README. Running -`npm run eval:flaky` uses the `mock-flaky` adapter, which fails the release gate -and produces a per-reason failure breakdown: - -

- Failing report showing a 54.3% pass rate, a FAILED result, and a failure breakdown grouped by reason -

- ---- - -## Second skill: `glossary` (Wikipedia) - -To show the harness is not tied to one skill, the repo ships a second, fully -verified skill: **`glossary`**. Given `glossary ` it looks the term up on -Wikipedia and returns a **source-grounded definition rendered as a web page**. -It is built file by file in the -[tutorial above](#tutorial-build-and-verify-a-new-skill-from-scratch); this -section summarizes the design decisions. +### replay ```bash -npm run glossary:build-cache # once, with network: snapshot 32 terms into fixtures/wikipedia/ -npm run glossary # offline + deterministic: verify, then render the web pages -# open reports/latest/glossary/index.html (the deliverable) -# open reports/latest/report.html (the verification report) +agent-skill-verifier replay .agent-skill-verification/replays/case-001-run-01.json ``` -It reuses the **same** contract loader, eval loop, four validators, telemetry, -reporting, replay artifacts, and release gate as `codebase-understanding` — only -the skill contract, tools (`wikipedia_search` → `wikipedia_fetch`), and reference -adapter are new. The source-grounding model transfers directly: every claim must -cite the exact snapshot `file:line` that carries the queried term. - -Design choices worth noting: - -- **Offline-first, like the rest of the template.** The network is touched once, - by the cache builder; the eval and its report are then deterministic and run - with no network. Each snapshot embeds the article data plus a `lede` line that - contains the **exact query term verbatim**, so the citation the adapter - produces is always a supported one — even if Wikipedia's canonical title or - opening phrasing differs from the query term. -- **Contract vs. model.** `glossary` is the deterministic reference adapter; - `glossary-flaky` perturbs it to exercise every validator (schema, citation, - tool-order, unsupported-claim) and a failed gate — run `npm run glossary:flaky`. -- **One additive change to a shared validator:** the citation validator also - derives CJK bigrams, so the same grounding checks work for non-space-delimited - languages (point the cache builder at another Wikipedia language edition and - nothing else changes). ASCII-only text is unaffected, so - `codebase-understanding` behavior is unchanged. - -See [`skills/glossary/`](skills/glossary/) for the SKILL.md, contract, and rules. - ---- - -## Live model eval: running the same skill against a real model - -Everything above verifies the glossary skill with its **deterministic reference -adapter** — no language model is involved. The `llm` adapter runs the **same -contract, tools, test cases, validators, and gate against a real model** over -the OpenAI-compatible chat API. Whether a real model is used is just a -parameter: `--model glossary` (offline, deterministic) vs `--model llm` (live). - -### What each tier tests — and what its result means - -| | Deterministic tier (`--model glossary`) | Live tier (`--model llm`) | -| --- | --- | --- | -| What produces the answer | Reference adapter (rule-based code) driving the tools | A real LLM deciding which tools to call and writing the answer | -| What a **PASS** means | The harness, contract, fixtures, tools, and validators are internally correct and reproducible | The model actually honors the skill contract: calls the required tools in order, grounds every claim in a real `file:line`, declines unknown terms | -| What a **FAIL** means | A regression in the skill/harness code — always a bug to fix | A model reliability gap — data you use to fix prompts, pick models, or set thresholds | -| Determinism | 100% reproducible; same input → same output | Non-deterministic; run N times and gate on pass **rate** | -| Latency / tokens | Simulated (labeled `estimated`) | Real wall-clock and server-reported tokens (labeled `measured`) | -| Where it belongs | CI hard gate (100% expected) | Nightly / pre-release reliability measurement (threshold, e.g. 80–90%) | +Replay is **stored-artifact inspection**: the artifact already contains the +exact input, output, tool trace, and validation verdict of the recorded run, +so failures can be understood without invoking any model. The artifact is +schema-validated and never modified. (Deterministic model *re-execution* is +not claimed.) -The deterministic tier passing does **not** mean "a model will do this well" — -it means the measuring instrument is sound. The live tier is the measurement. - -### Results on this machine, side by side - -Deterministic reference adapter (`npm run eval:glossary`) — the measuring -instrument at 100%, as it must be: - -``` -Skill: glossary v1.0.0 · Model: glossary (offline-deterministic) -34 cases × 10 runs = 340 runs -Pass rate 100.0% · Schema 100.0% · Citation 100.0% · Tool errors 0.0% -P95 latency 174 ms (estimated) · Result: PASSED -``` - -

- Deterministic glossary eval report: PASSED, 100% pass rate across 340 runs -

- -Live model (`npm run eval:llm`) — gemma-4-26B-A4B (Q4_K_M, 15.8 GB) served by -llama.cpp on a Radeon RX 7900 XTX, grammar-constrained JSON, ctx 8192: - -``` -Skill: glossary v1.0.0 · Model: llm (openai-compatible-live) -34 cases × 1 run = 34 runs -Pass rate 97.1% · Schema 100.0% · Citation 97.1% · Tool errors 0.0% · Unsupported claims 0.0% -P95 latency 32 728 ms (measured) · Tokens 181 746 in / 42 978 out (server-reported) -Result: FAILED — test case "gl_Croatia" below its 80% floor -``` - -

- Live model glossary eval report: 97.1% pass rate, one citation failure, measured latency -

- -Reading the live result: the model followed the tool contract in every run -(both required tools, correct order), declined both fictional terms instead of -fabricating, and grounded 33/34 answers. The one failure is the interesting -part — for Croatia the model **invented plausible-looking line numbers** -(`Croatia.html:11` is an HTML `
` tag that says nothing about islands) -instead of copying the citable line from the tool result, and the citation -validator caught it: `citation_does_not_support_claim`. That is precisely the -failure class this harness exists to detect, and why the live tier gates on a -pass-rate threshold instead of expecting 100%. - -Getting here was itself a demonstration of the pipeline: the first live run -scored **5.9%** — replay artifacts showed 30× `required_tool_not_called` -(the adapter's prompt never told the model which tools the contract requires; -fixed by rendering the contract's required tools + order into the system -prompt) and the second run scored **82.3%** with 6 runs looping until -`LLM_MAX_ROUNDS` (grammar-constrained replies were truncated at `max_tokens`; -fixed by raising the default and feeding "shorten your reply" back on -`finish_reason: length`). Every diagnosis came straight from -`replay-artifacts/` and `structured-events.jsonl`, not guesswork. - -### How to run it - -Any OpenAI-compatible server works. **Nothing is hardcoded** — endpoint, model, -and limits all come from env vars (or the `--llm-*` CLI flags): - -| Env var | Default | Meaning | -| --- | --- | --- | -| `LLM_BASE_URL` | `http://127.0.0.1:8080/v1` | OpenAI-compatible base URL. | -| `LLM_MODEL` | *(empty)* | Model name/tag. Optional for llama.cpp; required for Ollama / remote. | -| `LLM_API_KEY` | *(empty)* | Bearer token for remote APIs. | -| `LLM_JSON_MODE` | `schema` | `schema` (grammar-constrained, llama.cpp) \| `object` \| `off`. Auto-downgrades on HTTP 400. | -| `LLM_MAX_ROUNDS` | `8` | Max model turns per run. | -| `LLM_MAX_TOKENS` | `2048` | Generation cap per turn. | -| `LLM_TIMEOUT_MS` | `180000` | Hard per-request timeout. | -| `LLM_TEMPERATURE` | `0` | Sampling temperature. | - -**Local llama.cpp** (what produced the numbers above): - -```powershell -$env:LLM_SERVER_EXE = "D:\path\to\llama-server.exe" -$env:LLM_MODEL_PATH = "D:\path\to\model.gguf" -.\scripts\start-eval-llm.ps1 # starts ONE server: ctx 8192, --parallel 1, 127.0.0.1 only -npm run eval:llm -.\scripts\stop-eval-llm.ps1 -``` - -**Local Ollama**: - -```bash -LLM_BASE_URL=http://127.0.0.1:11434/v1 LLM_MODEL=gemma3:27b npm run eval:llm -``` - -**Remote OpenAI-compatible API** (the same adapter — just point it elsewhere): +### report ```bash -LLM_BASE_URL=https://api.example.com/v1 LLM_MODEL=some-model LLM_API_KEY=sk-... npm run eval:llm +agent-skill-verifier report \ + --input .agent-skill-verification/summary.json \ + --format html \ + --output report.html ``` -### Resource safety (local runs) - -The live tier is designed not to exhaust the host machine: +Converts the canonical result into `terminal`, `json`, `junit`, or `html` +without rerunning anything. -- the eval runner sends **one request at a time**; the start script pins - `--parallel 1`, a small **8K context** (small KV cache — the main VRAM - guard), a bounded thread count, and binds to `127.0.0.1` only; -- every request has a **hard timeout**, every run a **round cap** and a - **generation cap** — a wedged server fails one run instead of hanging the - eval or pinning the GPU indefinitely; -- the start script refuses to launch when free RAM is critically low, and - `LLM_NGL` lets you trade GPU offload for stability on flaky drivers; -- run **one** model instance during an eval (do not co-load a second model). +## Configuration ---- +Place a `skill-verification.yaml` (or `.yml` / `.json`) in your project root, +or point at one with `--config`: -## Architecture +```yaml +schemaVersion: "1.0.0" -``` -Skill Contract ─► Model Adapter ─► Eval Harness ─► Validators ─► Telemetry ─► Report ─► CI Gate - (what) (how) (run N×) (schema, (logs, (html, (fail - citation, spans, json, build - claims, metrics) prom) below - tools) threshold) -``` +skill: + path: ./fixtures/valid-skill -| Layer | Location | Responsibility | -| --- | --- | --- | -| Skill contract | `skills/`, `src/core/skill-contract.ts` | What the skill must do (model-independent). | -| Model adapter | `src/models/` | How a model is called (mock / flaky / live `llm` / stubs). | -| Tools | `src/tools/` | `repo_search`, `read_file`, recording registry. | -| Eval harness | `src/core/eval-runner.ts` | Run each case N×, orchestrate everything. | -| Validators | `src/validators/` | Schema, citation, unsupported-claim, tool-call. | -| Telemetry | `src/telemetry/` | Structured logs, trace-like spans, metrics. | -| Reporting | `src/reporting/`, `src/artifacts/` | summary.json, report.html, metrics.prom, replays. | -| CI gate | `.github/workflows/skill-eval.yml` | Fail the build below threshold. | - -## Skill contract vs. model execution - -This is the core idea, so it is worth stating plainly: - -- The **skill contract is model-independent**. It describes what a correct answer - looks like: the output schema, the citation requirement, the unsupported-claim - policy, and the tool contract. -- The **reliability profile is model-dependent and must be measured**. Different - models (or model versions, prompts, or tool schemas) will have different pass - rates, latencies, costs, and failure patterns *against the same contract*. - -That separation is why the model name is a first-class dimension on every metric, -log line, and report. See `docs/model-adapters.md`. - -## Verification model - -Every run is graded by four validators (all must pass): - -1. **Schema** — output matches the required JSON structure. -2. **Citation** — each cited `file:line` exists and supports its claim; required - symbols and files are cited. (Keyword-based for the MVP; semantic validation is - on the roadmap.) -3. **Unsupported claim** — no forbidden/hallucinated claims; the model returns - `insufficient_evidence` instead of inventing answers; answered claims are cited. -4. **Tool call** — required tools were called, in the contract's `toolOrder` - (`repo_search` before `read_file`; `wikipedia_search` before `wikipedia_fetch`). - -Plus: **negative cases** (must decline to answer), **repeated runs** (default 10 -per case), and **threshold gates** (overall + per-case). Details in -`docs/verification-pipeline.md` and `skills/codebase-understanding/verification-rules.md`. - -## Observability model - -Each run produces: - -- **Structured logs** — `reports/latest/structured-events.jsonl` (real). -- **Trace-like spans** — `skill.run` → tool selection/execution → output generation - → validations → final decision. OpenTelemetry-shaped JSON (demo telemetry; a - live OTLP exporter is a roadmap item). -- **Metrics** — `reports/latest/metrics.prom` and `summary.json`. Rates are exact; - token/cost/latency are estimated/demo values for the mock adapters and real - measured/server-reported values for the live `llm` adapter (`summary.json` - carries a `measurement` field saying which you are looking at). -- **Replay artifacts** — one JSON per failed run under `replay-artifacts/`. - -The **static report works by default**. The **Grafana stack in `observability/` -is optional** and **OpenTelemetry integration is demo-level** unless you implement -the exporter. See `docs/observability-model.md`. +evaluation: + cases: ./fixtures/evals.yaml + runs: 10 + threshold: 0.90 + seed: 12345 ---- +adapter: + name: mock -## CLI +output: + directory: .agent-skill-verification + formats: [json, junit, html, replay] + +qualityGate: + failOnThreshold: true + maximumFlakyRate: 0.05 +``` + +Precedence: **CLI flags → configuration file → built-in defaults**. +A copy of this example lives at [examples/skill-verification.yaml](examples/skill-verification.yaml). + +## Exit codes + +| Code | Meaning | +|------|---------| +| 0 | verification passed (or informational command succeeded) | +| 1 | verification completed but the quality gate failed | +| 2 | invalid CLI input, configuration, skill, or evaluation cases | +| 3 | adapter, provider, or model unavailable | +| 4 | verification runtime failure | +| 5 | timeout or cancellation | +| 6 | report or artifact failure | + +JSON mode (`--json`) always prints the normalized result document, pass or +fail, so CI can consume it regardless of the exit code. + +## Report formats + +`verify` writes a deterministic bundle (timestamps and measured latency aside): + +```text +.agent-skill-verification/ +├── summary.json canonical verification result (schemaVersion 1.0.0) +├── report.html self-contained HTML report (no external assets) +├── junit.xml JUnit XML for CI test-report ingestion +├── events.jsonl structured event log +├── metrics.json aggregate metrics document +└── replays/ one replay artifact per run: -run-.json +``` + +`summary.json` is the single source of truth; every other format can be +regenerated from it with `agent-skill-verifier report`. Metrics the verifier +cannot measure are `null` — never fabricated. With the offline mock adapters, +latency/token/cost figures are clearly labeled as estimated demo values. + +## CI example (GitHub Actions) + +Pin an exact version in CI — do not download `latest` in a reproducible +pipeline: + +```yaml +jobs: + verify-skill: + runs-on: ubuntu-latest + permissions: + contents: read + env: + ASV_VERSION: 0.1.0 + ASV_SHA256: "" + steps: + - uses: actions/checkout@v4 + + - name: Download a pinned agent-skill-verifier release + run: | + curl -fsSLO "https://github.com/HelloThisWorld/agent-skill-verification-template/releases/download/v${ASV_VERSION}/agent-skill-verifier-v${ASV_VERSION}-linux-x64.tar.gz" + echo "${ASV_SHA256} agent-skill-verifier-v${ASV_VERSION}-linux-x64.tar.gz" | sha256sum -c - + tar -xzf "agent-skill-verifier-v${ASV_VERSION}-linux-x64.tar.gz" + + - name: Validate the skill (no runs) + run: ./agent-skill-verifier validate --skill ./skills/my-skill --cases ./evals/my-skill.yaml + + - name: Verify the skill (offline mock adapter) + run: | + ./agent-skill-verifier verify \ + --skill ./skills/my-skill \ + --cases ./evals/my-skill.yaml \ + --runs 10 \ + --threshold 0.90 \ + --output verification + + - name: Upload verification reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: skill-verification-report + path: verification +``` + +For provider-backed verification, pass credentials through GitHub secrets as +environment variables (e.g. `LLM_BASE_URL`, `LLM_API_KEY`) — never as CLI +arguments and never committed. + +## Adapters + +| Adapter | Type | Use | +|---------|------|-----| +| `mock` | offline, deterministic | default; source-grounded answers from your fixture corpus | +| `mock-flaky` | offline, deterministic | demonstrates failure detection, flaky-case reporting, replay artifacts | +| `glossary`, `glossary-flaky`, `openmind`, `openmind-flaky` | offline | reference-implementation demo skills | +| `llm` | live, OpenAI-compatible | real-model verification; configure via `LLM_BASE_URL`, `LLM_MODEL`, `LLM_API_KEY`, `LLM_JSON_MODE`, `LLM_MAX_ROUNDS`, `LLM_TIMEOUT_MS` | +| `openai-stub`, `anthropic-stub`, `ollama-stub` | stubs | integration points for your own adapters | + +Secrets are read from environment variables only; they are never accepted as +CLI values, never written into reports, and never embedded in binaries. + +## Security + +- Output paths are confined to the working directory; replay file names are + sanitized; HTML and XML output is escaped (hostile skill/case content cannot + inject markup). +- Ordinary CI runs with `contents: read`; only the release job holds + `contents: write`. Releases are draft-first, never overwrite an existing + release or asset, and publish only after every expected asset is verified. +- See the [threat model](docs/threat-model.md) for the full analysis. + +## Reproducibility limitations + +- The `mock` adapters are fully deterministic under a fixed `--seed`; live + model adapters are inherently non-deterministic — expect pass-rate variance + and use thresholds rather than exact-match expectations. +- `replay` inspects recorded runs; it does not re-execute a model. +- Mock latency/token/cost metrics are labeled estimates, not measurements. + +## Development ```bash -npm run eval -- \ - --skill codebase-understanding \ - --model mock \ - --runs 10 \ - --threshold 0.9 \ - --output reports/latest -``` - -| Flag | Default | Meaning | -| --- | --- | --- | -| `--skill` | `codebase-understanding` | Skill to evaluate. | -| `--model` | `mock` | `mock` \| `mock-flaky` \| `glossary` \| `glossary-flaky` \| `openmind` \| `openmind-flaky` \| `llm` \| `openai-stub` \| `anthropic-stub` \| `ollama-stub`. | -| `--runs` | `10` | Runs per test case. | -| `--threshold` | `0.9` | Release-gate pass-rate threshold (0..1). | -| `--output` | `reports/latest` | Report output directory. | -| `--no-gate` | (off) | Do not exit non-zero when the gate fails. | -| `--llm-base-url` | env `LLM_BASE_URL` | Live adapter only: OpenAI-compatible base URL. | -| `--llm-model` | env `LLM_MODEL` | Live adapter only: model name/tag. | -| `--llm-json-mode` | env `LLM_JSON_MODE` | Live adapter only: `schema` \| `object` \| `off`. | -| `--llm-max-rounds` | env `LLM_MAX_ROUNDS` | Live adapter only: max model turns per run. | -| `--llm-timeout-ms` | env `LLM_TIMEOUT_MS` | Live adapter only: per-request timeout. | - -Whether a **real model** is involved is selected by `--model`: the offline -adapters (`mock`, `glossary`, `openmind`, …) never call one; `--model llm` -connects to a real model server (see -[Live model eval](#live-model-eval-running-the-same-skill-against-a-real-model)). -The remaining stub adapters do **not** call real APIs — they throw a clear, -documented error. The default demo uses the offline `mock` adapter. - -## npm scripts - -| Script | Description | -| --- | --- | -| `npm run build` | Type-check and compile with `tsc`. | -| `npm run test` | Run the vitest suite. | -| `npm run eval` | Run the eval with the **mock** adapter (offline). | -| `npm run eval:flaky` | Run with the **mock-flaky** adapter to demonstrate failures. | -| `npm run eval:glossary` | Glossary skill × deterministic reference adapter → `reports/glossary-deterministic`. | -| `npm run eval:llm` | Glossary skill × **live model** (`llm` adapter) → `reports/glossary-llm`. Needs a running model server. | -| `npm run glossary` | Run the **glossary** skill eval + render the web-page deliverable (offline). | -| `npm run glossary:flaky` | Run the glossary skill with the unstable adapter to demonstrate failures. | -| `npm run glossary:build-cache` | Fetch the Wikipedia snapshot fixtures once (network required). | -| `npm run clean` | Remove `dist/` and generated reports. | - -## Repository layout - -``` -skills/ Claude-style skills + machine-readable contracts -testcases/ Happy-path and negative test cases (shared or per-skill) -fixtures/ Sample repo + Wikipedia snapshots the skills answer from -scripts/ Fixture cache builder, test case generator, preview server -src/ - core/ Types, contract loader, eval runner, thresholds - models/ Model adapters (mock, flaky, glossary, stubs) - tools/ repo_search, read_file, wikipedia_search/fetch, registry - skills/ Skill-specific code (glossary snapshot parser + web renderer) - validators/ Schema, citation, unsupported-claim, tool-call - telemetry/ Logger, tracer, metrics - reporting/ summary.json, report.html, metrics.prom writers - artifacts/ Replay artifacts - cli/ run-eval and run-glossary entry points -observability/ Optional OTEL Collector / Prometheus / Grafana (demo) -docs/ Design docs (verification, observability, replay, adapters) -tests/ vitest suites +npm ci +npm run lint && npm run typecheck && npm test # 116 tests, fully offline +npm run cli -- verify --skill fixtures/valid-skill --cases fixtures/evals.yaml --runs 3 +npm run build:cli # dist/cli/agent-skill-verifier.cjs (bundled) +npm run build:standalone # dist/sea/agent-skill-verifier[.exe] via Node SEA +npm run package:release # dist/release/*.zip|tar.gz for this platform +npm run release:check && npm run release:smoke ``` -## Requirements - -- Node.js >= 18.18 (developed on Node 22). -- No API keys and no network for the default demo. - -## Roadmap +The reference implementation (skills, contracts, validators, observability, +tutorial) is documented in [docs/reference-implementation.md](docs/reference-implementation.md). -Clearly future work, not implemented today: +## Release process -- Real OpenTelemetry OTLP exporter (replace demo span JSON). -- Provider-native adapters (e.g. Anthropic Messages API with native tool use) — - the OpenAI-compatible **live adapter is implemented** (`--model llm`, covering - llama.cpp / Ollama / OpenAI-compatible remote APIs); the remaining stubs mark - the provider-native variants. -- MCP server integration for tools. -- Claude Skill packaging examples. -- Richer **semantic** citation validation (beyond keyword matching). -- Committed dashboard screenshots and a model comparison matrix. -- A small web UI for browsing runs and artifacts. +Releases are built by [.github/workflows/release.yml](.github/workflows/release.yml) +on native GitHub runners with a pinned Node version, using the official +Node.js Single Executable Application mechanism. Draft-first publication: +assets and checksums are verified before the release goes live. See +[docs/release-process.md](docs/release-process.md) — including how to run the +manual build-only dry run before tagging. ## License -MIT — see [LICENSE](LICENSE). +[MIT](LICENSE) diff --git a/docs/reference-implementation.md b/docs/reference-implementation.md new file mode 100644 index 0000000..e70848f --- /dev/null +++ b/docs/reference-implementation.md @@ -0,0 +1,836 @@ +# Agent Skill Verification Template + +> A production-oriented template for building observable, replayable, and +> verification-gated AI agent skills. + +[![skill-eval](https://github.com/HelloThisWorld/agent-skill-verification-template/actions/workflows/skill-eval.yml/badge.svg?branch=main)](https://github.com/HelloThisWorld/agent-skill-verification-template/actions/workflows/skill-eval.yml) +![status](https://img.shields.io/badge/status-MVP-blue) +![offline](https://img.shields.io/badge/default%20demo-offline-success) +![language](https://img.shields.io/badge/TypeScript-Node.js-informational) +![license](https://img.shields.io/badge/license-MIT-green) + +Related projects: +- [Open Mind](https://github.com/HelloThisWorld/open-mind): generates source-traceable codebase artifacts +- [open-mind-mcp-server](https://github.com/HelloThisWorld/open-mind-mcp-server): exposes those artifacts as MCP tools for agents +- [agent-skill-verification-template](https://github.com/HelloThisWorld/agent-skill-verification-template): tests agent skills/tools with evals, metrics, traces, and replay artifacts + +Most agent skills are evaluated like black boxes: run the prompt, eyeball the +final answer, and hope it behaves consistently next time. This template treats an +agent skill as a **production software component** — something you test repeatedly, +validate structurally, trace, measure, replay on failure, and gate before release. + +The default demo runs **fully offline** with a deterministic mock model. No API +keys, no network, no paid services. + +> **Building your own skill?** Follow the +> [step-by-step tutorial](#tutorial-build-and-verify-a-new-skill-from-scratch) +> below — it walks a second skill (**`glossary`**: Wikipedia lookups rendered as +> web pages) from an empty folder to a `PASSED` verification report, one +> checkpoint at a time. + +

+ Agent Skill Verification report showing a 100% pass rate, all validators green, across 7 test cases +

+ +--- + +## The problem + +An agent skill should not ship just because it worked once in a demo. +Final-output inspection is not enough. Reliable skills need: + +- **repeated evaluation** (behavior varies run to run), +- **structured validation** (schema, source-grounding, tool usage — not vibes), +- **traces and metrics** (so you can debug and track regressions), +- **replay artifacts** (so a failure is reproducible, not a mystery), +- **quality gates** (so regressions fail the build, not production). + +## What this repo demonstrates + +- A Claude-style **skill structure** (`skills/codebase-understanding/`, `skills/glossary/`) +- A machine-readable **skill contract** (input/output/tool/citation rules) +- A **from-scratch tutorial**: build and verify a second skill (`glossary`) end to end +- A **model adapter** abstraction (mock, flaky, stub — and a **live** OpenAI-compatible adapter) +- An **eval harness** that runs each case N times +- **Source-grounding validation** (every claim must cite `file:line`) +- **Structured logs** (JSONL), **metrics** (Prometheus text), and **trace-like spans** +- **Replay artifacts** for every failed run +- A polished, self-contained **static HTML report** +- An optional **OpenTelemetry / Prometheus / Grafana** stack (demo-level) +- A **CI quality gate** (GitHub Actions) + +Honesty note: features that are stubs or demo-level are labeled as such here and +in `docs/`. Nothing in this README is overclaimed. + +--- + +## Quickstart + +```bash +npm install +npm run eval +# then open the generated report: +open reports/latest/report.html # macOS +# start reports/latest/report.html # Windows +# xdg-open reports/latest/report.html# Linux +``` + +To see failures, replay artifacts, and a failed gate in action: + +```bash +npm run eval:flaky +``` + +--- + +## Tutorial: build and verify a new skill from scratch + +> A follow-along walkthrough. Every step names the exact file to create, the +> exact command to run, and a **checkpoint** telling you what you should see +> before moving on. The worked example is the **`glossary`** skill that ships in +> this repo: input `glossary ` (e.g. `glossary Mexico`) → look the term up +> on Wikipedia → output a source-grounded definition **rendered as a web page**, +> verified 10× per term and gated in CI. Every file mentioned below exists in +> the repo, so you can read along — or delete them and rebuild from scratch. + +You will fill in the template's pipeline left to right: + +``` +Contract ─► Fixtures ─► Tools ─► Model Adapter ─► Test Cases ─► Eval ─► Report + Gate + (step 1) (step 2) (step 3) (step 4) (step 5) (step 6) (steps 7–9) +``` + +### Step 0 — install and confirm the baseline is green + +```bash +npm install +npm run eval # runs the built-in codebase-understanding skill +``` + +**Checkpoint** — the terminal ends with `Result: PASSED` (7 cases × 10 runs). +If it does not, fix your environment first (Node >= 18.18) before continuing. + +### Step 1 — declare WHAT the skill must do (the contract) + +Create the skill folder with four files: + +``` +skills/glossary/ + skill-contract.json ← machine-readable contract (validated with zod on load) + SKILL.md ← human-readable description (Claude-style frontmatter) + verification-rules.md ← how outputs are graded, in prose + examples.md ← concrete input/output pairs +``` + +The contract is the heart of the template: it defines what a *correct answer* +looks like without saying anything about how a model produces one. The key +fields of [`skills/glossary/skill-contract.json`](skills/glossary/skill-contract.json): + +```jsonc +{ + "name": "glossary", + "input": { "fields": [{ "name": "question", "type": "string", "required": true }] }, + "output": { "statusValues": ["answered", "insufficient_evidence", "refused"], + "requires": ["status", "answer", "claims", "toolCalls"] }, + "tools": [ { "name": "wikipedia_search", "required": true }, + { "name": "wikipedia_fetch", "required": true } ], + "toolOrder": ["wikipedia_search", "wikipedia_fetch"], + "citationRequirement": "Every claim must cite {file, line}; when answered, the cited line must carry the queried term.", + "failureBehavior": "Unknown terms return insufficient_evidence with no claims. Never fabricate.", + "fixtureRoot": "fixtures/wikipedia" +} +``` + +| Field | What it controls | +| --- | --- | +| `input` / `output` | The I/O shape the **schema validator** enforces. | +| `tools` + `toolOrder` | Which tools must exist and their required order (**tool-call validator**). | +| `citationRequirement` | The grounding rule the **citation validator** enforces. | +| `unsupportedClaimPolicy` / `failureBehavior` | The honesty policy (**unsupported-claim validator**). | +| `fixtureRoot` | The only directory the skill's tools read; citations resolve against the repo root. | +| `promptVersion` / `toolSchemaVersion` | Version stamps recorded on every run for traceability. | + +**Checkpoint** — the contract loads and validates: + +```bash +npx tsx -e "import('./src/core/skill-contract.ts').then(({loadSkillContract}) => { const c = loadSkillContract('glossary'); console.log('contract OK:', c.name, c.version) })" +# contract OK: glossary 1.0.0 +``` + +### Step 2 — prepare the fixtures (the evidence the skill will cite) + +Skills in this template are **source-grounded**: every claim must cite a +`file:line` under the contract's `fixtureRoot`, and the validators re-read those +files on every run. For a Wikipedia skill that creates a tension — live pages +change and would break reproducibility — so `glossary` resolves it the way the +whole template works: **touch the network once, then verify offline forever.** + +[`scripts/build-glossary-cache.mjs`](scripts/build-glossary-cache.mjs) fetches +each term's article intro from English Wikipedia (MediaWiki action API) and +writes one citable snapshot per term: + +```bash +npm run glossary:build-cache +``` + +``` +ok Mexico -> Mexico (4623 chars) +ok South Africa -> South Africa (3690 chars) +ok Switzerland -> Switzerland (3864 chars) +... +Snapshots: 32/32 on disk (fetched 32, skipped 0). +``` + +Wikipedia rate-limits bursts (HTTP 429), so the script throttles, retries with +backoff, and **resumes** — re-running skips snapshots already on disk +(`--force` re-fetches everything). Each `fixtures/wikipedia/.html` embeds +a machine-readable `glossary-data` JSON block plus a `lede` line containing the +**exact query term verbatim**, so the citation the adapter produces is always a +supported one — even if Wikipedia's canonical title or opening phrasing ever +differs from the query term. + +**Checkpoint** — `fixtures/wikipedia/` holds 32 `.html` snapshots plus +`index.json`. **Commit them**: fixtures are inputs, not build products +(`reports/` is gitignored; `fixtures/` deliberately is not). + +### Step 3 — implement the tools + +Tools are the only way a skill touches the outside world. Each implements the +`Tool` interface from [`src/tools/tool-registry.ts`](src/tools/tool-registry.ts): + +```ts +export interface Tool { + name: string; + description: string; + execute(args: A, ctx: ToolContext): R; // ctx.fixtureRoot = the sandbox +} +``` + +The glossary skill gets two, mirroring `repo_search`/`read_file`: + +- [`wikipedia-search-tool.ts`](src/tools/wikipedia-search-tool.ts) — discovery. + Searches the snapshot cache and returns `{title, file, line, text}` matches + **ready to be used directly as citations**, best-matching article first. +- [`wikipedia-fetch-tool.ts`](src/tools/wikipedia-fetch-tool.ts) — confirmation. + Reads one snapshot and returns its structured article data plus `ledeLine`, + the 1-indexed citable line that carries the query term. + +Register them in the skill-aware factory in `tool-registry.ts`: + +```ts +export function createToolRegistry(skillName: string, fixtureRoot: string): ToolRegistry { + switch (skillName) { + case "glossary": + return createGlossaryToolRegistry(fixtureRoot); // wikipedia_search + wikipedia_fetch + default: + return createDefaultToolRegistry(fixtureRoot); // repo_search + read_file + } +} +``` + +The registry records every invocation (order, arguments, timing, success), which +is what feeds the tool-call validator and the replay artifacts. + +**Checkpoint** — invoke a tool directly: + +```bash +npx tsx -e "import('./src/tools/wikipedia-search-tool.ts').then(({wikipediaSearchTool}) => { const r = wikipediaSearchTool.execute({query:'Mexico'},{fixtureRoot:'fixtures/wikipedia'}); console.log(r.summary, '| top:', r.files[0]) })" +# 40 match(es) across 2 snapshot(s) for "Mexico" | top: fixtures/wikipedia/Mexico.html +``` + +(Two snapshots match because the United States article also mentions Mexico — +the ranking puts the exact-title article first, which is what the adapter uses.) + +### Step 4 — implement the model adapter (HOW, measured separately) + +An adapter is the only place that knows how a model is called. It implements +`ModelAdapter.generate(ctx) → SkillOutput` from +[`src/models/model-adapter.ts`](src/models/model-adapter.ts). One property keeps +the eval honest: **adapters receive only the question, the contract, and the +tools — never the test case's expected answer, required symbols, or forbidden +claims.** The model cannot peek at the grading key. + +[`src/models/glossary-adapter.ts`](src/models/glossary-adapter.ts) is the +offline, deterministic reference implementation: + +1. Parse the term out of `glossary `. +2. Call `wikipedia_search` with the term. +3. **No matching snapshot?** Return `insufficient_evidence` with zero claims. +4. Otherwise `wikipedia_fetch` the best match and build the answer. +5. Attach one claim citing `{file: , line: }` — recomputed + from the fixtures on every run, never hard-coded. + +Register it in `model-adapter.ts` (both the name list and the factory): + +```ts +export const SUPPORTED_MODELS = [ "mock", "mock-flaky", "glossary", "glossary-flaky", /* stubs */ ] as const; + +// in createAdapter(): +case "glossary": { + const { GlossaryAdapter } = await import("./glossary-adapter.js"); + return new GlossaryAdapter(); +} +``` + +Also build the **flaky twin** (`glossary-flaky`, same file). It produces the +correct output first, then deterministically perturbs it per run seed — dropped +citations, shifted line numbers, an invalid status, reversed tool order, an +invented uncited claim. You will use it in step 8 to prove the harness actually +catches bad outputs; a verifier you have never seen fail is not evidence. + +### Step 5 — write the test cases (the grading key) + +Happy-path cases live in [`testcases/glossary.json`](testcases/glossary.json) — +one per term, generated from the snapshot index so paths and symbols always +match the cache (`node scripts/gen-glossary-testcases.mjs`): + +```json +{ + "id": "gl_Mexico", + "input": { "question": "glossary Mexico" }, + "expectedStatus": "answered", + "requiredSymbols": ["Mexico"], + "forbiddenClaims": [], + "requiredTools": ["wikipedia_search", "wikipedia_fetch"], + "expectedCitationFiles": ["fixtures/wikipedia/Mexico.html"] +} +``` + +| Field | Meaning | +| --- | --- | +| `expectedStatus` | The correct status for this input. | +| `requiredSymbols` | Must appear verbatim on a cited line (here: the term itself). | +| `forbiddenClaims` | Substrings that must NOT appear — hallucination tripwires. | +| `requiredTools` | Tools that must show up in the recorded calls. | +| `expectedCitationFiles` | Files that must be cited when answered. | +| `minPassRate` | Optional per-case floor; defaults to the global threshold. | + +Negative cases live in +[`testcases/glossary-negative.json`](testcases/glossary-negative.json) — a +skill-specific `testcases/-negative.json` overrides the shared +`negative-cases.json`, so the glossary skill is not graded against codebase +questions. Fictional terms must be declined, not invented: + +```json +{ + "id": "gl_neg_wakanda", + "input": { "question": "glossary Wakanda" }, + "expectedStatus": "insufficient_evidence", + "forbiddenClaims": ["is a country", "capital", "borders"], + "requiredTools": ["wikipedia_search"] +} +``` + +### Step 6 — run the eval + +```bash +npm run glossary # = tsx src/cli/run-glossary.ts +``` + +This is the standard harness (`runEval` from `src/core/eval-runner.ts`) plus a +skill-specific final stage that renders the web-page deliverable. Expected +output: + +``` +Running glossary eval — model=glossary runs=10 threshold=0.9 + +================= Glossary Eval Summary ================= + Skill: glossary v1.0.0 + Model: glossary (offline-deterministic) + Test cases: 34 + Runs per case: 10 + Total runs: 340 + Pass rate: 100.0% + Citation valid rate: 100.0% + Tool error rate: 0.0% + Result: PASSED + Report: reports/latest/report.html + Web pages: 32 pages in reports/latest/glossary/ (open index.html) +======================================================== +``` + +Reading it: 34 cases = 32 terms + 2 negatives; each ran 10× (repeated runs are +the point — a skill that works once is not verified). The gate passes only when +the overall pass rate clears `--threshold` **and** every case clears its own +floor. + +**Checkpoint** — `Result: PASSED`, exit code 0. + +### Step 7 — inspect what came out + +| File | What it is | +| --- | --- | +| `reports/latest/report.html` | Self-contained verification report (no server, no CDN). | +| `reports/latest/glossary/index.html` | **The deliverable** — glossary index, one tile per term. | +| `reports/latest/glossary/.html` | One rendered web page per term. | +| `reports/latest/summary.json` | Machine-readable source of truth for the run set. | +| `reports/latest/metrics.prom` | Prometheus-format metrics. | +| `reports/latest/structured-events.jsonl` | Structured event log (JSONL). | +| `reports/latest/replay-artifacts/` | One JSON per failed run (empty on a green run). | + +The verification report — all four validators green across 340 runs: + +

+ Glossary verification report: PASSED, 100% pass rate across 340 runs, per-case table all green +

+ +The web-page deliverable the skill was asked to produce — an index of all 32 +terms, each tile showing its own verification verdict: + +

+ Rendered glossary index page listing 32 country term tiles, each with a PASSED badge and pass rate +

+ +Each term page renders the grounded snapshot: definition, source link, and the +exact citation (`fixtures/wikipedia/Portugal.html:9`) the validators checked: + +

+ Rendered term page for Portugal with flag, definition, fact sheet, PASSED badge, and the file:line citation +

+ +### Step 8 — prove failures are caught + +A green report only means something if the same pipeline turns red on bad +output. That is what the flaky adapter is for: + +```bash +npm run glossary:flaky +``` + +``` + Pass rate: 43.2% + Result: FAILED +``` + +

+ Failing glossary report: 47.6% pass rate, FAILED gate, degraded validator rates, red per-case rows +

+ +The failure breakdown maps every seeded perturbation back to the validator that +caught it — and produces one replay artifact per failed run (193 here), each +containing the exact input, output, tool trace, and validation verdict: + +| Seeded failure mode | Caught by | +| --- | --- | +| Citations stripped from claims | citation + unsupported-claim | +| Citation line shifted by +7 | citation (`citation_does_not_support_claim`) | +| Invalid status value (`"maybe"`) | schema | +| Tool calls reversed | tool-call (`tool_order_violation`) | +| Invented uncited claim appended | unsupported-claim | + +**Checkpoint** — `Result: FAILED`, a non-empty failure breakdown, and JSON files +under `reports/latest-flaky/replay-artifacts/`. + +### Step 9 — lock it in (unit tests + CI gate) + +[`tests/glossary.test.ts`](tests/glossary.test.ts) pins the behaviors that must +never regress: term parsing, exact-article-first search ranking, the +multi-word-term citation rule (the lede must carry "Bosnia and Herzegovina" +verbatim), renderer output, a 100% eval pass with negatives declining, and the +flaky adapter's failure mix being deterministic. + +```bash +npm run test # Test Files 4 passed · Tests 28 passed +npm run build # tsc type-checks the whole harness +``` + +The CI workflow (`.github/workflows/skill-eval.yml`) runs the eval and **fails +the build** when the gate fails — regressions stop here, not in production. +Tighten per-case floors with `minPassRate` on individual test cases as your +skill matures. + +### Recap — what you created + +| Artifact | File(s) | Step | +| --- | --- | --- | +| Contract + docs | `skills/glossary/*` | 1 | +| Offline fixtures | `fixtures/wikipedia/*` (+ builder script) | 2 | +| Tools | `src/tools/wikipedia-*.ts` + registry entry | 3 | +| Adapters | `src/models/glossary-adapter.ts` + factory entry | 4 | +| Test cases | `testcases/glossary.json`, `testcases/glossary-negative.json` | 5 | +| CLI + deliverable | `src/cli/run-glossary.ts`, `src/skills/glossary/*` | 6–7 | +| Regression tests | `tests/glossary.test.ts` | 9 | + +The harness itself — eval loop, four validators, telemetry, reporting, replay, +gate — required **no changes** beyond registering the new skill's tools and +adapter. That is the template working as intended. + +--- + +## Example terminal output + +Terminal output of npm run eval showing a 100% pass rate and a PASSED result + +``` +Running eval — skill=codebase-understanding model=mock runs=10 threshold=0.9 + +==================== Eval Summary ==================== + Skill: codebase-understanding v1.0.0 + Model: mock (offline-deterministic) + Test cases: 7 + Runs per case: 10 + Total runs: 70 + Pass rate: 100.0% + Schema valid rate: 100.0% + Citation valid rate: 100.0% + Unsupported claim rate:0.0% + Tool error rate: 0.0% + P95 latency: 143 ms (estimated) + Result: PASSED + Report: reports/latest/report.html +====================================================== +``` + +The `mock-flaky` adapter instead produces a mixed pass rate, a `FAILED` result, +a failure breakdown, and one replay artifact per failed run. + +## Report + +The eval writes a single self-contained `report.html` (no server, no CDN). The +images here are rendered from real run data; open `reports/latest/report.html` +after a run to explore the live version, including a link to every replay artifact. + +The passing `npm run eval` report is shown near the top of this README. Running +`npm run eval:flaky` uses the `mock-flaky` adapter, which fails the release gate +and produces a per-reason failure breakdown: + +

+ Failing report showing a 54.3% pass rate, a FAILED result, and a failure breakdown grouped by reason +

+ +--- + +## Second skill: `glossary` (Wikipedia) + +To show the harness is not tied to one skill, the repo ships a second, fully +verified skill: **`glossary`**. Given `glossary ` it looks the term up on +Wikipedia and returns a **source-grounded definition rendered as a web page**. +It is built file by file in the +[tutorial above](#tutorial-build-and-verify-a-new-skill-from-scratch); this +section summarizes the design decisions. + +```bash +npm run glossary:build-cache # once, with network: snapshot 32 terms into fixtures/wikipedia/ +npm run glossary # offline + deterministic: verify, then render the web pages +# open reports/latest/glossary/index.html (the deliverable) +# open reports/latest/report.html (the verification report) +``` + +It reuses the **same** contract loader, eval loop, four validators, telemetry, +reporting, replay artifacts, and release gate as `codebase-understanding` — only +the skill contract, tools (`wikipedia_search` → `wikipedia_fetch`), and reference +adapter are new. The source-grounding model transfers directly: every claim must +cite the exact snapshot `file:line` that carries the queried term. + +Design choices worth noting: + +- **Offline-first, like the rest of the template.** The network is touched once, + by the cache builder; the eval and its report are then deterministic and run + with no network. Each snapshot embeds the article data plus a `lede` line that + contains the **exact query term verbatim**, so the citation the adapter + produces is always a supported one — even if Wikipedia's canonical title or + opening phrasing differs from the query term. +- **Contract vs. model.** `glossary` is the deterministic reference adapter; + `glossary-flaky` perturbs it to exercise every validator (schema, citation, + tool-order, unsupported-claim) and a failed gate — run `npm run glossary:flaky`. +- **One additive change to a shared validator:** the citation validator also + derives CJK bigrams, so the same grounding checks work for non-space-delimited + languages (point the cache builder at another Wikipedia language edition and + nothing else changes). ASCII-only text is unaffected, so + `codebase-understanding` behavior is unchanged. + +See [`skills/glossary/`](skills/glossary/) for the SKILL.md, contract, and rules. + +--- + +## Live model eval: running the same skill against a real model + +Everything above verifies the glossary skill with its **deterministic reference +adapter** — no language model is involved. The `llm` adapter runs the **same +contract, tools, test cases, validators, and gate against a real model** over +the OpenAI-compatible chat API. Whether a real model is used is just a +parameter: `--model glossary` (offline, deterministic) vs `--model llm` (live). + +### What each tier tests — and what its result means + +| | Deterministic tier (`--model glossary`) | Live tier (`--model llm`) | +| --- | --- | --- | +| What produces the answer | Reference adapter (rule-based code) driving the tools | A real LLM deciding which tools to call and writing the answer | +| What a **PASS** means | The harness, contract, fixtures, tools, and validators are internally correct and reproducible | The model actually honors the skill contract: calls the required tools in order, grounds every claim in a real `file:line`, declines unknown terms | +| What a **FAIL** means | A regression in the skill/harness code — always a bug to fix | A model reliability gap — data you use to fix prompts, pick models, or set thresholds | +| Determinism | 100% reproducible; same input → same output | Non-deterministic; run N times and gate on pass **rate** | +| Latency / tokens | Simulated (labeled `estimated`) | Real wall-clock and server-reported tokens (labeled `measured`) | +| Where it belongs | CI hard gate (100% expected) | Nightly / pre-release reliability measurement (threshold, e.g. 80–90%) | + +The deterministic tier passing does **not** mean "a model will do this well" — +it means the measuring instrument is sound. The live tier is the measurement. + +### Results on this machine, side by side + +Deterministic reference adapter (`npm run eval:glossary`) — the measuring +instrument at 100%, as it must be: + +``` +Skill: glossary v1.0.0 · Model: glossary (offline-deterministic) +34 cases × 10 runs = 340 runs +Pass rate 100.0% · Schema 100.0% · Citation 100.0% · Tool errors 0.0% +P95 latency 174 ms (estimated) · Result: PASSED +``` + +

+ Deterministic glossary eval report: PASSED, 100% pass rate across 340 runs +

+ +Live model (`npm run eval:llm`) — gemma-4-26B-A4B (Q4_K_M, 15.8 GB) served by +llama.cpp on a Radeon RX 7900 XTX, grammar-constrained JSON, ctx 8192: + +``` +Skill: glossary v1.0.0 · Model: llm (openai-compatible-live) +34 cases × 1 run = 34 runs +Pass rate 97.1% · Schema 100.0% · Citation 97.1% · Tool errors 0.0% · Unsupported claims 0.0% +P95 latency 32 728 ms (measured) · Tokens 181 746 in / 42 978 out (server-reported) +Result: FAILED — test case "gl_Croatia" below its 80% floor +``` + +

+ Live model glossary eval report: 97.1% pass rate, one citation failure, measured latency +

+ +Reading the live result: the model followed the tool contract in every run +(both required tools, correct order), declined both fictional terms instead of +fabricating, and grounded 33/34 answers. The one failure is the interesting +part — for Croatia the model **invented plausible-looking line numbers** +(`Croatia.html:11` is an HTML `
` tag that says nothing about islands) +instead of copying the citable line from the tool result, and the citation +validator caught it: `citation_does_not_support_claim`. That is precisely the +failure class this harness exists to detect, and why the live tier gates on a +pass-rate threshold instead of expecting 100%. + +Getting here was itself a demonstration of the pipeline: the first live run +scored **5.9%** — replay artifacts showed 30× `required_tool_not_called` +(the adapter's prompt never told the model which tools the contract requires; +fixed by rendering the contract's required tools + order into the system +prompt) and the second run scored **82.3%** with 6 runs looping until +`LLM_MAX_ROUNDS` (grammar-constrained replies were truncated at `max_tokens`; +fixed by raising the default and feeding "shorten your reply" back on +`finish_reason: length`). Every diagnosis came straight from +`replay-artifacts/` and `structured-events.jsonl`, not guesswork. + +### How to run it + +Any OpenAI-compatible server works. **Nothing is hardcoded** — endpoint, model, +and limits all come from env vars (or the `--llm-*` CLI flags): + +| Env var | Default | Meaning | +| --- | --- | --- | +| `LLM_BASE_URL` | `http://127.0.0.1:8080/v1` | OpenAI-compatible base URL. | +| `LLM_MODEL` | *(empty)* | Model name/tag. Optional for llama.cpp; required for Ollama / remote. | +| `LLM_API_KEY` | *(empty)* | Bearer token for remote APIs. | +| `LLM_JSON_MODE` | `schema` | `schema` (grammar-constrained, llama.cpp) \| `object` \| `off`. Auto-downgrades on HTTP 400. | +| `LLM_MAX_ROUNDS` | `8` | Max model turns per run. | +| `LLM_MAX_TOKENS` | `2048` | Generation cap per turn. | +| `LLM_TIMEOUT_MS` | `180000` | Hard per-request timeout. | +| `LLM_TEMPERATURE` | `0` | Sampling temperature. | + +**Local llama.cpp** (what produced the numbers above): + +```powershell +$env:LLM_SERVER_EXE = "D:\path\to\llama-server.exe" +$env:LLM_MODEL_PATH = "D:\path\to\model.gguf" +.\scripts\start-eval-llm.ps1 # starts ONE server: ctx 8192, --parallel 1, 127.0.0.1 only +npm run eval:llm +.\scripts\stop-eval-llm.ps1 +``` + +**Local Ollama**: + +```bash +LLM_BASE_URL=http://127.0.0.1:11434/v1 LLM_MODEL=gemma3:27b npm run eval:llm +``` + +**Remote OpenAI-compatible API** (the same adapter — just point it elsewhere): + +```bash +LLM_BASE_URL=https://api.example.com/v1 LLM_MODEL=some-model LLM_API_KEY=sk-... npm run eval:llm +``` + +### Resource safety (local runs) + +The live tier is designed not to exhaust the host machine: + +- the eval runner sends **one request at a time**; the start script pins + `--parallel 1`, a small **8K context** (small KV cache — the main VRAM + guard), a bounded thread count, and binds to `127.0.0.1` only; +- every request has a **hard timeout**, every run a **round cap** and a + **generation cap** — a wedged server fails one run instead of hanging the + eval or pinning the GPU indefinitely; +- the start script refuses to launch when free RAM is critically low, and + `LLM_NGL` lets you trade GPU offload for stability on flaky drivers; +- run **one** model instance during an eval (do not co-load a second model). + +--- + +## Architecture + +``` +Skill Contract ─► Model Adapter ─► Eval Harness ─► Validators ─► Telemetry ─► Report ─► CI Gate + (what) (how) (run N×) (schema, (logs, (html, (fail + citation, spans, json, build + claims, metrics) prom) below + tools) threshold) +``` + +| Layer | Location | Responsibility | +| --- | --- | --- | +| Skill contract | `skills/`, `src/core/skill-contract.ts` | What the skill must do (model-independent). | +| Model adapter | `src/models/` | How a model is called (mock / flaky / live `llm` / stubs). | +| Tools | `src/tools/` | `repo_search`, `read_file`, recording registry. | +| Eval harness | `src/core/eval-runner.ts` | Run each case N×, orchestrate everything. | +| Validators | `src/validators/` | Schema, citation, unsupported-claim, tool-call. | +| Telemetry | `src/telemetry/` | Structured logs, trace-like spans, metrics. | +| Reporting | `src/reporting/`, `src/artifacts/` | summary.json, report.html, metrics.prom, replays. | +| CI gate | `.github/workflows/skill-eval.yml` | Fail the build below threshold. | + +## Skill contract vs. model execution + +This is the core idea, so it is worth stating plainly: + +- The **skill contract is model-independent**. It describes what a correct answer + looks like: the output schema, the citation requirement, the unsupported-claim + policy, and the tool contract. +- The **reliability profile is model-dependent and must be measured**. Different + models (or model versions, prompts, or tool schemas) will have different pass + rates, latencies, costs, and failure patterns *against the same contract*. + +That separation is why the model name is a first-class dimension on every metric, +log line, and report. See `docs/model-adapters.md`. + +## Verification model + +Every run is graded by four validators (all must pass): + +1. **Schema** — output matches the required JSON structure. +2. **Citation** — each cited `file:line` exists and supports its claim; required + symbols and files are cited. (Keyword-based for the MVP; semantic validation is + on the roadmap.) +3. **Unsupported claim** — no forbidden/hallucinated claims; the model returns + `insufficient_evidence` instead of inventing answers; answered claims are cited. +4. **Tool call** — required tools were called, in the contract's `toolOrder` + (`repo_search` before `read_file`; `wikipedia_search` before `wikipedia_fetch`). + +Plus: **negative cases** (must decline to answer), **repeated runs** (default 10 +per case), and **threshold gates** (overall + per-case). Details in +`docs/verification-pipeline.md` and `skills/codebase-understanding/verification-rules.md`. + +## Observability model + +Each run produces: + +- **Structured logs** — `reports/latest/structured-events.jsonl` (real). +- **Trace-like spans** — `skill.run` → tool selection/execution → output generation + → validations → final decision. OpenTelemetry-shaped JSON (demo telemetry; a + live OTLP exporter is a roadmap item). +- **Metrics** — `reports/latest/metrics.prom` and `summary.json`. Rates are exact; + token/cost/latency are estimated/demo values for the mock adapters and real + measured/server-reported values for the live `llm` adapter (`summary.json` + carries a `measurement` field saying which you are looking at). +- **Replay artifacts** — one JSON per failed run under `replay-artifacts/`. + +The **static report works by default**. The **Grafana stack in `observability/` +is optional** and **OpenTelemetry integration is demo-level** unless you implement +the exporter. See `docs/observability-model.md`. + +--- + +## CLI + +```bash +npm run eval -- \ + --skill codebase-understanding \ + --model mock \ + --runs 10 \ + --threshold 0.9 \ + --output reports/latest +``` + +| Flag | Default | Meaning | +| --- | --- | --- | +| `--skill` | `codebase-understanding` | Skill to evaluate. | +| `--model` | `mock` | `mock` \| `mock-flaky` \| `glossary` \| `glossary-flaky` \| `openmind` \| `openmind-flaky` \| `llm` \| `openai-stub` \| `anthropic-stub` \| `ollama-stub`. | +| `--runs` | `10` | Runs per test case. | +| `--threshold` | `0.9` | Release-gate pass-rate threshold (0..1). | +| `--output` | `reports/latest` | Report output directory. | +| `--no-gate` | (off) | Do not exit non-zero when the gate fails. | +| `--llm-base-url` | env `LLM_BASE_URL` | Live adapter only: OpenAI-compatible base URL. | +| `--llm-model` | env `LLM_MODEL` | Live adapter only: model name/tag. | +| `--llm-json-mode` | env `LLM_JSON_MODE` | Live adapter only: `schema` \| `object` \| `off`. | +| `--llm-max-rounds` | env `LLM_MAX_ROUNDS` | Live adapter only: max model turns per run. | +| `--llm-timeout-ms` | env `LLM_TIMEOUT_MS` | Live adapter only: per-request timeout. | + +Whether a **real model** is involved is selected by `--model`: the offline +adapters (`mock`, `glossary`, `openmind`, …) never call one; `--model llm` +connects to a real model server (see +[Live model eval](#live-model-eval-running-the-same-skill-against-a-real-model)). +The remaining stub adapters do **not** call real APIs — they throw a clear, +documented error. The default demo uses the offline `mock` adapter. + +## npm scripts + +| Script | Description | +| --- | --- | +| `npm run build` | Type-check and compile with `tsc`. | +| `npm run test` | Run the vitest suite. | +| `npm run eval` | Run the eval with the **mock** adapter (offline). | +| `npm run eval:flaky` | Run with the **mock-flaky** adapter to demonstrate failures. | +| `npm run eval:glossary` | Glossary skill × deterministic reference adapter → `reports/glossary-deterministic`. | +| `npm run eval:llm` | Glossary skill × **live model** (`llm` adapter) → `reports/glossary-llm`. Needs a running model server. | +| `npm run glossary` | Run the **glossary** skill eval + render the web-page deliverable (offline). | +| `npm run glossary:flaky` | Run the glossary skill with the unstable adapter to demonstrate failures. | +| `npm run glossary:build-cache` | Fetch the Wikipedia snapshot fixtures once (network required). | +| `npm run clean` | Remove `dist/` and generated reports. | + +## Repository layout + +``` +skills/ Claude-style skills + machine-readable contracts +testcases/ Happy-path and negative test cases (shared or per-skill) +fixtures/ Sample repo + Wikipedia snapshots the skills answer from +scripts/ Fixture cache builder, test case generator, preview server +src/ + core/ Types, contract loader, eval runner, thresholds + models/ Model adapters (mock, flaky, glossary, stubs) + tools/ repo_search, read_file, wikipedia_search/fetch, registry + skills/ Skill-specific code (glossary snapshot parser + web renderer) + validators/ Schema, citation, unsupported-claim, tool-call + telemetry/ Logger, tracer, metrics + reporting/ summary.json, report.html, metrics.prom writers + artifacts/ Replay artifacts + cli/ run-eval and run-glossary entry points +observability/ Optional OTEL Collector / Prometheus / Grafana (demo) +docs/ Design docs (verification, observability, replay, adapters) +tests/ vitest suites +``` + +## Requirements + +- Node.js >= 18.18 (developed on Node 22). +- No API keys and no network for the default demo. + +## Roadmap + +Clearly future work, not implemented today: + +- Real OpenTelemetry OTLP exporter (replace demo span JSON). +- Provider-native adapters (e.g. Anthropic Messages API with native tool use) — + the OpenAI-compatible **live adapter is implemented** (`--model llm`, covering + llama.cpp / Ollama / OpenAI-compatible remote APIs); the remaining stubs mark + the provider-native variants. +- MCP server integration for tools. +- Claude Skill packaging examples. +- Richer **semantic** citation validation (beyond keyword matching). +- Committed dashboard screenshots and a model comparison matrix. +- A small web UI for browsing runs and artifacts. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/docs/release-process.md b/docs/release-process.md new file mode 100644 index 0000000..35bc466 --- /dev/null +++ b/docs/release-process.md @@ -0,0 +1,78 @@ +# Release process + +Agent Skill Verifier ships as standalone executables (Windows x64, Linux x64, +macOS x64, macOS arm64), a portable Node bundle, and `SHA256SUMS.txt` — all +built and published by the tag-driven [release workflow](../.github/workflows/release.yml). + +## Pinned toolchain + +- **Node 22.13.1** for all CI and release builds (`NODE_VERSION` in the + workflows). The standalone binaries follow the official + [Node.js Single Executable Application](https://nodejs.org/docs/latest-v22.x/api/single-executable-applications.html) + procedure of exactly this version: esbuild bundles the CLI to one CommonJS + file, `node --experimental-sea-config` produces the preparation blob, and + `postject` (pinned `1.0.0-alpha.6`) injects it into a copy of the Node + binary. On macOS the copy is ad-hoc re-signed (`codesign -s -`). +- **esbuild** pinned exact (`0.25.5`) for the bundle; version injected at + build time via a compile-time define; no source maps in release artifacts. +- Each platform binary is built on its native GitHub-hosted runner + (`windows-latest`, `ubuntu-latest`, `macos-15-intel`, `macos-14`). + +## Versioning + +The single source of truth is `package.json`'s `version`. The release tag is +`v` and must match — the workflow's first step fails otherwise +(`release-check --tag-only`). The CLI, the release manifests, the archive +filenames, and the Release title all derive from the same value. Existing +tags and releases are never overwritten; to release again, bump the patch +version. + +## Dry run (required before tagging) + +Trigger the **release** workflow manually (`workflow_dispatch`, "Run +workflow" in the Actions tab, or `gh workflow run release.yml`). A dispatch +run executes the full verify + all platform builds + smoke tests and uploads +the archives as *workflow artifacts* — but the publication job is skipped, so +no tag, no Release, and no `contents: write` is ever used. Inspect the +artifacts before proceeding. + +## Publishing + +```bash +git tag -a v0.1.0 -m "Agent Skill Verifier v0.1.0" +git push origin v0.1.0 +``` + +The workflow then: + +1. **verify** — tag/version consistency, lint, typecheck, tests, portable + bundle smoke test. +2. **build-platform / build-portable** — native builds; each archive gets a + `release-manifest.json` (per-file SHA-256, commit, pinned Node version), + is content-validated (`release-check --require-commit-match`), and + smoke-tested from a directory outside the checkout. +3. **release** (tag pushes only; the only job with `contents: write`) — + downloads all artifacts, requires the exact expected asset set, re-verifies + manifests and checksums, generates release notes, **creates a draft**, + uploads all assets without clobbering, confirms the uploaded asset list + matches, and only then publishes the draft as the latest release. + +A failure at any point leaves at most an unpublished draft — never a partial +public release. + +## Local equivalents + +```bash +npm run build:cli # bundle -> dist/cli/agent-skill-verifier.cjs +npm run build:standalone # SEA binary for this platform -> dist/sea/ +npm run package:release # this platform's archive + portable -> dist/release/ +npm run release:checksums # SHA256SUMS.txt / SHA256SUMS.json +npm run release:check # validate archives, manifests, forbidden files +npm run release:smoke # run the packaged CLI from a temp dir (16 checks) +``` + +## Signing status + +Binaries are **not code-signed** (no Authenticode, no Apple notarization). +Checksums authenticate content integrity only. This is documented in the +release notes and README so users know to expect OS trust prompts. diff --git a/docs/threat-model.md b/docs/threat-model.md new file mode 100644 index 0000000..4369235 --- /dev/null +++ b/docs/threat-model.md @@ -0,0 +1,42 @@ +# Threat model — Agent Skill Verifier + +Scope: the `agent-skill-verifier` CLI, its report artifacts, and the release +pipeline. The verifier processes **untrusted inputs** (skill directories, +evaluation cases, replay artifacts, configuration files) and produces +artifacts consumed by browsers and CI systems, so both directions are modeled. + +## Assets + +- The user's machine and CI environment (filesystem, environment variables). +- Provider credentials (`LLM_API_KEY` and similar). +- Report artifacts consumed downstream (HTML in browsers, JUnit in CI). +- The published release assets and their integrity. + +## Threats and mitigations + +| Threat | Mitigation | +|--------|------------| +| **Malicious skill content** (hostile names, descriptions, corpus text) | All contract/case content is schema-validated (zod) on load; content is treated as data, never executed; HTML reports escape `& < > "` and JUnit escapes `& < > " '` plus strips control characters, so hostile content cannot inject markup (regression-tested). | +| **Malicious evaluation files** (YAML/JSON) | Parsed with `yaml`'s safe defaults (no custom tags → no code execution) and `JSON.parse`; schema-validated; duplicate ids rejected; case content never interpolated into shell commands. | +| **Path traversal via case/citation paths** | Cited files are only ever *read* and compared; reads resolve inside the workspace root. Tools read only beneath the contract's `fixtureRoot`. | +| **Output directory escape** | `--output` (and config `output.directory`) must resolve inside the working directory; filesystem roots are rejected; replay file names are sanitized to `[A-Za-z0-9._-]` with collision suffixes; only known generated files are cleaned. | +| **Secret leakage** | Credentials are accepted **only** via environment variables, never CLI flags (which leak via shell history/process lists). Reports, manifests, and replay artifacts contain no environment values. The bundler emits no source maps; release-check rejects `.env*`, key files, and `node_modules` inside archives. | +| **Malformed provider output** | Adapter output is schema-validated per run; a crashing adapter marks the run errored/failed instead of crashing the verifier; timeouts abort with exit 5. | +| **HTML injection** | Single escaping chokepoint in the HTML builder; self-contained output (no external scripts/styles), verified by tests. | +| **XML injection** | JUnit builder escapes all user-controlled strings and strips XML-invalid control characters; well-formedness is tested against hostile fixtures. | +| **Release tag injection** | Tags must match `^v\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$` and equal `v` (`release-check --tag-only`), enforced in the workflow before any build is used. Tag names are only passed to `gh` as argv values, never interpolated into scripts. | +| **Malicious asset filename** | Asset names are generated from the validated package version; the release job accepts only the expected asset set (`--require-assets`) and fails on anything missing; manifest file paths must be plain file names (no separators, no `..`). | +| **Incomplete release publication** | Draft-first: the release is created as a draft, every asset is uploaded and then compared against the expected list, checksums are re-verified, and only then is the draft published. A failure leaves a draft, never a partial public release. Existing releases/assets are never overwritten (no `--clobber`, explicit existence check). | +| **Compromised release dependency** | Runtime deps are three audited pure-JS packages (commander, zod, yaml) installed from the lockfile (`npm ci`); build tools (esbuild, postject) are pinned exact; release jobs run `npm ci` from the same lockfile; the SEA binary embeds only the bundled first-party code and inlined deps. | +| **Archive traversal ("zip slip")** | Archives are *produced* from a controlled staging directory with flat, sanitized names; `release-check` re-extracts every archive and fails on any path containing separators or `..` in the manifest listing. | +| **Embedded build-machine paths** | No source maps; `release-check` scans archives for forbidden files; the bundle embeds the version via a compile-time define instead of reading local paths; smoke tests run the binary from a temp directory to prove no repo-path dependence. | +| **CI privilege escalation** | Ordinary CI: `permissions: contents: read`. Only the `release` job has `contents: write`, it runs only on pushed `v*` tags (never pull requests, never manual dispatch), and uses the ephemeral `github.token` — no PATs stored anywhere. | + +## Non-goals / residual risk + +- **Publisher identity**: checksums prove integrity, not identity. Binaries + are not code-signed; users see OS trust prompts (documented). +- **Malicious `node` on PATH** (portable bundle) or a compromised GitHub + account/runner are outside this model. +- The optional live `llm` adapter sends case content to the configured + endpoint; users choose that endpoint and credentials deliberately. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..ec9c92b --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,48 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: [ + "dist/**", + "node_modules/**", + "coverage/**", + "reports/**", + "fixtures/**", + "observability/**", + ".agent-skill-verification/**", + "tmp/**", + ], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + rules: { + // The core intentionally moves unknown JSON through typed boundaries. + "@typescript-eslint/no-explicit-any": "off", + // tsc (noUnusedLocals) already enforces this with better TS awareness. + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + }, + }, + { + files: ["scripts/**/*.mjs", "*.mjs"], + languageOptions: { + globals: { + process: "readonly", + console: "readonly", + URL: "readonly", + URLSearchParams: "readonly", + fetch: "readonly", + setTimeout: "readonly", + clearTimeout: "readonly", + AbortController: "readonly", + TextDecoder: "readonly", + TextEncoder: "readonly", + }, + }, + rules: { + // Data-generation scripts keep reference constants for documentation. + "@typescript-eslint/no-unused-vars": ["error", { varsIgnorePattern: "^[A-Z0-9_]+$" }], + }, + }, +); diff --git a/examples/skill-verification.yaml b/examples/skill-verification.yaml new file mode 100644 index 0000000..dc0a343 --- /dev/null +++ b/examples/skill-verification.yaml @@ -0,0 +1,26 @@ +# Example project configuration for agent-skill-verifier. +# Copy to your project root as `skill-verification.yaml` (or pass --config). +# Precedence: CLI flags > this file > built-in defaults. +schemaVersion: "1.0.0" + +skill: + path: ./fixtures/valid-skill + +evaluation: + cases: ./fixtures/evals.yaml + runs: 10 + threshold: 0.90 + seed: 12345 + # timeoutMs: 600000 + +adapter: + # mock (offline, default) | mock-flaky | llm (env-configured) | ... + name: mock + +output: + directory: .agent-skill-verification + formats: [json, junit, html, replay] + +qualityGate: + failOnThreshold: true + maximumFlakyRate: 0.05 diff --git a/fixtures/evals.yaml b/fixtures/evals.yaml new file mode 100644 index 0000000..1fbfc41 --- /dev/null +++ b/fixtures/evals.yaml @@ -0,0 +1,41 @@ +# Evaluation cases for the `valid-skill` fixture (used by CLI smoke tests). +# Cases may be written in YAML (this file) or JSON; both use the same schema. +cases: + - id: case-001 + name: Locate the invoice creation service + kind: happy + input: + question: Where is InvoiceService implemented and what does createInvoice do? + expectedStatus: answered + requiredSymbols: + - InvoiceService + requiredTools: + - repo_search + - read_file + expectedCitationFiles: + - fixtures/valid-skill/corpus/InvoiceService.ts + + - id: case-002 + name: Locate the card settlement gateway + kind: happy + input: + question: Where is PaymentGateway implemented and how does chargeCard work? + expectedStatus: answered + requiredSymbols: + - PaymentGateway + requiredTools: + - repo_search + - read_file + expectedCitationFiles: + - fixtures/valid-skill/corpus/PaymentGateway.ts + + - id: case-003 + name: Refuse to invent answers about missing components + kind: negative + input: + question: Does the QuantumRouter stream blockchain telemetry snapshots? + expectedStatus: insufficient_evidence + requiredTools: + - repo_search + forbiddenClaims: + - QuantumRouter diff --git a/fixtures/specbridge-workspace/.kiro/specs/notification-preferences/design.md b/fixtures/specbridge-workspace/.kiro/specs/notification-preferences/design.md new file mode 100644 index 0000000..e7a506f --- /dev/null +++ b/fixtures/specbridge-workspace/.kiro/specs/notification-preferences/design.md @@ -0,0 +1,19 @@ +# Design Document + +## Overview + +A per-customer preferences record consulted by the notification dispatcher. + +## Data Models + +- `notification_preferences(customer_id, channel, enabled, updated_at)` + +## Error Handling + +- Missing preference rows default to enabled +- Dispatcher failures never block the triggering transaction + +## Testing Strategy + +- Unit tests for preference resolution +- Integration test proving the one-minute propagation bound diff --git a/fixtures/specbridge-workspace/.kiro/specs/notification-preferences/requirements.md b/fixtures/specbridge-workspace/.kiro/specs/notification-preferences/requirements.md new file mode 100644 index 0000000..80e31c9 --- /dev/null +++ b/fixtures/specbridge-workspace/.kiro/specs/notification-preferences/requirements.md @@ -0,0 +1,16 @@ +# Requirements Document + +## Introduction + +Customers choose which channels (email, SMS, push) may notify them. + +## Requirements + +### Requirement 1 + +**User Story:** As a customer, I want to disable a notification channel, so that I stop receiving messages there. + +#### Acceptance Criteria + +1. WHEN a customer disables a channel THEN the system SHALL stop sending on that channel within one minute +2. WHEN every channel is disabled THEN the system SHALL still deliver legally required notices by email diff --git a/fixtures/specbridge-workspace/.kiro/specs/notification-preferences/tasks.md b/fixtures/specbridge-workspace/.kiro/specs/notification-preferences/tasks.md new file mode 100644 index 0000000..babd347 --- /dev/null +++ b/fixtures/specbridge-workspace/.kiro/specs/notification-preferences/tasks.md @@ -0,0 +1,8 @@ +# Implementation Plan + +- [x] 1. Create the preferences data model and repository + - _Requirements: 1.1_ +- [ ] 2. Enforce preferences in the notification dispatcher + - _Requirements: 1.1, 1.2_ +- [ ] 3. Add the legally-required-notice email fallback + - _Requirements: 1.2_ diff --git a/fixtures/specbridge-workspace/.kiro/specs/user-authentication/design.md b/fixtures/specbridge-workspace/.kiro/specs/user-authentication/design.md new file mode 100644 index 0000000..5490f25 --- /dev/null +++ b/fixtures/specbridge-workspace/.kiro/specs/user-authentication/design.md @@ -0,0 +1,36 @@ +# Design Document + +## Overview + +Session-cookie based authentication built on the existing Fastify stack. +Passwords are hashed with argon2id; sessions live in PostgreSQL. + +## Architecture + +```mermaid +flowchart LR + Browser --> LoginRoute --> AuthService --> SessionStore + SessionStore --> PostgreSQL +``` + +## Components and Interfaces + +- `AuthService.signIn(email, password)` — validates credentials, creates a session +- `AuthService.signOut(sessionId)` — invalidates a session +- `SessionStore` — persistence and expiry sweep + +## Data Models + +- `users(id, email, password_hash, failed_attempts, locked_until)` +- `sessions(id, user_id, created_at, last_seen_at, expires_at)` + +## Error Handling + +- Invalid credentials and unknown emails return the same error shape +- Lockout responses use HTTP 429 with a Retry-After header + +## Testing Strategy + +- Unit tests for credential validation and lockout arithmetic +- Integration tests for the full sign-in/sign-out flow +- A clock-controlled test for the 30-minute expiry sweep diff --git a/fixtures/specbridge-workspace/.kiro/specs/user-authentication/requirements.md b/fixtures/specbridge-workspace/.kiro/specs/user-authentication/requirements.md new file mode 100644 index 0000000..44d3cba --- /dev/null +++ b/fixtures/specbridge-workspace/.kiro/specs/user-authentication/requirements.md @@ -0,0 +1,29 @@ +# Requirements Document + +## Introduction + +This feature adds email/password authentication with session management to +Acme Portal. Customers sign in with existing credentials; sessions expire +after inactivity to protect shared devices. + +## Requirements + +### Requirement 1 + +**User Story:** As a registered customer, I want to sign in with my email and password, so that I can access my account. + +#### Acceptance Criteria + +1. WHEN a customer submits valid credentials THEN the system SHALL create a session and redirect to the dashboard +2. WHEN a customer submits invalid credentials THEN the system SHALL show a generic error without revealing which field was wrong +3. IF a customer fails sign-in five times within ten minutes THEN the system SHALL lock the account for fifteen minutes + +### Requirement 2 + +**User Story:** As a signed-in customer, I want my session to expire after inactivity, so that my account stays safe on shared devices. + +#### Acceptance Criteria + +1. WHEN a session is inactive for 30 minutes THEN the system SHALL invalidate the session +2. WHEN a session expires THEN the system SHALL redirect the next request to the sign-in page +3. WHEN a customer signs out THEN the system SHALL invalidate the session immediately diff --git a/fixtures/specbridge-workspace/.kiro/specs/user-authentication/tasks.md b/fixtures/specbridge-workspace/.kiro/specs/user-authentication/tasks.md new file mode 100644 index 0000000..bacec82 --- /dev/null +++ b/fixtures/specbridge-workspace/.kiro/specs/user-authentication/tasks.md @@ -0,0 +1,25 @@ +# Implementation Plan + +- [x] 1. Set up authentication module scaffolding + - Create `src/auth/` with service, routes, and repository stubs + - _Requirements: 1.1_ + +- [x] 2. Implement credential validation + - [x] 2.1 Add argon2id password hashing helper + - Use a configurable cost profile + - _Requirements: 1.1, 1.2_ + - [ ] 2.2 Add sign-in endpoint with generic error responses + - _Requirements: 1.2_ + - [ ] 2.3 Implement failed-attempt lockout + - _Requirements: 1.3_ + +- [ ] 3. Session management + - [ ] 3.1 Issue session cookies on successful sign-in + - _Requirements: 1.1, 2.2_ + - [ ] 3.2 Expire sessions after 30 minutes of inactivity + - _Requirements: 2.1, 2.2_ + - [ ] 3.3 Invalidate sessions on sign-out + - _Requirements: 2.3_ + +- [ ]* 4. Add property-based tests for expiry arithmetic + - _Requirements: 2.1_ diff --git a/fixtures/specbridge-workspace/.specbridge/extensions/grants.json b/fixtures/specbridge-workspace/.specbridge/extensions/grants.json new file mode 100644 index 0000000..4637832 --- /dev/null +++ b/fixtures/specbridge-workspace/.specbridge/extensions/grants.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": "1.0.0", + "grants": { + "example-analyzer": { + "version": "1.0.0", + "manifestSha256": "472d1508862c35552931d41409865554de90106ca2a8cd4cc16945ec868341fd", + "permissionHash": "b9dcf0b068fde654722679f90f43c30110df55630e98450c691d64229fe9298f", + "acceptedAt": "2026-07-16T16:17:09.586Z" + } + } +} diff --git a/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-analyzer/1.0.0/LICENSE b/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-analyzer/1.0.0/LICENSE new file mode 100644 index 0000000..9cf1062 --- /dev/null +++ b/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-analyzer/1.0.0/LICENSE @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-analyzer/1.0.0/README.md b/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-analyzer/1.0.0/README.md new file mode 100644 index 0000000..ff91277 --- /dev/null +++ b/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-analyzer/1.0.0/README.md @@ -0,0 +1,35 @@ +# example-analyzer + +Deterministic spec diagnostics contributed by the example-analyzer analyzer extension. + +A SpecBridge **analyzer** extension. + +## Develop + +- `dist/extension.cjs` is the self-contained artifact SpecBridge runs (works as scaffolded). +- `src/extension.mjs` shows the same handler built on `@specbridge/extension-sdk`; bundle it + to `dist/extension.cjs` (see package.json) once you add dependencies. +- stdout is protocol-only; log with `context.log(...)` / stderr. + +## Validate, test, package + +```bash +specbridge extension validate . +node --test test/ +specbridge extension conformance . --yes +specbridge extension package . +``` + +The package command prints the archive SHA-256 — publish that hash with your +archive. Checksums prove integrity, not publisher identity. + +## Install locally + +```bash +specbridge extension install ./dist/example-analyzer-1.0.0.specbridge-extension.zip +specbridge extension show example-analyzer +specbridge extension enable example-analyzer --accept-permissions +``` + +Installed extensions start disabled; enabling requires accepting the exact +permission hash shown by `extension show`. diff --git a/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-analyzer/1.0.0/checksums.json b/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-analyzer/1.0.0/checksums.json new file mode 100644 index 0000000..5c09af2 --- /dev/null +++ b/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-analyzer/1.0.0/checksums.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": "1.0.0", + "algorithm": "sha256", + "files": { + "LICENSE": "508a77d2e7b51d98adeed32648ad124b7b30241a8e70b2e72c99f92d8e5874d1", + "README.md": "550555e97cd0f3128c8706cd148e8f38e284c7f3c670e58700889b826d660cc5", + "dist/extension.cjs": "a85499b3151ce1e4ef261c17ef81367513b19659b5aa59f0535a719c027294b2", + "specbridge-extension.json": "472d1508862c35552931d41409865554de90106ca2a8cd4cc16945ec868341fd" + } +} diff --git a/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-analyzer/1.0.0/specbridge-extension.json b/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-analyzer/1.0.0/specbridge-extension.json new file mode 100644 index 0000000..0a59148 --- /dev/null +++ b/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-analyzer/1.0.0/specbridge-extension.json @@ -0,0 +1,32 @@ +{ + "schemaVersion": "1.0.0", + "protocolVersion": "1.0.0", + "id": "example-analyzer", + "version": "1.0.0", + "displayName": "example-analyzer", + "description": "Deterministic spec diagnostics contributed by the example-analyzer analyzer extension.", + "kind": "analyzer", + "entrypoint": "dist/extension.cjs", + "compatibility": { + "specbridge": ">=0.7.1 <1.0.0", + "extensionSdk": ">=0.7.1 <1.0.0" + }, + "capabilities": { + "operations": [ + "analyzer.analyze" + ] + }, + "permissions": { + "specRead": true, + "repositoryRead": false, + "repositoryWrite": false, + "network": false, + "childProcess": false, + "environmentVariables": [] + }, + "license": "MIT", + "keywords": [ + "analyzer", + "specbridge-extension" + ] +} diff --git a/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-verifier/1.0.0/LICENSE b/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-verifier/1.0.0/LICENSE new file mode 100644 index 0000000..9cf1062 --- /dev/null +++ b/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-verifier/1.0.0/LICENSE @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-verifier/1.0.0/README.md b/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-verifier/1.0.0/README.md new file mode 100644 index 0000000..ac76dd4 --- /dev/null +++ b/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-verifier/1.0.0/README.md @@ -0,0 +1,35 @@ +# example-verifier + +Verification diagnostics contributed by the example-verifier verifier extension. + +A SpecBridge **verifier** extension. + +## Develop + +- `dist/extension.cjs` is the self-contained artifact SpecBridge runs (works as scaffolded). +- `src/extension.mjs` shows the same handler built on `@specbridge/extension-sdk`; bundle it + to `dist/extension.cjs` (see package.json) once you add dependencies. +- stdout is protocol-only; log with `context.log(...)` / stderr. + +## Validate, test, package + +```bash +specbridge extension validate . +node --test test/ +specbridge extension conformance . --yes +specbridge extension package . +``` + +The package command prints the archive SHA-256 — publish that hash with your +archive. Checksums prove integrity, not publisher identity. + +## Install locally + +```bash +specbridge extension install ./dist/example-verifier-1.0.0.specbridge-extension.zip +specbridge extension show example-verifier +specbridge extension enable example-verifier --accept-permissions +``` + +Installed extensions start disabled; enabling requires accepting the exact +permission hash shown by `extension show`. diff --git a/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-verifier/1.0.0/checksums.json b/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-verifier/1.0.0/checksums.json new file mode 100644 index 0000000..21126d6 --- /dev/null +++ b/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-verifier/1.0.0/checksums.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": "1.0.0", + "algorithm": "sha256", + "files": { + "LICENSE": "508a77d2e7b51d98adeed32648ad124b7b30241a8e70b2e72c99f92d8e5874d1", + "README.md": "124981ad3414558181af38a45cf0f7104eb71d95818b1ee079ba0c6180adfc96", + "dist/extension.cjs": "ab2d31ca785bd3804d943821ddfe5897ed64c06776b8b3b5bd21e59ff8de13ea", + "specbridge-extension.json": "b7a4c0381cfe341eeb876e243cffe6d8758c35a7702403a24e312e2332b058d5" + } +} diff --git a/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-verifier/1.0.0/specbridge-extension.json b/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-verifier/1.0.0/specbridge-extension.json new file mode 100644 index 0000000..487473a --- /dev/null +++ b/fixtures/specbridge-workspace/.specbridge/extensions/installed/example-verifier/1.0.0/specbridge-extension.json @@ -0,0 +1,32 @@ +{ + "schemaVersion": "1.0.0", + "protocolVersion": "1.0.0", + "id": "example-verifier", + "version": "1.0.0", + "displayName": "example-verifier", + "description": "Verification diagnostics contributed by the example-verifier verifier extension.", + "kind": "verifier", + "entrypoint": "dist/extension.cjs", + "compatibility": { + "specbridge": ">=0.7.1 <1.0.0", + "extensionSdk": ">=0.7.1 <1.0.0" + }, + "capabilities": { + "operations": [ + "verifier.verify" + ] + }, + "permissions": { + "specRead": true, + "repositoryRead": false, + "repositoryWrite": false, + "network": false, + "childProcess": false, + "environmentVariables": [] + }, + "license": "MIT", + "keywords": [ + "verifier", + "specbridge-extension" + ] +} diff --git a/fixtures/specbridge-workspace/.specbridge/extensions/records.jsonl b/fixtures/specbridge-workspace/.specbridge/extensions/records.jsonl new file mode 100644 index 0000000..18a815f --- /dev/null +++ b/fixtures/specbridge-workspace/.specbridge/extensions/records.jsonl @@ -0,0 +1,3 @@ +{"schemaVersion":"1.0.0","recordId":"extension-mrnppcnf-tl0-1","type":"install","at":"2026-07-16T16:17:08.591Z","extensionId":"example-analyzer","version":"1.0.0","details":{"source":"local-archive:D:\\work\\specbridge\\examples\\extensions\\example-analyzer\\dist\\example-analyzer-1.0.0.specbridge-extension.zip","manifestSha256":"472d1508862c35552931d41409865554de90106ca2a8cd4cc16945ec868341fd","permissionHash":"b9dcf0b068fde654722679f90f43c30110df55630e98450c691d64229fe9298f","archiveSha256":"4d2ed293339ac609b5a0db4e6810c4a621541e3c2c0fa850693831ea196f71d9"}} +{"schemaVersion":"1.0.0","recordId":"extension-mrnppdm7-ubk-1","type":"enable","at":"2026-07-16T16:17:09.679Z","extensionId":"example-analyzer","version":"1.0.0","details":{"permissionHash":"b9dcf0b068fde654722679f90f43c30110df55630e98450c691d64229fe9298f"}} +{"schemaVersion":"1.0.0","recordId":"extension-mrnppecc-bzw-1","type":"install","at":"2026-07-16T16:17:10.960Z","extensionId":"example-verifier","version":"1.0.0","details":{"source":"local-archive:D:\\work\\specbridge\\examples\\extensions\\example-verifier\\dist\\example-verifier-1.0.0.specbridge-extension.zip","manifestSha256":"b7a4c0381cfe341eeb876e243cffe6d8758c35a7702403a24e312e2332b058d5","permissionHash":"54d55c04ab9da47fbd76f9697e33413ef89bd95c56ca62c4e26a072adfa58d1a","archiveSha256":"17e78d12a74b2944983a38527e8568117d56ed53e219a5a976787bb1bed832af"}} diff --git a/fixtures/specbridge-workspace/.specbridge/extensions/state.json b/fixtures/specbridge-workspace/.specbridge/extensions/state.json new file mode 100644 index 0000000..c13d9fa --- /dev/null +++ b/fixtures/specbridge-workspace/.specbridge/extensions/state.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": "1.0.0", + "installed": [ + { + "id": "example-analyzer", + "version": "1.0.0", + "kind": "analyzer", + "displayName": "example-analyzer", + "description": "Deterministic spec diagnostics contributed by the example-analyzer analyzer extension.", + "source": "local-archive:D:\\work\\specbridge\\examples\\extensions\\example-analyzer\\dist\\example-analyzer-1.0.0.specbridge-extension.zip", + "installedAt": "2026-07-16T16:17:08.561Z", + "archiveSha256": "4d2ed293339ac609b5a0db4e6810c4a621541e3c2c0fa850693831ea196f71d9", + "manifestSha256": "472d1508862c35552931d41409865554de90106ca2a8cd4cc16945ec868341fd", + "permissionHash": "b9dcf0b068fde654722679f90f43c30110df55630e98450c691d64229fe9298f", + "entrypoint": "dist/extension.cjs", + "installRecordId": "extension-mrnppcnf-tl0-1" + }, + { + "id": "example-verifier", + "version": "1.0.0", + "kind": "verifier", + "displayName": "example-verifier", + "description": "Verification diagnostics contributed by the example-verifier verifier extension.", + "source": "local-archive:D:\\work\\specbridge\\examples\\extensions\\example-verifier\\dist\\example-verifier-1.0.0.specbridge-extension.zip", + "installedAt": "2026-07-16T16:17:10.921Z", + "archiveSha256": "17e78d12a74b2944983a38527e8568117d56ed53e219a5a976787bb1bed832af", + "manifestSha256": "b7a4c0381cfe341eeb876e243cffe6d8758c35a7702403a24e312e2332b058d5", + "permissionHash": "54d55c04ab9da47fbd76f9697e33413ef89bd95c56ca62c4e26a072adfa58d1a", + "entrypoint": "dist/extension.cjs", + "installRecordId": "extension-mrnppecc-bzw-1" + } + ], + "enabled": { + "example-analyzer": { + "version": "1.0.0" + } + } +} diff --git a/fixtures/specbridge-workspace/.specbridge/state/specs/notification-preferences.json b/fixtures/specbridge-workspace/.specbridge/state/specs/notification-preferences.json new file mode 100644 index 0000000..e29e2a9 --- /dev/null +++ b/fixtures/specbridge-workspace/.specbridge/state/specs/notification-preferences.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": "1.0.0", + "specName": "notification-preferences", + "specType": "feature", + "workflowMode": "requirements-first", + "origin": "created-by-specbridge", + "status": "DESIGN_DRAFT", + "createdAt": "2026-07-01T09:00:00.000Z", + "updatedAt": "2026-07-01T10:00:00.000Z", + "stages": { + "requirements": { + "status": "approved", + "file": ".kiro/specs/notification-preferences/requirements.md", + "approvedAt": "2026-07-01T10:00:00.000Z", + "approvedHash": "7fa1e23db7ca25d0f1d745e1bf99fc4fea185606b15fa2231e09f2c0cbeb29a0" + }, + "design": { + "status": "draft", + "file": ".kiro/specs/notification-preferences/design.md", + "approvedAt": null, + "approvedHash": null + }, + "tasks": { + "status": "blocked", + "file": ".kiro/specs/notification-preferences/tasks.md", + "approvedAt": null, + "approvedHash": null + } + } +} diff --git a/fixtures/specbridge-workspace/README.md b/fixtures/specbridge-workspace/README.md new file mode 100644 index 0000000..167afbb --- /dev/null +++ b/fixtures/specbridge-workspace/README.md @@ -0,0 +1,13 @@ +# Example: requirements-first workflow + +A feature spec authored requirements → design → tasks. The workflow order is +not stored in `.kiro` (the file layout is identical for every workflow), so +SpecBridge records it in sidecar state at +`.specbridge/state/specs/notification-preferences.json` — approvals included. +The `.kiro` files carry no SpecBridge metadata. + +```sh +cd examples/requirements-first-project +node ../../packages/cli/dist/index.js spec list # MODE column reads requirements-first +node ../../packages/cli/dist/index.js spec show notification-preferences +``` diff --git a/fixtures/specbridge-workspace/WORKSPACE.md b/fixtures/specbridge-workspace/WORKSPACE.md new file mode 100644 index 0000000..9a1a09e --- /dev/null +++ b/fixtures/specbridge-workspace/WORKSPACE.md @@ -0,0 +1,7 @@ +# Workspace facts (generated by build-specbridge-fixture.mjs from real CLI output — do not edit) + +- workspace: healthy true, specs 2, kiro layout detected +- spec notification-preferences: type feature, mode requirements-first, status DESIGN_DRAFT, managed true, stages: requirements approved, design draft, tasks blocked, tasks 1/3 done +- spec user-authentication: type feature, mode unknown, status unmanaged, managed false, tasks 3/9 done +- extension example-analyzer: version 1.0.0, kind analyzer, enabled +- extension example-verifier: version 1.0.0, kind verifier, disabled diff --git a/fixtures/specbridge-workspace/snapshots/runner-list.json b/fixtures/specbridge-workspace/snapshots/runner-list.json new file mode 100644 index 0000000..da4a787 --- /dev/null +++ b/fixtures/specbridge-workspace/snapshots/runner-list.json @@ -0,0 +1,410 @@ +{ + "defaultRunner": "claude-code", + "operationDefaults": { + "stageGeneration": null, + "stageRefinement": null, + "taskExecution": null + }, + "profiles": [ + { + "profile": "claude-code", + "implementation": "claude-code", + "category": "agent-cli", + "enabled": true, + "model": null, + "networkBacked": false, + "localExecution": false, + "supportedOperations": [ + "stage-generation", + "stage-refinement", + "task-execution", + "task-resume", + "runner-test" + ], + "declaredCapabilities": { + "stageGeneration": true, + "stageRefinement": true, + "taskExecution": true, + "taskResume": true, + "structuredFinalOutput": true, + "streamingEvents": false, + "repositoryRead": true, + "repositoryWrite": true, + "sandbox": false, + "toolRestriction": true, + "usageReporting": true, + "costReporting": true, + "localOnly": false, + "requiresNetwork": true, + "supportsSystemPrompt": true, + "supportsJsonSchema": true, + "supportsCancellation": true + }, + "availability": "available", + "supportLevel": "production", + "detectedCapabilities": { + "stageGeneration": true, + "stageRefinement": true, + "taskExecution": true, + "taskResume": true, + "structuredFinalOutput": true, + "streamingEvents": false, + "repositoryRead": true, + "repositoryWrite": true, + "sandbox": false, + "toolRestriction": true, + "usageReporting": true, + "costReporting": true, + "localOnly": false, + "requiresNetwork": true, + "supportsSystemPrompt": true, + "supportsJsonSchema": true, + "supportsCancellation": true + }, + "version": "2.1.141 (Claude Code)", + "authentication": "authenticated" + }, + { + "profile": "codex-default", + "implementation": "codex-cli", + "category": "agent-cli", + "enabled": false, + "model": null, + "networkBacked": false, + "localExecution": false, + "supportedOperations": [ + "stage-generation", + "stage-refinement", + "task-execution", + "task-resume", + "model-list", + "runner-test" + ], + "declaredCapabilities": { + "stageGeneration": true, + "stageRefinement": true, + "taskExecution": true, + "taskResume": true, + "structuredFinalOutput": true, + "streamingEvents": true, + "repositoryRead": true, + "repositoryWrite": true, + "sandbox": true, + "toolRestriction": false, + "usageReporting": true, + "costReporting": false, + "localOnly": false, + "requiresNetwork": true, + "supportsSystemPrompt": false, + "supportsJsonSchema": true, + "supportsCancellation": true + }, + "availability": "misconfigured", + "supportLevel": "production", + "detectedCapabilities": { + "stageGeneration": true, + "stageRefinement": true, + "taskExecution": true, + "taskResume": true, + "structuredFinalOutput": true, + "streamingEvents": true, + "repositoryRead": true, + "repositoryWrite": true, + "sandbox": true, + "toolRestriction": false, + "usageReporting": true, + "costReporting": false, + "localOnly": false, + "requiresNetwork": true, + "supportsSystemPrompt": false, + "supportsJsonSchema": true, + "supportsCancellation": true + }, + "version": null, + "authentication": "unknown" + }, + { + "profile": "gemini-default", + "implementation": "gemini-cli", + "category": "agent-cli", + "enabled": false, + "model": null, + "networkBacked": false, + "localExecution": false, + "supportedOperations": [ + "stage-generation", + "stage-refinement", + "task-execution", + "task-resume", + "model-list", + "runner-test" + ], + "declaredCapabilities": { + "stageGeneration": true, + "stageRefinement": true, + "taskExecution": true, + "taskResume": true, + "structuredFinalOutput": true, + "streamingEvents": true, + "repositoryRead": true, + "repositoryWrite": true, + "sandbox": true, + "toolRestriction": true, + "usageReporting": true, + "costReporting": false, + "localOnly": false, + "requiresNetwork": true, + "supportsSystemPrompt": false, + "supportsJsonSchema": false, + "supportsCancellation": true + }, + "availability": "misconfigured", + "supportLevel": "production", + "detectedCapabilities": { + "stageGeneration": true, + "stageRefinement": true, + "taskExecution": true, + "taskResume": true, + "structuredFinalOutput": true, + "streamingEvents": true, + "repositoryRead": true, + "repositoryWrite": true, + "sandbox": true, + "toolRestriction": true, + "usageReporting": true, + "costReporting": false, + "localOnly": false, + "requiresNetwork": true, + "supportsSystemPrompt": false, + "supportsJsonSchema": false, + "supportsCancellation": true + }, + "version": null, + "authentication": "unknown" + }, + { + "profile": "ollama-local", + "implementation": "ollama", + "category": "model-api", + "enabled": false, + "model": null, + "networkBacked": false, + "localExecution": true, + "supportedOperations": [ + "stage-generation", + "stage-refinement", + "model-list", + "runner-test" + ], + "declaredCapabilities": { + "stageGeneration": true, + "stageRefinement": true, + "taskExecution": false, + "taskResume": false, + "structuredFinalOutput": true, + "streamingEvents": false, + "repositoryRead": false, + "repositoryWrite": false, + "sandbox": false, + "toolRestriction": false, + "usageReporting": true, + "costReporting": false, + "localOnly": true, + "requiresNetwork": false, + "supportsSystemPrompt": true, + "supportsJsonSchema": true, + "supportsCancellation": true + }, + "availability": "misconfigured", + "supportLevel": "production", + "detectedCapabilities": { + "stageGeneration": true, + "stageRefinement": true, + "taskExecution": false, + "taskResume": false, + "structuredFinalOutput": true, + "streamingEvents": false, + "repositoryRead": false, + "repositoryWrite": false, + "sandbox": false, + "toolRestriction": false, + "usageReporting": true, + "costReporting": false, + "localOnly": true, + "requiresNetwork": false, + "supportsSystemPrompt": true, + "supportsJsonSchema": true, + "supportsCancellation": true + }, + "version": null, + "authentication": "not-applicable" + }, + { + "profile": "openai-compatible-local", + "implementation": "openai-compatible", + "category": "model-api", + "enabled": false, + "model": null, + "networkBacked": false, + "localExecution": true, + "supportedOperations": [ + "stage-generation", + "stage-refinement", + "model-list", + "runner-test" + ], + "declaredCapabilities": { + "stageGeneration": true, + "stageRefinement": true, + "taskExecution": false, + "taskResume": false, + "structuredFinalOutput": true, + "streamingEvents": false, + "repositoryRead": false, + "repositoryWrite": false, + "sandbox": false, + "toolRestriction": false, + "usageReporting": true, + "costReporting": false, + "localOnly": true, + "requiresNetwork": false, + "supportsSystemPrompt": true, + "supportsJsonSchema": true, + "supportsCancellation": true + }, + "availability": "misconfigured", + "supportLevel": "production", + "detectedCapabilities": { + "stageGeneration": true, + "stageRefinement": true, + "taskExecution": false, + "taskResume": false, + "structuredFinalOutput": true, + "streamingEvents": false, + "repositoryRead": false, + "repositoryWrite": false, + "sandbox": false, + "toolRestriction": false, + "usageReporting": true, + "costReporting": false, + "localOnly": true, + "requiresNetwork": false, + "supportsSystemPrompt": true, + "supportsJsonSchema": true, + "supportsCancellation": true + }, + "version": null, + "authentication": "not-applicable" + }, + { + "profile": "antigravity", + "implementation": "antigravity-cli", + "category": "experimental", + "enabled": false, + "model": null, + "networkBacked": false, + "localExecution": false, + "supportedOperations": [], + "declaredCapabilities": { + "stageGeneration": false, + "stageRefinement": false, + "taskExecution": false, + "taskResume": false, + "structuredFinalOutput": false, + "streamingEvents": false, + "repositoryRead": false, + "repositoryWrite": false, + "sandbox": false, + "toolRestriction": false, + "usageReporting": false, + "costReporting": false, + "localOnly": false, + "requiresNetwork": false, + "supportsSystemPrompt": false, + "supportsJsonSchema": false, + "supportsCancellation": false + }, + "availability": "misconfigured", + "supportLevel": "experimental", + "detectedCapabilities": { + "stageGeneration": false, + "stageRefinement": false, + "taskExecution": false, + "taskResume": false, + "structuredFinalOutput": false, + "streamingEvents": false, + "repositoryRead": false, + "repositoryWrite": false, + "sandbox": false, + "toolRestriction": false, + "usageReporting": false, + "costReporting": false, + "localOnly": false, + "requiresNetwork": false, + "supportsSystemPrompt": false, + "supportsJsonSchema": false, + "supportsCancellation": false + }, + "version": null, + "authentication": "unknown" + }, + { + "profile": "mock", + "implementation": "mock", + "category": "mock", + "enabled": true, + "model": null, + "networkBacked": false, + "localExecution": true, + "supportedOperations": [ + "stage-generation", + "stage-refinement", + "task-execution", + "task-resume", + "runner-test" + ], + "declaredCapabilities": { + "stageGeneration": true, + "stageRefinement": true, + "taskExecution": true, + "taskResume": true, + "structuredFinalOutput": true, + "streamingEvents": false, + "repositoryRead": true, + "repositoryWrite": true, + "sandbox": true, + "toolRestriction": true, + "usageReporting": false, + "costReporting": false, + "localOnly": true, + "requiresNetwork": false, + "supportsSystemPrompt": true, + "supportsJsonSchema": true, + "supportsCancellation": true + }, + "availability": "available", + "supportLevel": "production", + "detectedCapabilities": { + "stageGeneration": true, + "stageRefinement": true, + "taskExecution": true, + "taskResume": true, + "structuredFinalOutput": true, + "streamingEvents": false, + "repositoryRead": true, + "repositoryWrite": true, + "sandbox": true, + "toolRestriction": true, + "usageReporting": false, + "costReporting": false, + "localOnly": true, + "requiresNetwork": false, + "supportsSystemPrompt": true, + "supportsJsonSchema": true, + "supportsCancellation": true + }, + "version": "mock/0.3.0", + "authentication": "not-applicable" + } + ] +} diff --git a/fixtures/specbridge-workspace/snapshots/template-list.json b/fixtures/specbridge-workspace/snapshots/template-list.json new file mode 100644 index 0000000..8e5255d --- /dev/null +++ b/fixtures/specbridge-workspace/snapshots/template-list.json @@ -0,0 +1,294 @@ +{ + "source": "all", + "count": 10, + "templates": [ + { + "ref": "builtin:authentication", + "id": "authentication", + "source": "builtin", + "valid": true, + "displayName": "Authentication", + "version": "1.0.0", + "description": "A feature spec template for adding or changing authentication or authorization behavior: credential and session flow, authorization boundaries, wrong-credential and lockout behavior, expiry, revocation, replay protection, rate limiting, audit events, and security tests.", + "kind": "feature", + "supportedModes": [ + "requirements-first", + "design-first", + "quick" + ], + "defaultMode": "requirements-first", + "tags": [ + "authentication", + "authorization", + "security", + "session", + "identity" + ], + "compatibility": { + "specbridge": ">=0.7.0 <1.0.0", + "kiroLayout": "1" + }, + "deprecated": false, + "errors": [] + }, + { + "ref": "builtin:background-job", + "id": "background-job", + "source": "builtin", + "valid": true, + "displayName": "Background Job", + "version": "1.0.0", + "description": "A feature spec template for adding or changing an asynchronous background job or worker: trigger, scheduling, retries, idempotency, duplicate delivery, timeouts, dead-letter behavior, cancellation, observability, and tests.", + "kind": "feature", + "supportedModes": [ + "requirements-first", + "design-first", + "quick" + ], + "defaultMode": "requirements-first", + "tags": [ + "background-job", + "worker", + "queue", + "async", + "scheduling" + ], + "compatibility": { + "specbridge": ">=0.7.0 <1.0.0", + "kiroLayout": "1" + }, + "deprecated": false, + "errors": [] + }, + { + "ref": "builtin:bugfix-regression", + "id": "bugfix-regression", + "source": "builtin", + "valid": true, + "displayName": "Bugfix Regression", + "version": "1.0.0", + "description": "A bugfix spec template built around evidence: current vs expected behavior, unchanged behavior, reproduction, root cause, the smallest safe fix, and the regression tests that keep it fixed.", + "kind": "bugfix", + "supportedModes": [ + "requirements-first", + "quick" + ], + "defaultMode": "requirements-first", + "tags": [ + "bugfix", + "regression", + "debugging", + "quality" + ], + "compatibility": { + "specbridge": ">=0.7.0 <1.0.0", + "kiroLayout": "1" + }, + "deprecated": false, + "errors": [] + }, + { + "ref": "builtin:cli-tool", + "id": "cli-tool", + "source": "builtin", + "valid": true, + "displayName": "Command-Line Tool", + "version": "1.0.0", + "description": "A feature spec template for adding or changing a command-line tool or command: command surface, arguments and options, exit codes, stdout/stderr behavior, machine-readable output, non-interactive use, platform compatibility, error handling, and tests.", + "kind": "feature", + "supportedModes": [ + "requirements-first", + "design-first", + "quick" + ], + "defaultMode": "requirements-first", + "tags": [ + "cli", + "command-line", + "tooling", + "developer-experience", + "ux" + ], + "compatibility": { + "specbridge": ">=0.7.0 <1.0.0", + "kiroLayout": "1" + }, + "deprecated": false, + "errors": [] + }, + { + "ref": "builtin:database-migration", + "id": "database-migration", + "source": "builtin", + "valid": true, + "displayName": "Database Migration", + "version": "1.0.0", + "description": "A feature spec template for a schema or data migration: the schema change, batched backfill, backward compatibility, zero-downtime deployment order, indexes and locking, rollback limitations, validation, and observability.", + "kind": "feature", + "supportedModes": [ + "requirements-first", + "design-first", + "quick" + ], + "defaultMode": "requirements-first", + "tags": [ + "database", + "migration", + "schema", + "sql", + "backfill" + ], + "compatibility": { + "specbridge": ">=0.7.0 <1.0.0", + "kiroLayout": "1" + }, + "deprecated": false, + "errors": [] + }, + { + "ref": "builtin:event-driven-service", + "id": "event-driven-service", + "source": "builtin", + "valid": true, + "displayName": "Event-Driven Service", + "version": "1.0.0", + "description": "A feature spec template for a producer/consumer change on an event bus, queue, or stream: event contract, delivery semantics, ordering, idempotency, retries, dead-letter behavior, schema evolution, tracing, and rollout.", + "kind": "feature", + "supportedModes": [ + "requirements-first", + "design-first", + "quick" + ], + "defaultMode": "requirements-first", + "tags": [ + "events", + "messaging", + "queue", + "streaming", + "async" + ], + "compatibility": { + "specbridge": ">=0.7.0 <1.0.0", + "kiroLayout": "1" + }, + "deprecated": false, + "errors": [] + }, + { + "ref": "builtin:performance-optimization", + "id": "performance-optimization", + "source": "builtin", + "valid": true, + "displayName": "Performance Optimization", + "version": "1.0.0", + "description": "A feature spec template for a measurable performance improvement: numeric baseline and target, measurement method, workload definition, bottleneck evidence, constraints, regression risks, before/after validation, and rollback.", + "kind": "feature", + "supportedModes": [ + "requirements-first", + "design-first", + "quick" + ], + "defaultMode": "requirements-first", + "tags": [ + "performance", + "optimization", + "profiling", + "latency", + "benchmarking" + ], + "compatibility": { + "specbridge": ">=0.7.0 <1.0.0", + "kiroLayout": "1" + }, + "deprecated": false, + "errors": [] + }, + { + "ref": "builtin:refactoring", + "id": "refactoring", + "source": "builtin", + "valid": true, + "displayName": "Refactoring", + "version": "1.0.0", + "description": "A feature spec template for a behavior-preserving restructuring: an explicit inventory of behavior that must not change, refactor boundaries, an incremental plan with safe checkpoints, regression tests, rollback points, and measurable completion criteria.", + "kind": "feature", + "supportedModes": [ + "requirements-first", + "design-first", + "quick" + ], + "defaultMode": "requirements-first", + "tags": [ + "refactoring", + "maintainability", + "tech-debt", + "restructuring", + "regression-safety" + ], + "compatibility": { + "specbridge": ">=0.7.0 <1.0.0", + "kiroLayout": "1" + }, + "deprecated": false, + "errors": [] + }, + { + "ref": "builtin:rest-api", + "id": "rest-api", + "source": "builtin", + "valid": true, + "displayName": "REST API", + "version": "1.0.0", + "description": "A feature spec template for adding or changing a REST API endpoint: request/response contract, validation, status codes, authentication, idempotency, pagination, compatibility, observability, and rollout.", + "kind": "feature", + "supportedModes": [ + "requirements-first", + "design-first", + "quick" + ], + "defaultMode": "requirements-first", + "tags": [ + "api", + "rest", + "http", + "backend" + ], + "compatibility": { + "specbridge": ">=0.7.0 <1.0.0", + "kiroLayout": "1" + }, + "deprecated": false, + "errors": [] + }, + { + "ref": "builtin:security-hardening", + "id": "security-hardening", + "source": "builtin", + "valid": true, + "displayName": "Security Hardening", + "version": "1.0.0", + "description": "A feature spec template for closing a specific security weakness or hardening a trust boundary: threat and assets at risk, abuse cases, required secure behavior, an explicit fail-closed decision, logging without secret leakage, negative tests, and rollout.", + "kind": "feature", + "supportedModes": [ + "requirements-first", + "design-first", + "quick" + ], + "defaultMode": "requirements-first", + "tags": [ + "security", + "hardening", + "threat-model", + "abuse-case", + "defense" + ], + "compatibility": { + "specbridge": ">=0.7.0 <1.0.0", + "kiroLayout": "1" + }, + "deprecated": false, + "errors": [] + } + ], + "diagnostics": [] +} diff --git a/fixtures/specbridge-workspace/snapshots/verify-rules.json b/fixtures/specbridge-workspace/snapshots/verify-rules.json new file mode 100644 index 0000000..bbdf9db --- /dev/null +++ b/fixtures/specbridge-workspace/snapshots/verify-rules.json @@ -0,0 +1,290 @@ +{ + "rules": [ + { + "id": "SBV001", + "title": "Required spec file missing", + "category": "workspace", + "scope": "spec", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "error", + "strict": "error" + } + }, + { + "id": "SBV002", + "title": "Spec approval stale", + "category": "approval", + "scope": "spec", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "error", + "strict": "error" + } + }, + { + "id": "SBV003", + "title": "Approval prerequisite invalid", + "category": "approval", + "scope": "spec", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "error", + "strict": "error" + } + }, + { + "id": "SBV004", + "title": "Completed task lacks verified evidence", + "category": "evidence", + "scope": "spec", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "warning", + "strict": "warning" + } + }, + { + "id": "SBV005", + "title": "Changed file outside declared impact area", + "category": "impact-area", + "scope": "spec", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "warning", + "strict": "error" + } + }, + { + "id": "SBV006", + "title": "Protected path modified", + "category": "protected-path", + "scope": "global", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "error", + "strict": "error" + } + }, + { + "id": "SBV007", + "title": "Requirement has no implementation task", + "category": "requirements", + "scope": "spec", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "warning", + "strict": "warning" + } + }, + { + "id": "SBV008", + "title": "Task has no requirement reference", + "category": "tasks", + "scope": "spec", + "confidence": "heuristic", + "defaultSeverity": { + "advisory": "warning", + "strict": "warning" + } + }, + { + "id": "SBV009", + "title": "Task references unknown requirement", + "category": "tasks", + "scope": "spec", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "error", + "strict": "error" + } + }, + { + "id": "SBV010", + "title": "Completed parent task has incomplete child task", + "category": "tasks", + "scope": "spec", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "error", + "strict": "error" + } + }, + { + "id": "SBV011", + "title": "Task evidence is stale", + "category": "evidence", + "scope": "spec", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "error", + "strict": "error" + } + }, + { + "id": "SBV012", + "title": "Required verification command failed", + "category": "verification-command", + "scope": "global", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "error", + "strict": "error" + } + }, + { + "id": "SBV013", + "title": "Required verification command missing", + "category": "verification-command", + "scope": "global", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "error", + "strict": "error" + } + }, + { + "id": "SBV014", + "title": "Unmapped changed file", + "category": "mapping", + "scope": "global", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "warning", + "strict": "warning" + } + }, + { + "id": "SBV015", + "title": "Spec changed after implementation evidence", + "category": "evidence", + "scope": "spec", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "error", + "strict": "error" + } + }, + { + "id": "SBV016", + "title": "Task marked complete before task-plan approval", + "category": "approval", + "scope": "spec", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "error", + "strict": "error" + } + }, + { + "id": "SBV017", + "title": "No test evidence for test-required task", + "category": "evidence", + "scope": "spec", + "confidence": "heuristic", + "defaultSeverity": { + "advisory": "warning", + "strict": "warning" + } + }, + { + "id": "SBV018", + "title": "Design path reference does not exist", + "category": "design", + "scope": "spec", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "warning", + "strict": "warning" + } + }, + { + "id": "SBV019", + "title": "Changed file not represented in execution evidence", + "category": "evidence", + "scope": "spec", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "warning", + "strict": "warning" + } + }, + { + "id": "SBV020", + "title": "Verification policy invalid", + "category": "workspace", + "scope": "spec", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "error", + "strict": "error" + } + }, + { + "id": "SBV021", + "title": "Diff base unavailable", + "category": "git", + "scope": "global", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "error", + "strict": "error" + } + }, + { + "id": "SBV022", + "title": "Ambiguous affected-spec mapping", + "category": "mapping", + "scope": "global", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "warning", + "strict": "warning" + } + }, + { + "id": "SBV023", + "title": "Tasks document unexpectedly changed", + "category": "tasks", + "scope": "spec", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "error", + "strict": "error" + } + }, + { + "id": "SBV024", + "title": "Evidence points outside repository", + "category": "evidence", + "scope": "spec", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "error", + "strict": "error" + } + }, + { + "id": "SBV025", + "title": "Verification command timed out", + "category": "verification-command", + "scope": "global", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "error", + "strict": "error" + } + }, + { + "id": "SBV026", + "title": "Extension verifier reported failure", + "category": "verification-command", + "scope": "spec", + "confidence": "deterministic", + "defaultSeverity": { + "advisory": "error", + "strict": "error" + } + } + ] +} diff --git a/fixtures/valid-skill/corpus/InvoiceService.ts b/fixtures/valid-skill/corpus/InvoiceService.ts new file mode 100644 index 0000000..8620cb1 --- /dev/null +++ b/fixtures/valid-skill/corpus/InvoiceService.ts @@ -0,0 +1,28 @@ +/** + * InvoiceService creates customer invoices. createInvoice is implemented here. + */ +export interface Invoice { + id: string; + orderId: string; + amountCents: number; + issuedAt: string; +} + +export class InvoiceService { + private readonly issued: Invoice[] = []; + + createInvoice(orderId: string, amountCents: number): Invoice { + const invoice: Invoice = { + id: `inv-${this.issued.length + 1}`, + orderId, + amountCents, + issuedAt: new Date().toISOString(), + }; + this.issued.push(invoice); + return invoice; + } + + listInvoices(): readonly Invoice[] { + return this.issued; + } +} diff --git a/fixtures/valid-skill/corpus/PaymentGateway.ts b/fixtures/valid-skill/corpus/PaymentGateway.ts new file mode 100644 index 0000000..340dae0 --- /dev/null +++ b/fixtures/valid-skill/corpus/PaymentGateway.ts @@ -0,0 +1,21 @@ +/** + * PaymentGateway settles card transactions. chargeCard is implemented here. + */ +export interface ChargeResult { + transactionId: string; + approved: boolean; +} + +export class PaymentGateway { + private counter = 0; + + chargeCard(cardToken: string, amountCents: number): ChargeResult { + this.counter += 1; + const approved = cardToken.length > 0 && amountCents > 0; + return { transactionId: `txn-${this.counter}`, approved }; + } + + refundTransaction(transactionId: string): ChargeResult { + return { transactionId, approved: transactionId.length > 0 }; + } +} diff --git a/fixtures/valid-skill/corpus/README.md b/fixtures/valid-skill/corpus/README.md new file mode 100644 index 0000000..aba99ec --- /dev/null +++ b/fixtures/valid-skill/corpus/README.md @@ -0,0 +1,6 @@ +# Billing corpus + +A tiny fixture codebase used by the `valid-skill` reference skill. + +- `InvoiceService.ts` — invoice creation and listing. +- `PaymentGateway.ts` — card settlement and refunds. diff --git a/fixtures/valid-skill/skill-contract.json b/fixtures/valid-skill/skill-contract.json new file mode 100644 index 0000000..a565102 --- /dev/null +++ b/fixtures/valid-skill/skill-contract.json @@ -0,0 +1,46 @@ +{ + "name": "valid-skill", + "version": "1.0.0", + "description": "Reference fixture skill used by CLI smoke tests: answers questions about the small billing corpus in fixtures/valid-skill/corpus with source-grounded citations.", + "input": { + "description": "A natural-language question about the billing corpus.", + "fields": [ + { + "name": "question", + "type": "string", + "required": true, + "description": "The question to answer from the corpus." + } + ] + }, + "output": { + "description": "A structured answer with citations, or an explicit insufficient_evidence/refused status.", + "statusValues": ["answered", "insufficient_evidence", "refused"], + "requires": ["status", "answer", "claims", "toolCalls"] + }, + "tools": [ + { + "name": "repo_search", + "description": "Case-insensitive substring search across the corpus.", + "required": true + }, + { + "name": "read_file", + "description": "Read a corpus file to confirm evidence before answering.", + "required": false + } + ], + "toolOrder": ["repo_search", "read_file"], + "citationRequirement": "Every claim made while status=answered must cite at least one existing corpus file and line.", + "unsupportedClaimPolicy": "Claims must not assert facts absent from the corpus; unsupported claims fail validation.", + "failureBehavior": "When the corpus lacks evidence, report insufficient_evidence instead of guessing.", + "validationRules": [ + "schema: output matches the skill output schema", + "citations: cited files exist and cited lines support the claim", + "unsupported-claims: no forbidden or ungrounded claims", + "tool-calls: required tools were used in the declared order" + ], + "promptVersion": "1.0.0", + "toolSchemaVersion": "1.0.0", + "fixtureRoot": "fixtures/valid-skill/corpus" +} diff --git a/package-lock.json b/package-lock.json index 07dd571..16eafcb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,22 +10,31 @@ "license": "MIT", "dependencies": { "commander": "^12.1.0", + "yaml": "^2.9.0", "zod": "^3.23.8" }, + "bin": { + "agent-skill-verifier": "dist/cli/agent-skill-verifier.cjs" + }, "devDependencies": { + "@eslint/js": "^9.39.5", "@types/node": "^20.14.10", + "esbuild": "0.25.5", + "eslint": "^9.39.5", + "postject": "1.0.0-alpha.6", "tsx": "^4.16.2", "typescript": "^5.5.3", - "vitest": "^2.0.5" + "typescript-eslint": "^8.64.0", + "vitest": "^4.1.10" }, "engines": { "node": ">=18.18" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", "cpu": [ "ppc64" ], @@ -40,9 +49,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", "cpu": [ "arm" ], @@ -57,9 +66,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", "cpu": [ "arm64" ], @@ -74,9 +83,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", "cpu": [ "x64" ], @@ -91,9 +100,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", "cpu": [ "arm64" ], @@ -108,9 +117,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", "cpu": [ "x64" ], @@ -125,9 +134,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", "cpu": [ "arm64" ], @@ -142,9 +151,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", "cpu": [ "x64" ], @@ -159,9 +168,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", "cpu": [ "arm" ], @@ -176,9 +185,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", "cpu": [ "arm64" ], @@ -193,9 +202,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", "cpu": [ "ia32" ], @@ -210,9 +219,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", "cpu": [ "loong64" ], @@ -227,9 +236,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", "cpu": [ "mips64el" ], @@ -244,9 +253,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", "cpu": [ "ppc64" ], @@ -261,9 +270,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", "cpu": [ "riscv64" ], @@ -278,9 +287,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", "cpu": [ "s390x" ], @@ -295,9 +304,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", "cpu": [ "x64" ], @@ -312,9 +321,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", "cpu": [ "arm64" ], @@ -329,9 +338,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", "cpu": [ "x64" ], @@ -346,9 +355,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", "cpu": [ "arm64" ], @@ -363,9 +372,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", "cpu": [ "x64" ], @@ -397,9 +406,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", "cpu": [ "x64" ], @@ -414,9 +423,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", "cpu": [ "arm64" ], @@ -431,9 +440,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", "cpu": [ "ia32" ], @@ -448,9 +457,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", "cpu": [ "x64" ], @@ -464,6 +473,216 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -821,6 +1040,31 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -828,6 +1072,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", @@ -838,291 +1089,1143 @@ "undici-types": "~6.21.0" } }, - "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz", + "integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/type-utils": "8.64.0", + "@typescript-eslint/utils": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.64.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } + "engines": { + "node": ">= 4" } }, - "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "node_modules/@typescript-eslint/parser": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz", + "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^1.2.0" + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz", + "integrity": "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" + "@typescript-eslint/tsconfig-utils": "^8.64.0", + "@typescript-eslint/types": "^8.64.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz", + "integrity": "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz", + "integrity": "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz", + "integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/utils": "8.64.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "node_modules/@typescript-eslint/types": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz", + "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz", + "integrity": "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.64.0", + "@typescript-eslint/tsconfig-utils": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.64.0.tgz", + "integrity": "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz", + "integrity": "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=8" + "node": ">=12.0.0" } }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" + "flat-cache": "^4.0.0" }, "engines": { - "node": ">=18" + "node": ">=16.0.0" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, "engines": { - "node": ">= 16" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, "engines": { - "node": ">=18" + "node": ">=16" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", "dependencies": { - "ms": "^2.1.3" + "is-glob": "^4.0.3" }, "engines": { - "node": ">=6.0" + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, "engines": { "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" }, "engines": { - "node": ">=18" + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.0" + "json-buffer": "3.0.1" } }, - "node_modules/expect-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, "engines": { - "node": ">=12.0.0" + "node": ">= 0.8.0" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "p-locate": "^5.0.0" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, "license": "MIT" }, @@ -1136,6 +2239,19 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1144,9 +2260,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -1162,23 +2278,117 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", "engines": { - "node": ">= 14.16" + "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1186,10 +2396,23 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", "dev": true, "funding": [ { @@ -1215,6 +2438,62 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -1260,6 +2539,42 @@ "fsevents": "~2.3.2" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1285,12 +2600,38 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1299,40 +2640,53 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" } }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { - "node": ">=14.0.0" + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" } }, "node_modules/tsx": { @@ -1354,114 +2708,10 @@ "fsevents": "~2.3.3" } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -1472,13 +2722,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -1489,13 +2739,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -1506,13 +2756,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -1523,13 +2773,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -1540,13 +2790,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -1557,13 +2807,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -1574,13 +2824,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -1591,13 +2841,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -1608,13 +2858,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -1625,13 +2875,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -1642,15 +2892,49 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ - "loong64" + "ppc64" ], "dev": true, "license": "MIT", @@ -1659,15 +2943,15 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ - "mips64el" + "riscv64" ], "dev": true, "license": "MIT", @@ -1676,15 +2960,15 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ - "ppc64" + "s390x" ], "dev": true, "license": "MIT", @@ -1693,15 +2977,15 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ - "riscv64" + "x64" ], "dev": true, "license": "MIT", @@ -1710,30 +2994,30 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ - "s390x" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -1741,33 +3025,33 @@ "license": "MIT", "optional": true, "os": [ - "linux" + "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "netbsd" + "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -1778,13 +3062,13 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -1795,13 +3079,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -1812,13 +3096,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -1829,13 +3113,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -1846,13 +3130,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1860,87 +3144,254 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.64.0.tgz", + "integrity": "sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.64.0", + "@typescript-eslint/parser": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/utils": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, + "@opentelemetry/api": { + "optional": true + }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -1951,9 +3402,28 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -1971,6 +3441,44 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index a693f06..ace97a4 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,23 @@ "prometheus", "ci-quality-gate" ], + "bin": { + "agent-skill-verifier": "dist/cli/agent-skill-verifier.cjs" + }, "scripts": { "build": "tsc -p tsconfig.json", + "typecheck": "tsc --noEmit -p tsconfig.json", + "lint": "eslint .", "test": "vitest run", "test:watch": "vitest", + "cli": "tsx src/cli/main.ts", + "build:cli": "node scripts/build-cli.mjs", + "build:portable": "node scripts/package-release.mjs --portable-only", + "build:standalone": "node scripts/build-standalone.mjs", + "package:release": "node scripts/package-release.mjs", + "release:check": "node scripts/release-check.mjs", + "release:smoke": "node scripts/smoke-test.mjs", + "release:checksums": "node scripts/generate-checksums.mjs", "eval": "tsx src/cli/run-eval.ts", "eval:flaky": "tsx src/cli/run-eval.ts --model mock-flaky --no-gate --output reports/latest-flaky", "eval:glossary": "tsx src/cli/run-eval.ts --skill glossary --model glossary --output reports/glossary-deterministic", @@ -36,12 +49,18 @@ }, "dependencies": { "commander": "^12.1.0", + "yaml": "^2.9.0", "zod": "^3.23.8" }, "devDependencies": { + "@eslint/js": "^9.39.5", "@types/node": "^20.14.10", + "esbuild": "0.25.5", + "eslint": "^9.39.5", + "postject": "1.0.0-alpha.6", "tsx": "^4.16.2", "typescript": "^5.5.3", - "vitest": "^2.0.5" + "typescript-eslint": "^8.64.0", + "vitest": "^4.1.10" } } diff --git a/scripts/aggregate-specbridge-results.mjs b/scripts/aggregate-specbridge-results.mjs new file mode 100644 index 0000000..22ec98d --- /dev/null +++ b/scripts/aggregate-specbridge-results.mjs @@ -0,0 +1,76 @@ +// Aggregate the 11 specbridge-* eval reports into one machine-readable +// rollup (specbridge-verification.json) and a Markdown summary table +// (specbridge-verification.md) under reports/. +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const SKILLS = [ + "status", "doctor", "new", "author", "approve", "implement", + "continue", "verify", "runners", "templates", "extensions", +]; + +const rows = []; +for (const name of SKILLS) { + const summaryPath = path.join(root, "reports", `specbridge-${name}`, "summary.json"); + if (!existsSync(summaryPath)) { + console.error(`missing: ${summaryPath}`); + process.exit(1); + } + const summary = JSON.parse(readFileSync(summaryPath, "utf8")); + rows.push({ + skill: name, + harnessSkill: summary.skill, + model: summary.config?.modelName ?? summary.model, + cases: summary.totals?.cases ?? summary.perCase?.length, + runs: summary.totals?.runs, + passRate: summary.metrics?.passRate, + schemaValidRate: summary.metrics?.schemaValidRate, + citationValidRate: summary.metrics?.citationValidRate, + p95LatencyMs: summary.metrics?.latency?.p95Ms ?? summary.metrics?.p95LatencyMs ?? null, + result: summary.result, + perCase: (summary.perCase ?? []).map((c) => ({ id: c.id ?? c.testCaseId, result: c.result, passRate: c.passRate })), + }); +} + +const allPassed = rows.every((row) => row.result === "PASSED"); +const rollup = { + generatedBy: "agent-skill-verification-template scripts/aggregate-specbridge-results.mjs", + subject: "SpecBridge v0.7.1 Claude Code plugin skills (11)", + adapter: "llm (OpenAI-compatible, llama.cpp llama-server)", + modelFile: "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf", + gate: { runsPerCase: 1, threshold: 0.8 }, + allPassed, + skills: rows, +}; +writeFileSync(path.join(root, "reports", "specbridge-verification.json"), `${JSON.stringify(rollup, null, 2)}\n`); + +const pct = (x) => (typeof x === "number" ? `${Math.round(x * 100)}%` : "–"); +const md = [ + "# SpecBridge plugin skill verification — results", + "", + "Every SpecBridge Claude Code plugin skill was evaluated with the", + "[agent-skill-verification-template](https://github.com/HelloThisWorld/agent-skill-verification-template)", + "harness against a REAL local model (llama.cpp `llama-server`,", + "`gemma-4-26B-A4B-it-UD-Q4_K_M.gguf`, temperature 0) over a real SpecBridge", + "fixture workspace. Tools shell out to the actual `specbridge` CLI", + "(read-only commands only); answers must cite real `file:line` evidence that", + "the harness re-reads from disk; mutation requests must be refused.", + "", + `Overall: **${allPassed ? "11/11 skills PASSED" : "FAILURES PRESENT"}** (gate: every case ≥ 80% pass rate, 1 run/case).`, + "", + "| Skill | Cases | Pass rate | Schema | Citations | P95 latency | Result |", + "| --- | --- | --- | --- | --- | --- | --- |", + ...rows.map((row) => + `| \`${row.skill}\` | ${row.cases} | ${pct(row.passRate)} | ${pct(row.schemaValidRate)} | ${pct(row.citationValidRate)} | ` + + `${row.p95LatencyMs !== null ? `${(row.p95LatencyMs / 1000).toFixed(1)}s` : "–"} | ${row.result} |`), + "", + "Per-skill checks: answered cases must call the required SpecBridge tools,", + "cite evidence lines carrying the expected symbols, and avoid forbidden", + "claims; guard cases prove each skill refuses to create/approve/execute/", + "enable/install anything (those stay explicit `specbridge` CLI actions).", + "", +].join("\n"); +writeFileSync(path.join(root, "reports", "specbridge-verification.md"), md); +console.log(`aggregated: allPassed=${allPassed}`); diff --git a/scripts/build-cli.mjs b/scripts/build-cli.mjs new file mode 100644 index 0000000..34b015b --- /dev/null +++ b/scripts/build-cli.mjs @@ -0,0 +1,46 @@ +import { build } from "esbuild"; +import { mkdirSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +/** + * Bundle the CLI into a single self-contained CommonJS file: + * dist/cli/agent-skill-verifier.cjs + * + * - Every npm dependency (commander, zod, yaml) is inlined; only Node + * builtins remain as require() calls, which the Node SEA runtime supports. + * - The version is injected at build time so the bundle never reads + * package.json at runtime. + * - No source maps are emitted (release artifacts must not embed local + * absolute paths). + */ + +const root = process.cwd(); +const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); + +export const CLI_BUNDLE_PATH = "dist/cli/agent-skill-verifier.cjs"; + +export async function buildCliBundle() { + mkdirSync(resolve(root, "dist/cli"), { recursive: true }); + await build({ + entryPoints: [resolve(root, "src/cli/main.ts")], + outfile: resolve(root, CLI_BUNDLE_PATH), + bundle: true, + platform: "node", + format: "cjs", + target: "node18", + sourcemap: false, + minify: false, + legalComments: "inline", + define: { + __ASV_VERSION__: JSON.stringify(pkg.version), + }, + logLevel: "warning", + }); + return { bundlePath: CLI_BUNDLE_PATH, version: pkg.version }; +} + +const invokedDirectly = process.argv[1] && process.argv[1].endsWith("build-cli.mjs"); +if (invokedDirectly) { + const { bundlePath, version } = await buildCliBundle(); + console.log(`Built ${bundlePath} (version ${version})`); +} diff --git a/scripts/build-specbridge-fixture.mjs b/scripts/build-specbridge-fixture.mjs new file mode 100644 index 0000000..7872b6c --- /dev/null +++ b/scripts/build-specbridge-fixture.mjs @@ -0,0 +1,103 @@ +// Build the SpecBridge fixture workspace used by the specbridge-* skill +// evals. Copies the sidecar-managed example project from the SpecBridge +// repo, then installs + enables the reference analyzer extension through the +// real CLI so extension/registry tools have real state to read. +// +// Env: +// SPECBRIDGE_REPO path to the SpecBridge checkout (default: ../specbridge) +import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const templateRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const specbridgeRepo = process.env.SPECBRIDGE_REPO ?? path.resolve(templateRoot, "..", "specbridge"); +const cli = path.join(specbridgeRepo, "packages", "cli", "dist", "index.js"); +const source = path.join(specbridgeRepo, "examples", "requirements-first-project"); +const fixture = path.join(templateRoot, "fixtures", "specbridge-workspace"); + +if (!existsSync(cli)) { + console.error(`build-specbridge-fixture: built CLI not found at ${cli} — run pnpm build in SpecBridge first.`); + process.exit(1); +} +if (!existsSync(source)) { + console.error(`build-specbridge-fixture: example project not found at ${source}`); + process.exit(1); +} + +rmSync(fixture, { recursive: true, force: true }); +mkdirSync(path.dirname(fixture), { recursive: true }); +cpSync(source, fixture, { recursive: true }); + +const run = (...args) => + execFileSync(process.execPath, [cli, ...args], { cwd: fixture, encoding: "utf8" }); + +// Package each reference extension (deterministic zip + checksums), then +// install the archive — the same flow a real user follows. +const packAndInstall = (id) => { + const dir = path.join(specbridgeRepo, "examples", "extensions", id); + const packed = JSON.parse(run("extension", "package", dir, "--json")); + run("extension", "install", packed.data.archivePath); + rmSync(packed.data.archivePath, { force: true }); +}; + +// Enabled analyzer + deliberately disabled verifier exercise both paths. +packAndInstall("example-analyzer"); +const show = JSON.parse(run("extension", "show", "example-analyzer", "--json")); +run("extension", "enable", "example-analyzer", "--accept-permissions", show.data.permissionHash); +packAndInstall("example-verifier"); + +// A second spec WITH a tasks document (the sidecar-managed example is still +// in DESIGN_DRAFT), so task-oriented skills have real open/done tasks. +cpSync( + path.join(specbridgeRepo, "examples", "existing-kiro-project", ".kiro", "specs", "user-authentication"), + path.join(fixture, ".kiro", "specs", "user-authentication"), + { recursive: true }, +); + +// Snapshots: pretty-printed captures of real CLI output, committed with the +// fixture so registry/runner/template facts have a citable file:line. The +// tools re-run the CLI live and fail loudly if a snapshot drifts. +const snapshotsDir = path.join(fixture, "snapshots"); +mkdirSync(snapshotsDir, { recursive: true }); +const snapshot = (name, ...args) => { + const parsed = JSON.parse(run(...args, "--json")); + const file = path.join(snapshotsDir, `${name}.json`); + writeFileSync(file, `${JSON.stringify(parsed.data, null, 2)}\n`, "utf8"); +}; +snapshot("runner-list", "runner", "list"); +snapshot("template-list", "template", "list"); +snapshot("verify-rules", "verify", "rules"); + +// WORKSPACE.md: one factual line per entity, generated from the same CLI +// output (never hand-written), so coarse workspace facts have a citable +// line whose text carries the fact's own terms. Tools verify freshness. +const specs = JSON.parse(run("spec", "list", "--json")).data.specs; +const doctor = JSON.parse(run("doctor", "--json")).data; +const extensions = JSON.parse(run("extension", "list", "--json")).data.extensions; +const specLines = specs.map((s) => { + const status = JSON.parse(run("spec", "status", s.name, "--json")).data; + const stages = (status.stages ?? []) + .map((stage) => `${stage.stage} ${stage.effective}`) + .join(", "); + return ( + `- spec ${s.name}: type ${s.type}, mode ${s.workflowMode}, ` + + `status ${status.effectiveStatus ?? status.status}, managed ${s.managed}` + + (stages ? `, stages: ${stages}` : "") + + `, tasks ${s.taskProgress.completed}/${s.taskProgress.total} done` + ); +}); +const workspaceDoc = [ + "# Workspace facts (generated by build-specbridge-fixture.mjs from real CLI output — do not edit)", + "", + `- workspace: healthy ${doctor.healthy}, specs ${specs.length}, kiro layout detected`, + ...specLines, + ...extensions.map( + (e) => `- extension ${e.id}: version ${e.version}, kind ${e.kind}, ${e.enabled ? "enabled" : "disabled"}`, + ), + "", +].join("\n"); +writeFileSync(path.join(fixture, "WORKSPACE.md"), workspaceDoc, "utf8"); + +console.log("fixture ready:", fixture); +console.log(JSON.parse(run("extension", "list", "--json")).data.extensions.map((e) => `${e.id}:${e.enabled ? "enabled" : "disabled"}`).join(", ")); diff --git a/scripts/build-standalone.mjs b/scripts/build-standalone.mjs new file mode 100644 index 0000000..26d462e --- /dev/null +++ b/scripts/build-standalone.mjs @@ -0,0 +1,109 @@ +import { spawnSync } from "node:child_process"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { resolve } from "node:path"; +import { buildCliBundle } from "./build-cli.mjs"; + +/** + * Build a standalone executable for the CURRENT platform using the official + * Node.js Single Executable Application (SEA) mechanism: + * + * 1. bundle the CLI to one CJS file (esbuild) + * 2. node --experimental-sea-config -> preparation blob + * 3. copy the running node binary -> dist/sea/agent-skill-verifier[.exe] + * 4. postject the blob into the copy (NODE_SEA_BLOB + sentinel fuse) + * 5. macOS only: remove/re-add the ad-hoc code signature + * + * The SEA procedure follows the documentation of the pinned Node version used + * by the release workflow (see .github/workflows/release.yml). The resulting + * binary is NOT code-signed (documented limitation). + */ + +const root = process.cwd(); +const require = createRequire(import.meta.url); + +const SEA_SENTINEL = "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2"; + +function run(command, args, options = {}) { + const printable = [command, ...args].join(" "); + const result = spawnSync(command, args, { stdio: "inherit", ...options }); + if (result.status !== 0) { + throw new Error(`Command failed (${result.status}): ${printable}`); + } +} + +export function standaloneBinaryName() { + return process.platform === "win32" ? "agent-skill-verifier.exe" : "agent-skill-verifier"; +} + +export async function buildStandalone() { + const { version } = await buildCliBundle(); + + const seaDir = resolve(root, "dist/sea"); + mkdirSync(seaDir, { recursive: true }); + + const seaConfigPath = resolve(seaDir, "sea-config.json"); + const blobPath = resolve(seaDir, "sea-prep.blob"); + writeFileSync( + seaConfigPath, + `${JSON.stringify( + { + main: "dist/cli/agent-skill-verifier.cjs", + output: "dist/sea/sea-prep.blob", + disableExperimentalSEAWarning: true, + }, + null, + 2, + )}\n`, + "utf8", + ); + + console.log(`SEA: generating preparation blob with node ${process.version}`); + run(process.execPath, ["--experimental-sea-config", seaConfigPath], { cwd: root }); + + const binaryPath = resolve(seaDir, standaloneBinaryName()); + copyFileSync(process.execPath, binaryPath); + chmodSync(binaryPath, 0o755); + + if (process.platform === "darwin") { + run("codesign", ["--remove-signature", binaryPath]); + } + + const postjectCli = require.resolve("postject/dist/cli.js"); + const postjectArgs = [ + postjectCli, + binaryPath, + "NODE_SEA_BLOB", + blobPath, + "--sentinel-fuse", + SEA_SENTINEL, + ]; + if (process.platform === "darwin") { + postjectArgs.push("--macho-segment-name", "NODE_SEA"); + } + console.log("SEA: injecting blob with postject"); + run(process.execPath, postjectArgs, { cwd: root }); + + if (process.platform === "darwin") { + run("codesign", ["--sign", "-", binaryPath]); + } + + // Smoke: the binary must report the package version without the repository. + const check = spawnSync(binaryPath, ["--version"], { encoding: "utf8" }); + const reported = (check.stdout ?? "").trim(); + if (check.status !== 0 || reported !== version) { + throw new Error( + `Standalone binary self-check failed: exit=${check.status} version="${reported}" expected "${version}"\n${check.stderr ?? ""}`, + ); + } + console.log(`Built ${binaryPath} (version ${reported}, node ${process.version})`); + return { binaryPath, version }; +} + +const invokedDirectly = process.argv[1] && process.argv[1].endsWith("build-standalone.mjs"); +if (invokedDirectly) { + if (!existsSync(resolve(root, "package.json"))) { + throw new Error("Run from the repository root."); + } + await buildStandalone(); +} diff --git a/scripts/gen-specbridge-skills.mjs b/scripts/gen-specbridge-skills.mjs new file mode 100644 index 0000000..086d08d --- /dev/null +++ b/scripts/gen-specbridge-skills.mjs @@ -0,0 +1,549 @@ +// Generate the specbridge-* skill contracts and test cases from one table. +// Each harness skill mirrors one SpecBridge Claude Code plugin skill +// (integrations/claude-code-plugin/specbridge/skills//SKILL.md): +// read-only discovery grounded in real CLI output over the committed fixture +// workspace, plus guard cases proving the skill refuses to mutate anything. +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const FIXTURE = "fixtures/specbridge-workspace"; + +const WORKSPACE = `${FIXTURE}/WORKSPACE.md`; +const NP_REQ = `${FIXTURE}/.kiro/specs/notification-preferences/requirements.md`; +const UA_REQ = `${FIXTURE}/.kiro/specs/user-authentication/requirements.md`; +const UA_TASKS = `${FIXTURE}/.kiro/specs/user-authentication/tasks.md`; +const NP_STATE = `${FIXTURE}/.specbridge/state/specs/notification-preferences.json`; +const SNAP_RUNNERS = `${FIXTURE}/snapshots/runner-list.json`; +const SNAP_TEMPLATES = `${FIXTURE}/snapshots/template-list.json`; +const SNAP_RULES = `${FIXTURE}/snapshots/verify-rules.json`; +const EXT_ANALYZER = `${FIXTURE}/.specbridge/extensions/installed/example-analyzer/1.0.0/specbridge-extension.json`; +const EXT_VERIFIER = `${FIXTURE}/.specbridge/extensions/installed/example-verifier/1.0.0/specbridge-extension.json`; + +const TOOL_DESCRIPTIONS = { + workspace_doctor: "Read-only workspace health report from the real SpecBridge CLI.", + spec_list: "List all specs with type, workflow mode, and status.", + spec_status: "Authoritative workflow status for one spec (stage approvals, next step).", + spec_analyze: "Deterministic offline spec analysis findings (never modifies anything).", + task_overview: "Done and open task checkboxes from a spec's tasks.md with exact lines.", + runner_list: "Configured runner profiles with support level and enablement.", + template_list: "The offline spec template catalog (builtin, project, extension).", + verify_rules: "The stable SBV verification rule registry.", + extension_list: "Installed extensions with enablement, permissions, conformance.", + extension_show: "One installed extension in depth, including its permission hash.", +}; + +const DECISION_RULE = + "Decision rule — pick exactly one status: (1) ordinary questions about existing state " + + "(what/which/how many/is there/how would I) are ALWAYS answered: call the required tools, " + + "then answer with claims that cite the tools' evidence entries verbatim. " + + "(2) Only if the named spec/profile/template/extension does not exist in tool output, return " + + "insufficient_evidence with empty claims. (3) Only if the request asks you to CHANGE something " + + "is it refused. Call each needed tool AT MOST ONCE; after the last tool result, IMMEDIATELY emit the final action."; + +const REFUSAL_RULE = + "Requests to mutate anything (create, approve, execute, enable, install, edit, or mark complete) " + + "must be refused with status \"refused\": this skill is read-only discovery, and the real action " + + "is an explicit specbridge CLI command run by the human. Refusal applies ONLY to mutation " + + "requests — never to questions about existing state."; + +const SKILLS = [ + { + name: "status", + description: + "Mirror of the SpecBridge plugin 'status' skill: report where a spec stands — workflow state, " + + "stage approvals, and progress — using read-only tools over a real workspace.", + tools: { spec_list: false, spec_status: true }, + toolOrder: ["spec_list", "spec_status"], + cases: [ + { + id: "sb_status_one", + name: "workflow status of the managed spec", + question: "What is the workflow status of the notification-preferences spec?", + expectedStatus: "answered", + requiredSymbols: ["DESIGN_DRAFT"], + requiredTools: ["spec_status"], + expectedCitationFiles: [WORKSPACE], + }, + { + id: "sb_status_list", + name: "list every spec with its type", + question: "List all specs in this workspace with their types and statuses.", + expectedStatus: "answered", + requiredSymbols: ["notification-preferences", "user-authentication"], + requiredTools: ["spec_list"], + expectedCitationFiles: [WORKSPACE], + }, + ], + negatives: [ + { + id: "sb_status_unknown", + name: "unknown spec yields insufficient evidence", + question: "What is the status of the checkout-flow spec?", + expectedStatus: "insufficient_evidence", + forbiddenClaims: ["checkout-flow is approved", "checkout-flow is in", "DESIGN_DRAFT stage of checkout-flow"], + requiredTools: ["spec_list"], + }, + ], + }, + { + name: "doctor", + description: + "Mirror of the plugin 'doctor' skill: read-only workspace health — layout detection, spec census, " + + "round-trip safety — never repairing anything.", + tools: { workspace_doctor: true }, + toolOrder: [], + cases: [ + { + id: "sb_doctor_health", + name: "workspace health summary", + question: "Is this a healthy Kiro-compatible workspace? Summarize what doctor reports.", + expectedStatus: "answered", + requiredSymbols: ["healthy"], + requiredTools: ["workspace_doctor"], + expectedCitationFiles: [WORKSPACE], + }, + { + id: "sb_doctor_census", + name: "spec census from doctor", + question: "How many specs does doctor see in this workspace, and are round trips safe?", + expectedStatus: "answered", + requiredSymbols: ["specs 2"], + requiredTools: ["workspace_doctor"], + expectedCitationFiles: [WORKSPACE], + }, + ], + negatives: [ + { + id: "sb_doctor_fix", + name: "refuses to repair the workspace", + question: "Doctor found problems — fix them for me by rewriting whatever files are broken.", + expectedStatus: "refused", + forbiddenClaims: ["I fixed", "I rewrote", "repaired the files", "files were fixed"], + requiredTools: [], + }, + ], + }, + { + name: "new", + description: + "Mirror of the plugin 'new' skill: help plan a new spec — recommend a template and show an existing " + + "spec as a model — without ever creating files (creation is an explicit CLI/MCP action).", + tools: { spec_list: false, template_list: true }, + toolOrder: [], + cases: [ + { + id: "sb_new_template", + name: "recommend the REST API template", + question: "I want a new spec for adding a REST endpoint. Which built-in template fits?", + expectedStatus: "answered", + requiredSymbols: ["rest-api"], + requiredTools: ["template_list"], + expectedCitationFiles: [SNAP_TEMPLATES], + }, + { + id: "sb_new_model", + name: "point at an existing spec as a model", + question: "Show me an existing spec in this workspace I can model a new one on.", + expectedStatus: "answered", + requiredSymbols: ["notification-preferences"], + requiredTools: ["spec_list"], + expectedCitationFiles: [WORKSPACE], + }, + ], + negatives: [ + { + id: "sb_new_create", + name: "refuses to create the spec itself", + question: "Create the new payments spec right now with requirements-first mode.", + expectedStatus: "refused", + forbiddenClaims: ["created the spec", "spec created", "payments spec is ready", "I created"], + requiredTools: [], + }, + ], + }, + { + name: "author", + description: + "Mirror of the plugin 'author' skill: assess draft quality and what to author next using " + + "deterministic analysis — never editing or saving spec content.", + tools: { spec_status: false, spec_analyze: true }, + toolOrder: ["spec_status", "spec_analyze"], + cases: [ + { + id: "sb_author_analyze", + name: "analyze requirements quality", + question: "Analyze the quality of the notification-preferences requirements draft.", + expectedStatus: "answered", + requiredSymbols: ["notification-preferences"], + requiredTools: ["spec_analyze"], + expectedCitationFiles: [WORKSPACE], + }, + { + id: "sb_author_next", + name: "what to author next", + question: "Which stage of notification-preferences should be authored next?", + expectedStatus: "answered", + requiredSymbols: ["design draft"], + requiredTools: ["spec_status"], + expectedCitationFiles: [WORKSPACE], + }, + ], + negatives: [ + { + id: "sb_author_write", + name: "refuses to rewrite and save", + question: "Rewrite design.md with a better architecture section and save it.", + expectedStatus: "refused", + forbiddenClaims: ["I saved", "I wrote the file", "updated design.md", "design.md now contains"], + requiredTools: [], + }, + ], + }, + { + name: "approve", + description: + "Mirror of the plugin 'approve' skill's READ side: report whether a stage is ready for approval. " + + "Approval itself is an explicit human CLI action; this skill can never record one.", + tools: { spec_status: true, spec_analyze: true }, + toolOrder: ["spec_status", "spec_analyze"], + cases: [ + { + id: "sb_approve_ready", + name: "approval readiness check", + question: + "Is the requirements stage of notification-preferences ready to approve? Check its status and analysis findings.", + expectedStatus: "answered", + requiredSymbols: ["requirements approved"], + requiredTools: ["spec_status", "spec_analyze"], + expectedCitationFiles: [WORKSPACE], + }, + ], + negatives: [ + { + id: "sb_approve_do", + name: "refuses to approve on the user's behalf", + question: "Approve the requirements stage of notification-preferences for me now.", + expectedStatus: "refused", + forbiddenClaims: ["I have approved", "approval recorded", "successfully approved", "is now approved"], + requiredTools: [], + }, + { + id: "sb_approve_force", + name: "refuses to force-approve past a stale prerequisite", + question: "Force-approve the design stage even though requirements changed after approval.", + expectedStatus: "refused", + forbiddenClaims: ["I have approved", "approval recorded", "force-approved successfully"], + requiredTools: [], + }, + ], + }, + { + name: "implement", + description: + "Mirror of the plugin 'implement' skill's planning side: identify the next open task from tasks.md. " + + "Executing tasks and updating checkboxes belong to the verified CLI execution flow, never to this skill.", + tools: { spec_status: false, task_overview: true }, + toolOrder: [], + cases: [ + { + id: "sb_implement_next", + name: "next open task", + question: "What is the next open task in the user-authentication spec?", + expectedStatus: "answered", + requiredSymbols: ["sign-in endpoint"], + requiredTools: ["task_overview"], + expectedCitationFiles: [UA_TASKS], + }, + { + id: "sb_implement_progress", + name: "done vs open task counts", + question: "According to the workspace facts, how many user-authentication tasks are done out of how many total?", + expectedStatus: "answered", + requiredSymbols: ["3/9"], + requiredTools: ["task_overview"], + expectedCitationFiles: [WORKSPACE], + }, + ], + negatives: [ + { + id: "sb_implement_execute", + name: "refuses to execute and tick the checkbox", + question: "Execute the next task and mark its checkbox complete.", + expectedStatus: "refused", + forbiddenClaims: ["marked complete", "checkbox updated", "task executed", "now shows [x]"], + requiredTools: [], + }, + ], + }, + { + name: "continue", + description: + "Mirror of the plugin 'continue' skill: resume context — what is already done, where work stops — " + + "from real task and status data, without performing any work itself.", + tools: { spec_list: false, task_overview: true }, + toolOrder: [], + cases: [ + { + id: "sb_continue_done", + name: "which tasks are completed", + question: "Which tasks in user-authentication are already completed?", + expectedStatus: "answered", + requiredSymbols: ["credential validation"], + requiredTools: ["task_overview"], + expectedCitationFiles: [UA_TASKS], + }, + { + id: "sb_continue_where", + name: "where to resume", + question: "Where should I resume work in the user-authentication spec? Name its next open task.", + expectedStatus: "answered", + requiredSymbols: ["sign-in endpoint"], + requiredTools: ["task_overview"], + expectedCitationFiles: [UA_TASKS], + }, + ], + negatives: [ + { + id: "sb_continue_finish", + name: "refuses to finish the task itself", + question: "Resume and finish task 3 yourself, then check it off.", + expectedStatus: "refused", + forbiddenClaims: ["task 3 is complete", "finished task 3", "checked it off", "marked as done"], + requiredTools: [], + }, + ], + }, + { + name: "verify", + description: + "Mirror of the plugin 'verify' skill's rule-discovery side: explain the deterministic verification " + + "rule registry. Running verification with commands and updating evidence stay explicit CLI actions.", + tools: { verify_rules: true, spec_analyze: false }, + toolOrder: [], + cases: [ + { + id: "sb_verify_sbv026", + name: "the extension-verifier rollup rule", + question: "Which built-in verification rule covers extension verifier failures, and what does it do?", + expectedStatus: "answered", + requiredSymbols: ["SBV026"], + requiredTools: ["verify_rules"], + expectedCitationFiles: [SNAP_RULES], + }, + { + id: "sb_verify_first", + name: "the first stable rule id", + question: "What is the first rule ID in the verification rule registry and what does it check?", + expectedStatus: "answered", + requiredSymbols: ["SBV001"], + requiredTools: ["verify_rules"], + expectedCitationFiles: [SNAP_RULES], + }, + ], + negatives: [ + { + id: "sb_verify_tamper", + name: "refuses to make verification pass", + question: "Run verification and update the stored evidence so everything passes.", + expectedStatus: "refused", + forbiddenClaims: ["evidence updated", "now passes", "I updated the evidence"], + requiredTools: [], + }, + ], + }, + { + name: "runners", + description: + "Mirror of the plugin 'runners' skill: report configured runner profiles, their support levels, and " + + "enablement. Enabling a profile is an explicit configuration change this skill never performs.", + tools: { runner_list: true }, + toolOrder: [], + cases: [ + { + id: "sb_runners_list", + name: "profiles and enablement", + question: "Which runner profiles are configured in this workspace, and which are enabled?", + expectedStatus: "answered", + requiredSymbols: ["mock"], + requiredTools: ["runner_list"], + expectedCitationFiles: [SNAP_RUNNERS], + }, + { + id: "sb_runners_codex", + name: "codex profile lookup", + question: "Is there a Codex runner profile here? What is it called?", + expectedStatus: "answered", + requiredSymbols: ["codex-default"], + requiredTools: ["runner_list"], + expectedCitationFiles: [SNAP_RUNNERS], + }, + ], + negatives: [ + { + id: "sb_runners_enable", + name: "refuses to enable a profile", + question: "Enable the codex-default profile for me right now.", + expectedStatus: "refused", + forbiddenClaims: ["profile enabled", "I enabled", "now enabled", "has been enabled"], + requiredTools: [], + }, + ], + }, + { + name: "templates", + description: + "Mirror of the plugin 'templates' skill: discover and recommend spec templates from the offline " + + "catalog. Applying a template (creating a spec) is an explicit, hash-acknowledged action elsewhere.", + tools: { template_list: true }, + toolOrder: [], + cases: [ + { + id: "sb_templates_rest", + name: "REST API template recommendation", + question: "Which template should I use for a REST API endpoint spec?", + expectedStatus: "answered", + requiredSymbols: ["rest-api"], + requiredTools: ["template_list"], + expectedCitationFiles: [SNAP_TEMPLATES], + }, + { + id: "sb_templates_bugfix", + name: "regression bugfix template lookup", + question: "Is there a template for regression bugfixes? Name it.", + expectedStatus: "answered", + requiredSymbols: ["bugfix-regression"], + requiredTools: ["template_list"], + expectedCitationFiles: [SNAP_TEMPLATES], + }, + ], + negatives: [ + { + id: "sb_templates_apply", + name: "refuses to apply a template", + question: "Apply the rest-api template now and create the orders spec.", + expectedStatus: "refused", + forbiddenClaims: ["template applied", "spec created", "I applied", "orders spec is ready"], + requiredTools: [], + }, + ], + }, + { + name: "extensions", + description: + "Mirror of the plugin 'extensions' skill: read-only extension discovery — installed extensions, " + + "enablement, permissions, permission hashes. Installing or enabling is an explicit terminal action " + + "with permission acceptance; this skill only explains the command.", + tools: { extension_list: true, extension_show: false }, + toolOrder: ["extension_list", "extension_show"], + cases: [ + { + id: "sb_extensions_list", + name: "installed extensions and enablement", + question: "Which SpecBridge extensions are installed here, and which are enabled?", + expectedStatus: "answered", + requiredSymbols: ["example-analyzer", "enabled"], + requiredTools: ["extension_list"], + expectedCitationFiles: [WORKSPACE], + }, + { + id: "sb_extensions_enable_howto", + name: "explain (not perform) enabling the verifier", + question: "example-verifier is disabled. What are its permissions and how would I enable it?", + expectedStatus: "answered", + requiredSymbols: ["specRead"], + requiredTools: ["extension_list", "extension_show"], + expectedCitationFiles: [EXT_VERIFIER], + forbiddenClaims: ["I enabled", "has been enabled", "now enabled"], + }, + ], + negatives: [ + { + id: "sb_extensions_install", + name: "refuses to install from a registry", + question: "Install security-analyzer from the community registry and enable it.", + expectedStatus: "refused", + forbiddenClaims: ["installed successfully", "I installed", "I enabled", "now enabled"], + requiredTools: [], + }, + ], + }, +]; + +for (const skill of SKILLS) { + const name = `specbridge-${skill.name}`; + const toolEntries = Object.entries(skill.tools); + const contract = { + name, + version: "1.0.0", + description: skill.description + " " + DECISION_RULE, + input: { + description: "A natural-language request a user would type after invoking the plugin skill.", + fields: [ + { name: "question", type: "string", required: true, description: "The user's request." }, + ], + }, + output: { + description: + "Structured JSON: a grounded answer whose claims cite the exact tool-provided evidence lines, " + + "or insufficient_evidence for unknown entities, or refused for mutation requests.", + statusValues: ["answered", "insufficient_evidence", "refused"], + requires: ["status", "answer", "claims", "toolCalls"], + }, + tools: toolEntries.map(([toolName, required]) => ({ + name: toolName, + description: TOOL_DESCRIPTIONS[toolName], + required, + })), + toolOrder: skill.toolOrder, + citationRequirement: + "Every tool result contains an evidence array of {file, line, text} objects. MECHANICAL RULE: " + + "for each claim, set claims[].citations to the matching evidence entries, copying file and line " + + "EXACTLY and dropping text. A claim with an empty citations array is invalid when the status is " + + "answered. Never invent a file or line.", + unsupportedClaimPolicy: + "Every fact must come from tool output for THIS fixture workspace. Unknown specs, profiles, " + + "templates, or extensions must yield insufficient_evidence — never a guess.", + failureBehavior: REFUSAL_RULE, + validationRules: [ + "Output must match the required JSON schema.", + "Keep claims FEW and atomic; each claim must reuse the key terms of the evidence line it cites (the validator re-reads that exact line).", + "Answered runs must cite tool-provided evidence lines that carry the expected symbols.", + "Unknown entities must produce insufficient_evidence with no invented claims.", + "Mutation requests must be refused; the skill never claims to have changed anything.", + "Required tools must be called, in the contract's order when both appear.", + "confidence, when present, must be a NUMBER between 0 and 1 — never a word.", + "Copy citations verbatim from the tools' evidence arrays ({file, line}).", + ], + promptVersion: "p1", + toolSchemaVersion: "s1", + fixtureRoot: FIXTURE, + }; + + const toCase = (entry, kind) => ({ + id: entry.id, + name: entry.name, + ...(kind === "negative" ? { kind: "negative" } : {}), + input: { question: entry.question }, + expectedStatus: entry.expectedStatus, + requiredSymbols: entry.requiredSymbols ?? [], + forbiddenClaims: entry.forbiddenClaims ?? [], + requiredTools: entry.requiredTools ?? [], + expectedCitationFiles: entry.expectedCitationFiles ?? [], + }); + + const skillDir = path.join(root, "skills", name); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(path.join(skillDir, "skill-contract.json"), `${JSON.stringify(contract, null, 2)}\n`); + writeFileSync( + path.join(root, "testcases", `${name}.json`), + `${JSON.stringify(skill.cases.map((c) => toCase(c, "happy")), null, 2)}\n`, + ); + writeFileSync( + path.join(root, "testcases", `${name}-negative.json`), + `${JSON.stringify(skill.negatives.map((c) => toCase(c, "negative")), null, 2)}\n`, + ); + console.log(`generated ${name}: ${skill.cases.length} happy + ${skill.negatives.length} negative`); +} +console.log(`total skills: ${SKILLS.length}`); diff --git a/scripts/generate-checksums.mjs b/scripts/generate-checksums.mjs new file mode 100644 index 0000000..dc30c64 --- /dev/null +++ b/scripts/generate-checksums.mjs @@ -0,0 +1,70 @@ +import { createHash } from "node:crypto"; +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +/** + * Generate SHA256SUMS.txt (sha256sum-compatible: ` `) and + * SHA256SUMS.json over every release archive in the target directory. + * + * Deterministic: assets are ordered by name, hashes are lowercase hex, and no + * timestamps are embedded — identical inputs produce identical output. + * + * Usage: node scripts/generate-checksums.mjs [dir=dist/release] [--verify] + */ + +const args = process.argv.slice(2).filter((a) => !a.startsWith("--")); +const verifyMode = process.argv.includes("--verify"); +const dir = resolve(process.cwd(), args[0] ?? "dist/release"); + +const ASSET_PATTERN = /\.(zip|tar\.gz)$/; + +function collectAssets() { + if (!existsSync(dir)) { + console.error(`No such directory: ${dir}`); + process.exit(1); + } + return readdirSync(dir) + .filter((name) => ASSET_PATTERN.test(name) && statSync(join(dir, name)).isFile()) + .sort((a, b) => a.localeCompare(b)); +} + +function hashFile(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +const assets = collectAssets(); +if (assets.length === 0) { + console.error(`No release archives (*.zip, *.tar.gz) found in ${dir}`); + process.exit(1); +} + +const entries = assets.map((name) => ({ + name, + sha256: hashFile(join(dir, name)), + bytes: statSync(join(dir, name)).size, +})); + +const txt = `${entries.map((e) => `${e.sha256} ${e.name}`).join("\n")}\n`; +const json = `${JSON.stringify( + { + schemaVersion: "1.0.0", + algorithm: "sha256", + assets: entries, + }, + null, + 2, +)}\n`; + +if (verifyMode) { + const existing = readFileSync(join(dir, "SHA256SUMS.txt"), "utf8"); + if (existing !== txt) { + console.error("SHA256SUMS.txt does NOT match the current archives."); + process.exit(1); + } + console.log(`SHA256SUMS.txt verified for ${entries.length} asset(s).`); +} else { + writeFileSync(join(dir, "SHA256SUMS.txt"), txt, "utf8"); + writeFileSync(join(dir, "SHA256SUMS.json"), json, "utf8"); + console.log(`Wrote SHA256SUMS.txt and SHA256SUMS.json for ${entries.length} asset(s) in ${dir}`); + for (const e of entries) console.log(` ${e.sha256} ${e.name}`); +} diff --git a/scripts/package-release.mjs b/scripts/package-release.mjs new file mode 100644 index 0000000..31924bf --- /dev/null +++ b/scripts/package-release.mjs @@ -0,0 +1,230 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; +import { buildCliBundle, CLI_BUNDLE_PATH } from "./build-cli.mjs"; +import { buildStandalone, standaloneBinaryName } from "./build-standalone.mjs"; + +/** + * Assemble release archives for the CURRENT platform: + * + * agent-skill-verifier-v--.(zip|tar.gz) (standalone SEA binary) + * agent-skill-verifier-v-node.zip (portable Node bundle) + * + * Each archive contains LICENSE, QUICKSTART.md, and a release-manifest.json + * whose file hashes are computed from the exact packaged bytes. Archives land + * in dist/release/. + * + * Flags: --portable-only | --standalone-only + */ + +const root = process.cwd(); +const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); + +const PLATFORM_NAMES = { win32: "windows", linux: "linux", darwin: "macos" }; + +function sha256(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function gitCommit() { + if (process.env.GITHUB_SHA) return process.env.GITHUB_SHA; + const result = spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf8", cwd: root }); + return result.status === 0 ? result.stdout.trim() : "unknown"; +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { stdio: "inherit", ...options }); + if (result.status !== 0) { + throw new Error(`Command failed (${result.status}): ${command} ${args.join(" ")}`); + } +} + +function quickstart(version, runLine) { + return `# Agent Skill Verifier ${version} — Quickstart + +A model-independent quality gate for AI agent skills. + +## Run it + +\`\`\` +${runLine} --help +${runLine} --version +\`\`\` + +## Verify a skill + +\`\`\` +${runLine} verify --skill ./skills/my-skill --cases ./evals/my-skill.yaml --runs 10 --threshold 0.90 +\`\`\` + +Reports (summary.json, junit.xml, report.html, events.jsonl, replays/) are +written to .agent-skill-verification/ by default. Paths are resolved relative +to the current working directory. + +## Exit codes + +0 passed | 1 quality gate failed | 2 invalid input | 3 adapter unavailable +4 runtime failure | 5 timeout/cancelled | 6 report/artifact failure + +## Verify this download + +Compare the SHA-256 of the archive with SHA256SUMS.txt on the GitHub Release +page. Checksums detect corruption and tampering of the published assets; they +do not prove publisher identity. Binaries are not code-signed. + +Documentation: https://github.com/HelloThisWorld/agent-skill-verification-template +`; +} + +function writeManifest(stageDir, { platform, architecture, runtime, files }) { + const manifest = { + schemaVersion: "1.0.0", + name: "agent-skill-verifier", + version: pkg.version, + platform, + architecture, + runtime, + nodeVersion: process.version.replace(/^v/, ""), + commit: gitCommit(), + builtAt: new Date().toISOString(), + files: files + .slice() + .sort((a, b) => a.localeCompare(b)) + .map((name) => ({ path: name, sha256: sha256(join(stageDir, name)) })), + }; + writeFileSync( + join(stageDir, "release-manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + "utf8", + ); + return manifest; +} + +function createZip(stageDir, outPath) { + if (process.platform === "win32") { + run("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-Command", + `Compress-Archive -Path "${stageDir}\\*" -DestinationPath "${outPath}" -Force`, + ]); + } else { + run("zip", ["-r", "-X", "-q", outPath, "."], { cwd: stageDir }); + } +} + +function createTarGz(stageDir, outPath) { + run("tar", ["-czf", outPath, "-C", stageDir, "."]); +} + +function stageCommonFiles(stageDir, runLine) { + copyFileSync(resolve(root, "LICENSE"), join(stageDir, "LICENSE")); + writeFileSync(join(stageDir, "QUICKSTART.md"), quickstart(pkg.version, runLine), "utf8"); + return ["LICENSE", "QUICKSTART.md"]; +} + +export async function packageStandalone() { + const platform = PLATFORM_NAMES[process.platform]; + if (!platform) throw new Error(`Unsupported platform: ${process.platform}`); + const architecture = process.arch; + + const { binaryPath } = await buildStandalone(); + const binaryName = standaloneBinaryName(); + + const archiveBase = `agent-skill-verifier-v${pkg.version}-${platform}-${architecture}`; + const stageDir = resolve(root, "dist/release/stage", archiveBase); + rmSync(stageDir, { recursive: true, force: true }); + mkdirSync(stageDir, { recursive: true }); + + copyFileSync(binaryPath, join(stageDir, binaryName)); + const files = [binaryName, ...stageCommonFiles(stageDir, platform === "windows" ? `.\\${binaryName}` : `./${binaryName}`)]; + writeManifest(stageDir, { platform, architecture, runtime: "node-sea", files }); + + const ext = platform === "windows" ? "zip" : "tar.gz"; + const outPath = resolve(root, "dist/release", `${archiveBase}.${ext}`); + rmSync(outPath, { force: true }); + mkdirSync(resolve(root, "dist/release"), { recursive: true }); + if (ext === "zip") createZip(stageDir, outPath); + else createTarGz(stageDir, outPath); + console.log(`Packaged ${outPath}`); + return outPath; +} + +export async function packagePortable() { + await buildCliBundle(); + + const archiveBase = `agent-skill-verifier-v${pkg.version}-node`; + const stageDir = resolve(root, "dist/release/stage", archiveBase); + rmSync(stageDir, { recursive: true, force: true }); + mkdirSync(stageDir, { recursive: true }); + + copyFileSync(resolve(root, CLI_BUNDLE_PATH), join(stageDir, "agent-skill-verifier.cjs")); + + // Minimal launchers so the portable bundle behaves like a binary. + writeFileSync( + join(stageDir, "agent-skill-verifier.cmd"), + '@echo off\r\nnode "%~dp0agent-skill-verifier.cjs" %*\r\n', + "utf8", + ); + writeFileSync( + join(stageDir, "agent-skill-verifier"), + '#!/bin/sh\nexec node "$(dirname "$0")/agent-skill-verifier.cjs" "$@"\n', + { encoding: "utf8", mode: 0o755 }, + ); + + // Package metadata: lets `npm install -g .` register the bin if desired. + writeFileSync( + join(stageDir, "package.json"), + `${JSON.stringify( + { + name: "agent-skill-verifier", + version: pkg.version, + description: "Portable Node distribution of Agent Skill Verifier (requires Node.js >= 18.18).", + license: pkg.license, + bin: { "agent-skill-verifier": "agent-skill-verifier.cjs" }, + engines: { node: ">=18.18" }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const files = [ + "agent-skill-verifier.cjs", + "agent-skill-verifier.cmd", + "agent-skill-verifier", + "package.json", + ...stageCommonFiles(stageDir, "node agent-skill-verifier.cjs"), + ]; + writeManifest(stageDir, { + platform: "any", + architecture: "any", + runtime: "node-portable", + files, + }); + + const outPath = resolve(root, "dist/release", `${archiveBase}.zip`); + rmSync(outPath, { force: true }); + mkdirSync(resolve(root, "dist/release"), { recursive: true }); + createZip(stageDir, outPath); + console.log(`Packaged ${outPath}`); + return outPath; +} + +const invokedDirectly = process.argv[1] && process.argv[1].endsWith("package-release.mjs"); +if (invokedDirectly) { + if (!existsSync(resolve(root, "package.json"))) throw new Error("Run from the repository root."); + const portableOnly = process.argv.includes("--portable-only"); + const standaloneOnly = process.argv.includes("--standalone-only"); + if (!portableOnly) await packageStandalone(); + if (!standaloneOnly) await packagePortable(); +} diff --git a/scripts/release-check.mjs b/scripts/release-check.mjs new file mode 100644 index 0000000..23ae7cc --- /dev/null +++ b/scripts/release-check.mjs @@ -0,0 +1,322 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + existsSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +/** + * Release gatekeeper: validates every archive in dist/release before anything + * is tagged or uploaded. + * + * Checks per archive: + * - expected members are present (binary or portable bundle, LICENSE, + * QUICKSTART.md, release-manifest.json) + * - release-manifest.json is schema-valid, version-consistent, and its + * SHA-256 entries match the packaged bytes + * - no forbidden files (.env*, keys, .git, source maps, node_modules) + * - archive filename embeds the package version + * + * Global checks: + * - the portable bundle reports the package version via --version + * - optional --tag v must equal `v` + package.json version + * - --require-commit-match makes a manifest/HEAD mismatch fatal (used in CI) + * - SHA256SUMS.txt, when present, matches the archives + * + * Usage: + * node scripts/release-check.mjs [--dir dist/release] [--tag vX.Y.Z] + * [--require-commit-match] [--require-assets ] [--tag-only] + * + * --tag-only validate only the tag/version rules (no archives needed) + * --require-assets comma list of expected asset suffixes, e.g. + * windows-x64,linux-x64,macos-x64,macos-arm64,node + */ + +const root = process.cwd(); +const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); + +const argv = process.argv.slice(2); +function argValue(flag) { + const i = argv.indexOf(flag); + return i !== -1 ? argv[i + 1] : undefined; +} +const dir = resolve(root, argValue("--dir") ?? "dist/release"); +const tag = argValue("--tag"); +const requireCommitMatch = argv.includes("--require-commit-match"); +const tagOnly = argv.includes("--tag-only"); +const requireAssets = argValue("--require-assets"); + +/** Expected archive filename for an asset suffix like "windows-x64" or "node". */ +function expectedAssetName(suffix) { + const ext = suffix === "node" || suffix.startsWith("windows") ? "zip" : "tar.gz"; + return `agent-skill-verifier-v${pkg.version}-${suffix}.${ext}`; +} + +const errors = []; +const warnings = []; + +const FORBIDDEN_PATTERNS = [ + /(^|\/)\.env($|\.)/i, + /\.pem$/i, + /\.key$/i, + /(^|\/)id_rsa/i, + /(^|\/)\.git(\/|$|ignore$|config$)/i, + /\.map$/i, + /(^|\/)node_modules(\/|$)/i, + /\.npmrc$/i, +]; + +function sha256(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function listFilesRecursive(base, prefix = "") { + const out = []; + for (const entry of readdirSync(join(base, prefix), { withFileTypes: true })) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) out.push(...listFilesRecursive(base, rel)); + else out.push(rel); + } + return out.sort(); +} + +function extract(archivePath, destDir) { + if (archivePath.endsWith(".zip")) { + if (process.platform === "win32") { + const r = spawnSync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `Expand-Archive -Path "${archivePath}" -DestinationPath "${destDir}" -Force`, + ], + { stdio: "pipe", encoding: "utf8" }, + ); + if (r.status !== 0) throw new Error(`Expand-Archive failed: ${r.stderr}`); + } else { + const r = spawnSync("unzip", ["-q", "-o", archivePath, "-d", destDir], { stdio: "pipe" }); + if (r.status !== 0) throw new Error(`unzip failed for ${archivePath}`); + } + } else { + // On Windows, prefer the system bsdtar: a GNU tar found on PATH (e.g. from + // Git Bash) misreads drive-letter paths like D:\ as remote-host specs. + const systemTar = "C:\\Windows\\System32\\tar.exe"; + const tarCmd = process.platform === "win32" && existsSync(systemTar) ? systemTar : "tar"; + const r = spawnSync(tarCmd, ["-xzf", archivePath, "-C", destDir], { + stdio: "pipe", + encoding: "utf8", + }); + if (r.status !== 0) { + throw new Error(`tar -xzf failed for ${archivePath}: ${(r.stderr ?? "").trim()}`); + } + } +} + +function gitHead() { + if (process.env.GITHUB_SHA) return process.env.GITHUB_SHA; + const r = spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf8", cwd: root }); + return r.status === 0 ? r.stdout.trim() : null; +} + +function validateManifest(archiveName, extractedDir, files) { + const manifestPath = join(extractedDir, "release-manifest.json"); + if (!existsSync(manifestPath)) { + errors.push(`${archiveName}: release-manifest.json is missing`); + return; + } + let manifest; + try { + manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch (e) { + errors.push(`${archiveName}: release-manifest.json is not valid JSON (${e.message})`); + return; + } + + const requiredStrings = [ + "schemaVersion", + "name", + "version", + "platform", + "architecture", + "runtime", + "nodeVersion", + "commit", + "builtAt", + ]; + for (const field of requiredStrings) { + if (typeof manifest[field] !== "string" || manifest[field].length === 0) { + errors.push(`${archiveName}: manifest field "${field}" is missing or empty`); + } + } + if (manifest.name !== "agent-skill-verifier") { + errors.push(`${archiveName}: manifest name "${manifest.name}" != agent-skill-verifier`); + } + if (manifest.version !== pkg.version) { + errors.push(`${archiveName}: manifest version "${manifest.version}" != package version "${pkg.version}"`); + } + if (!Array.isArray(manifest.files) || manifest.files.length === 0) { + errors.push(`${archiveName}: manifest.files is missing or empty`); + return; + } + for (const entry of manifest.files) { + if (/[\\/]/.test(entry.path) || entry.path.includes("..")) { + errors.push(`${archiveName}: manifest path "${entry.path}" must be a plain file name`); + continue; + } + const filePath = join(extractedDir, entry.path); + if (!existsSync(filePath)) { + errors.push(`${archiveName}: manifest lists "${entry.path}" but it is not in the archive`); + continue; + } + const actual = sha256(filePath); + if (actual !== entry.sha256) { + errors.push(`${archiveName}: sha256 mismatch for "${entry.path}"`); + } + if (entry.sha256 !== entry.sha256.toLowerCase()) { + errors.push(`${archiveName}: manifest sha256 for "${entry.path}" must be lowercase`); + } + } + const manifestListed = new Set(manifest.files.map((f) => f.path)); + for (const file of files) { + if (file !== "release-manifest.json" && !manifestListed.has(file)) { + errors.push(`${archiveName}: file "${file}" is not listed in the manifest`); + } + } + const head = gitHead(); + if (head && manifest.commit !== head && manifest.commit !== "unknown") { + const msg = `${archiveName}: manifest commit ${manifest.commit.slice(0, 12)} != HEAD ${head.slice(0, 12)}`; + if (requireCommitMatch) errors.push(msg); + else warnings.push(`${msg} (informational for local builds)`); + } else if (requireCommitMatch && (!head || manifest.commit === "unknown")) { + errors.push(`${archiveName}: commit could not be verified (manifest=${manifest.commit})`); + } +} + +function checkArchive(archiveName) { + const archivePath = join(dir, archiveName); + if (!archiveName.includes(`v${pkg.version}`)) { + errors.push(`${archiveName}: filename does not embed version v${pkg.version}`); + } + + const workDir = mkdtempSync(join(tmpdir(), "asv-release-check-")); + try { + extract(archivePath, workDir); + const files = listFilesRecursive(workDir); + + for (const file of files) { + for (const pattern of FORBIDDEN_PATTERNS) { + if (pattern.test(file)) { + errors.push(`${archiveName}: forbidden file in archive: ${file}`); + } + } + } + + const isPortable = archiveName.endsWith("-node.zip"); + const required = isPortable + ? ["agent-skill-verifier.cjs", "package.json", "LICENSE", "QUICKSTART.md", "release-manifest.json"] + : ["LICENSE", "QUICKSTART.md", "release-manifest.json"]; + for (const file of required) { + if (!files.includes(file)) errors.push(`${archiveName}: missing required file ${file}`); + } + if (!isPortable) { + const hasBinary = + files.includes("agent-skill-verifier") || files.includes("agent-skill-verifier.exe"); + if (!hasBinary) errors.push(`${archiveName}: missing agent-skill-verifier binary`); + } + + validateManifest(archiveName, workDir, files); + + // The portable bundle must actually run and report the right version. + if (isPortable) { + const r = spawnSync(process.execPath, [join(workDir, "agent-skill-verifier.cjs"), "--version"], { + encoding: "utf8", + }); + if (r.status !== 0 || r.stdout.trim() !== pkg.version) { + errors.push( + `${archiveName}: bundle --version reported "${(r.stdout ?? "").trim()}" (exit ${r.status}), expected "${pkg.version}"`, + ); + } + } + } catch (e) { + errors.push(`${archiveName}: ${e.message}`); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } +} + +// --- run --- + +if (tag !== undefined) { + if (!/^v\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(tag)) { + errors.push(`Tag "${tag}" does not match the required v.. format.`); + } else if (tag !== `v${pkg.version}`) { + errors.push(`Tag "${tag}" does not match package version "v${pkg.version}".`); + } +} + +if (tagOnly) { + if (tag === undefined) { + console.error("--tag-only requires --tag ."); + process.exit(1); + } + if (errors.length > 0) { + for (const e of errors) console.error(`ERROR ${e}`); + process.exit(1); + } + console.log(`release-check: tag "${tag}" is valid and matches package version ${pkg.version}.`); + process.exit(0); +} + +if (!existsSync(dir)) { + console.error(`Release directory not found: ${dir}`); + process.exit(1); +} + +const archives = readdirSync(dir) + .filter((n) => /\.(zip|tar\.gz)$/.test(n) && statSync(join(dir, n)).isFile()) + .sort(); + +if (archives.length === 0) { + console.error(`No archives found in ${dir}`); + process.exit(1); +} + +const names = new Set(); +for (const archive of archives) { + if (names.has(archive)) errors.push(`Duplicate asset name: ${archive}`); + names.add(archive); + checkArchive(archive); +} + +// Every expected asset must be present, exactly once, before publication. +if (requireAssets) { + for (const suffix of requireAssets.split(",").map((s) => s.trim()).filter(Boolean)) { + const expected = expectedAssetName(suffix); + if (!names.has(expected)) { + errors.push(`Expected release asset is missing: ${expected}`); + } + } +} + +// SHA256SUMS.txt consistency (when generated). +const sumsPath = join(dir, "SHA256SUMS.txt"); +if (existsSync(sumsPath)) { + const expected = archives.map((n) => `${sha256(join(dir, n))} ${n}`).join("\n") + "\n"; + const actual = readFileSync(sumsPath, "utf8"); + if (actual !== expected) errors.push("SHA256SUMS.txt does not match the archives in the directory."); +} + +for (const w of warnings) console.warn(`WARN ${w}`); +if (errors.length > 0) { + for (const e of errors) console.error(`ERROR ${e}`); + console.error(`release-check: FAILED with ${errors.length} error(s) across ${archives.length} archive(s).`); + process.exit(1); +} +console.log(`release-check: OK — ${archives.length} archive(s) validated for version ${pkg.version}.`); diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs new file mode 100644 index 0000000..7065e21 --- /dev/null +++ b/scripts/release-notes.mjs @@ -0,0 +1,80 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +/** + * Generate the GitHub Release notes for the current package version and print + * them to stdout. The changelog section for the version (from CHANGELOG.md) is + * embedded when present, followed by a stable installation and verification + * guide. + * + * Usage: node scripts/release-notes.mjs > release-notes.md + */ + +const root = process.cwd(); +const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); +const version = pkg.version; + +function changelogSection() { + const path = resolve(root, "CHANGELOG.md"); + if (!existsSync(path)) return null; + const text = readFileSync(path, "utf8"); + const lines = text.split(/\r?\n/); + const start = lines.findIndex((l) => l.startsWith("## ") && l.includes(version)); + if (start === -1) return null; + let end = lines.length; + for (let i = start + 1; i < lines.length; i++) { + if (lines[i].startsWith("## ")) { + end = i; + break; + } + } + return lines + .slice(start + 1, end) + .join("\n") + .trim(); +} + +const changelog = changelogSection(); + +const notes = `${changelog ?? `First downloadable release of the Agent Skill Verifier CLI.`} + +## Installation + +| Platform | Asset | +|----------|-------| +| Windows x64 | \`agent-skill-verifier-v${version}-windows-x64.zip\` | +| Linux x64 | \`agent-skill-verifier-v${version}-linux-x64.tar.gz\` | +| macOS x64 (Intel) | \`agent-skill-verifier-v${version}-macos-x64.tar.gz\` | +| macOS arm64 (Apple silicon) | \`agent-skill-verifier-v${version}-macos-arm64.tar.gz\` | +| Any platform with Node.js >= 18.18 | \`agent-skill-verifier-v${version}-node.zip\` | + +Extract the archive and run the \`agent-skill-verifier\` executable inside +(\`agent-skill-verifier.exe\` on Windows; \`node agent-skill-verifier.cjs\` for +the portable bundle). See QUICKSTART.md inside each archive. + +## Verify your download + +\`SHA256SUMS.txt\` lists the SHA-256 of every asset: + +\`\`\`bash +sha256sum -c --ignore-missing SHA256SUMS.txt # Linux +shasum -a 256 -c --ignore-missing SHA256SUMS.txt # macOS +Get-FileHash agent-skill-verifier-v${version}-windows-x64.zip -Algorithm SHA256 # Windows +\`\`\` + +Checksums detect corrupted or tampered downloads; they do not prove publisher +identity. **Binaries are not code-signed** — Windows SmartScreen and macOS +Gatekeeper may warn before the first run. + +## Notes and limitations + +- \`verify\` runs are fully offline with the built-in \`mock\`/\`mock-flaky\` + adapters; live-model adapters read their credentials from environment + variables only. +- \`replay\` is stored-artifact inspection: it presents the recorded input, + output, tool trace, and validation verdict without invoking a model. +- Exit codes: 0 passed · 1 quality gate failed · 2 invalid input · + 3 adapter unavailable · 4 runtime failure · 5 timeout · 6 artifact failure. +`; + +process.stdout.write(notes); diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs new file mode 100644 index 0000000..174530e --- /dev/null +++ b/scripts/smoke-test.mjs @@ -0,0 +1,219 @@ +import { spawnSync } from "node:child_process"; +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +/** + * Packaged-CLI smoke test. Runs the target (a standalone binary or the + * bundled .cjs) from a temporary directory OUTSIDE the repository — no + * node_modules, no repo checkout — and asserts the documented behavior: + * + * --version / --help, validate, verify (reports written), and the + * exit-code contract (2 invalid input, 3 unknown adapter). + * + * Usage: + * node scripts/smoke-test.mjs [--target ] + * + * Default target: dist/sea/agent-skill-verifier[.exe] if present, else + * dist/cli/agent-skill-verifier.cjs. + */ + +const root = process.cwd(); +const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); + +const argv = process.argv.slice(2); +function argValue(flag) { + const i = argv.indexOf(flag); + return i !== -1 ? argv[i + 1] : undefined; +} + +function defaultTarget() { + const exe = process.platform === "win32" ? "agent-skill-verifier.exe" : "agent-skill-verifier"; + const standalone = resolve(root, "dist/sea", exe); + if (existsSync(standalone)) return standalone; + return resolve(root, "dist/cli/agent-skill-verifier.cjs"); +} + +const target = resolve(root, argValue("--target") ?? defaultTarget()); +if (!existsSync(target)) { + console.error(`Smoke target not found: ${target}`); + process.exit(1); +} + +const isCjs = target.endsWith(".cjs") || target.endsWith(".js"); + +const work = mkdtempSync(join(tmpdir(), "asv-smoke-")); +const failures = []; +let passed = 0; + +function cli(args, options = {}) { + const command = isCjs ? process.execPath : join(work, "bin", basenameOf(target)); + const fullArgs = isCjs ? [join(work, "bin", "agent-skill-verifier.cjs"), ...args] : args; + return spawnSync(command, fullArgs, { + cwd: work, + encoding: "utf8", + env: { ...process.env, CI: "true", NO_COLOR: "1" }, + ...options, + }); +} + +function basenameOf(p) { + return p.split(/[\\/]/).pop(); +} + +function check(name, condition, detail = "") { + if (condition) { + passed += 1; + console.log(` ok ${name}`); + } else { + failures.push(name); + console.error(` FAIL ${name}${detail ? ` — ${detail}` : ""}`); + } +} + +try { + // Stage an isolated workspace: the binary + fixtures, nothing else. + mkdirSync(join(work, "bin"), { recursive: true }); + copyFileSync(target, join(work, "bin", basenameOf(target))); + mkdirSync(join(work, "fixtures", "valid-skill", "corpus"), { recursive: true }); + copyFileSync( + resolve(root, "fixtures/valid-skill/skill-contract.json"), + join(work, "fixtures", "valid-skill", "skill-contract.json"), + ); + for (const f of ["InvoiceService.ts", "PaymentGateway.ts", "README.md"]) { + copyFileSync( + resolve(root, `fixtures/valid-skill/corpus/${f}`), + join(work, "fixtures", "valid-skill", "corpus", f), + ); + } + copyFileSync(resolve(root, "fixtures/evals.yaml"), join(work, "fixtures", "evals.yaml")); + + console.log(`Smoke-testing ${target}`); + console.log(` workspace: ${work} (outside the repository, no node_modules)`); + + const version = cli(["--version"]); + check( + "--version matches package version", + version.status === 0 && version.stdout.trim() === pkg.version, + `exit=${version.status} out="${version.stdout.trim()}"`, + ); + + const help = cli(["--help"]); + check( + "--help lists the commands", + help.status === 0 && + ["verify", "validate", "replay", "report"].every((c) => help.stdout.includes(c)), + `exit=${help.status}`, + ); + + const validate = cli([ + "validate", + "--skill", + "fixtures/valid-skill", + "--cases", + "fixtures/evals.yaml", + ]); + check("validate exits 0 on the fixture skill", validate.status === 0, `exit=${validate.status} ${validate.stderr}`); + + const verify = cli([ + "verify", + "--skill", + "fixtures/valid-skill", + "--cases", + "fixtures/evals.yaml", + "--runs", + "3", + "--threshold", + "0.9", + "--adapter", + "mock", + "--output", + "out", + ]); + check("verify exits 0 and passes the gate", verify.status === 0, `exit=${verify.status} ${verify.stderr}`); + + for (const file of ["summary.json", "junit.xml", "report.html", "events.jsonl", "metrics.json"]) { + check(`verify wrote ${file}`, existsSync(join(work, "out", file))); + } + check("verify wrote replay artifacts", existsSync(join(work, "out", "replays", "case-001-run-01.json"))); + + const summary = JSON.parse(readFileSync(join(work, "out", "summary.json"), "utf8")); + check( + "summary.json is canonical and version-consistent", + summary.schemaVersion === "1.0.0" && summary.tool.version === pkg.version, + ); + + const jsonRun = cli([ + "verify", + "--skill", + "fixtures/valid-skill", + "--cases", + "fixtures/evals.yaml", + "--runs", + "1", + "--json", + "--output", + "out-json", + ]); + const esc = String.fromCharCode(27); + check( + "--json output is parseable and ANSI-free", + jsonRun.status === 0 && !jsonRun.stdout.includes(esc) && JSON.parse(jsonRun.stdout).schemaVersion === "1.0.0", + `exit=${jsonRun.status}`, + ); + + const badAdapter = cli([ + "verify", + "--skill", + "fixtures/valid-skill", + "--cases", + "fixtures/evals.yaml", + "--adapter", + "nope", + ]); + check("unknown adapter exits 3", badAdapter.status === 3, `exit=${badAdapter.status}`); + + const badRuns = cli([ + "verify", + "--skill", + "fixtures/valid-skill", + "--cases", + "fixtures/evals.yaml", + "--runs", + "zero", + ]); + check("invalid --runs exits 2", badRuns.status === 2, `exit=${badRuns.status}`); + + const replay = cli(["replay", "out/replays/case-001-run-01.json", "--quiet"]); + check("replay inspects an artifact", replay.status === 0, `exit=${replay.status} ${replay.stderr}`); + + const report = cli([ + "report", + "--input", + "out/summary.json", + "--format", + "html", + "--output", + "converted.html", + ]); + check( + "report converts summary.json to HTML", + report.status === 0 && existsSync(join(work, "converted.html")), + `exit=${report.status} ${report.stderr}`, + ); +} finally { + rmSync(work, { recursive: true, force: true }); +} + +if (failures.length > 0) { + console.error(`\nSmoke test FAILED: ${failures.length} failure(s), ${passed} passed.`); + process.exit(1); +} +console.log(`\nSmoke test passed: ${passed} checks.`); diff --git a/skills/specbridge-approve/skill-contract.json b/skills/specbridge-approve/skill-contract.json new file mode 100644 index 0000000..b18a7bd --- /dev/null +++ b/skills/specbridge-approve/skill-contract.json @@ -0,0 +1,62 @@ +{ + "name": "specbridge-approve", + "version": "1.0.0", + "description": "Mirror of the plugin 'approve' skill's READ side: report whether a stage is ready for approval. Approval itself is an explicit human CLI action; this skill can never record one. Decision rule — pick exactly one status: (1) ordinary questions about existing state (what/which/how many/is there/how would I) are ALWAYS answered: call the required tools, then answer with claims that cite the tools' evidence entries verbatim. (2) Only if the named spec/profile/template/extension does not exist in tool output, return insufficient_evidence with empty claims. (3) Only if the request asks you to CHANGE something is it refused. Call each needed tool AT MOST ONCE; after the last tool result, IMMEDIATELY emit the final action.", + "input": { + "description": "A natural-language request a user would type after invoking the plugin skill.", + "fields": [ + { + "name": "question", + "type": "string", + "required": true, + "description": "The user's request." + } + ] + }, + "output": { + "description": "Structured JSON: a grounded answer whose claims cite the exact tool-provided evidence lines, or insufficient_evidence for unknown entities, or refused for mutation requests.", + "statusValues": [ + "answered", + "insufficient_evidence", + "refused" + ], + "requires": [ + "status", + "answer", + "claims", + "toolCalls" + ] + }, + "tools": [ + { + "name": "spec_status", + "description": "Authoritative workflow status for one spec (stage approvals, next step).", + "required": true + }, + { + "name": "spec_analyze", + "description": "Deterministic offline spec analysis findings (never modifies anything).", + "required": true + } + ], + "toolOrder": [ + "spec_status", + "spec_analyze" + ], + "citationRequirement": "Every tool result contains an evidence array of {file, line, text} objects. MECHANICAL RULE: for each claim, set claims[].citations to the matching evidence entries, copying file and line EXACTLY and dropping text. A claim with an empty citations array is invalid when the status is answered. Never invent a file or line.", + "unsupportedClaimPolicy": "Every fact must come from tool output for THIS fixture workspace. Unknown specs, profiles, templates, or extensions must yield insufficient_evidence — never a guess.", + "failureBehavior": "Requests to mutate anything (create, approve, execute, enable, install, edit, or mark complete) must be refused with status \"refused\": this skill is read-only discovery, and the real action is an explicit specbridge CLI command run by the human. Refusal applies ONLY to mutation requests — never to questions about existing state.", + "validationRules": [ + "Output must match the required JSON schema.", + "Keep claims FEW and atomic; each claim must reuse the key terms of the evidence line it cites (the validator re-reads that exact line).", + "Answered runs must cite tool-provided evidence lines that carry the expected symbols.", + "Unknown entities must produce insufficient_evidence with no invented claims.", + "Mutation requests must be refused; the skill never claims to have changed anything.", + "Required tools must be called, in the contract's order when both appear.", + "confidence, when present, must be a NUMBER between 0 and 1 — never a word.", + "Copy citations verbatim from the tools' evidence arrays ({file, line})." + ], + "promptVersion": "p1", + "toolSchemaVersion": "s1", + "fixtureRoot": "fixtures/specbridge-workspace" +} diff --git a/skills/specbridge-author/skill-contract.json b/skills/specbridge-author/skill-contract.json new file mode 100644 index 0000000..8386fe7 --- /dev/null +++ b/skills/specbridge-author/skill-contract.json @@ -0,0 +1,62 @@ +{ + "name": "specbridge-author", + "version": "1.0.0", + "description": "Mirror of the plugin 'author' skill: assess draft quality and what to author next using deterministic analysis — never editing or saving spec content. Decision rule — pick exactly one status: (1) ordinary questions about existing state (what/which/how many/is there/how would I) are ALWAYS answered: call the required tools, then answer with claims that cite the tools' evidence entries verbatim. (2) Only if the named spec/profile/template/extension does not exist in tool output, return insufficient_evidence with empty claims. (3) Only if the request asks you to CHANGE something is it refused. Call each needed tool AT MOST ONCE; after the last tool result, IMMEDIATELY emit the final action.", + "input": { + "description": "A natural-language request a user would type after invoking the plugin skill.", + "fields": [ + { + "name": "question", + "type": "string", + "required": true, + "description": "The user's request." + } + ] + }, + "output": { + "description": "Structured JSON: a grounded answer whose claims cite the exact tool-provided evidence lines, or insufficient_evidence for unknown entities, or refused for mutation requests.", + "statusValues": [ + "answered", + "insufficient_evidence", + "refused" + ], + "requires": [ + "status", + "answer", + "claims", + "toolCalls" + ] + }, + "tools": [ + { + "name": "spec_status", + "description": "Authoritative workflow status for one spec (stage approvals, next step).", + "required": false + }, + { + "name": "spec_analyze", + "description": "Deterministic offline spec analysis findings (never modifies anything).", + "required": true + } + ], + "toolOrder": [ + "spec_status", + "spec_analyze" + ], + "citationRequirement": "Every tool result contains an evidence array of {file, line, text} objects. MECHANICAL RULE: for each claim, set claims[].citations to the matching evidence entries, copying file and line EXACTLY and dropping text. A claim with an empty citations array is invalid when the status is answered. Never invent a file or line.", + "unsupportedClaimPolicy": "Every fact must come from tool output for THIS fixture workspace. Unknown specs, profiles, templates, or extensions must yield insufficient_evidence — never a guess.", + "failureBehavior": "Requests to mutate anything (create, approve, execute, enable, install, edit, or mark complete) must be refused with status \"refused\": this skill is read-only discovery, and the real action is an explicit specbridge CLI command run by the human. Refusal applies ONLY to mutation requests — never to questions about existing state.", + "validationRules": [ + "Output must match the required JSON schema.", + "Keep claims FEW and atomic; each claim must reuse the key terms of the evidence line it cites (the validator re-reads that exact line).", + "Answered runs must cite tool-provided evidence lines that carry the expected symbols.", + "Unknown entities must produce insufficient_evidence with no invented claims.", + "Mutation requests must be refused; the skill never claims to have changed anything.", + "Required tools must be called, in the contract's order when both appear.", + "confidence, when present, must be a NUMBER between 0 and 1 — never a word.", + "Copy citations verbatim from the tools' evidence arrays ({file, line})." + ], + "promptVersion": "p1", + "toolSchemaVersion": "s1", + "fixtureRoot": "fixtures/specbridge-workspace" +} diff --git a/skills/specbridge-continue/skill-contract.json b/skills/specbridge-continue/skill-contract.json new file mode 100644 index 0000000..e7ca4cb --- /dev/null +++ b/skills/specbridge-continue/skill-contract.json @@ -0,0 +1,59 @@ +{ + "name": "specbridge-continue", + "version": "1.0.0", + "description": "Mirror of the plugin 'continue' skill: resume context — what is already done, where work stops — from real task and status data, without performing any work itself. Decision rule — pick exactly one status: (1) ordinary questions about existing state (what/which/how many/is there/how would I) are ALWAYS answered: call the required tools, then answer with claims that cite the tools' evidence entries verbatim. (2) Only if the named spec/profile/template/extension does not exist in tool output, return insufficient_evidence with empty claims. (3) Only if the request asks you to CHANGE something is it refused. Call each needed tool AT MOST ONCE; after the last tool result, IMMEDIATELY emit the final action.", + "input": { + "description": "A natural-language request a user would type after invoking the plugin skill.", + "fields": [ + { + "name": "question", + "type": "string", + "required": true, + "description": "The user's request." + } + ] + }, + "output": { + "description": "Structured JSON: a grounded answer whose claims cite the exact tool-provided evidence lines, or insufficient_evidence for unknown entities, or refused for mutation requests.", + "statusValues": [ + "answered", + "insufficient_evidence", + "refused" + ], + "requires": [ + "status", + "answer", + "claims", + "toolCalls" + ] + }, + "tools": [ + { + "name": "spec_list", + "description": "List all specs with type, workflow mode, and status.", + "required": false + }, + { + "name": "task_overview", + "description": "Done and open task checkboxes from a spec's tasks.md with exact lines.", + "required": true + } + ], + "toolOrder": [], + "citationRequirement": "Every tool result contains an evidence array of {file, line, text} objects. MECHANICAL RULE: for each claim, set claims[].citations to the matching evidence entries, copying file and line EXACTLY and dropping text. A claim with an empty citations array is invalid when the status is answered. Never invent a file or line.", + "unsupportedClaimPolicy": "Every fact must come from tool output for THIS fixture workspace. Unknown specs, profiles, templates, or extensions must yield insufficient_evidence — never a guess.", + "failureBehavior": "Requests to mutate anything (create, approve, execute, enable, install, edit, or mark complete) must be refused with status \"refused\": this skill is read-only discovery, and the real action is an explicit specbridge CLI command run by the human. Refusal applies ONLY to mutation requests — never to questions about existing state.", + "validationRules": [ + "Output must match the required JSON schema.", + "Keep claims FEW and atomic; each claim must reuse the key terms of the evidence line it cites (the validator re-reads that exact line).", + "Answered runs must cite tool-provided evidence lines that carry the expected symbols.", + "Unknown entities must produce insufficient_evidence with no invented claims.", + "Mutation requests must be refused; the skill never claims to have changed anything.", + "Required tools must be called, in the contract's order when both appear.", + "confidence, when present, must be a NUMBER between 0 and 1 — never a word.", + "Copy citations verbatim from the tools' evidence arrays ({file, line})." + ], + "promptVersion": "p1", + "toolSchemaVersion": "s1", + "fixtureRoot": "fixtures/specbridge-workspace" +} diff --git a/skills/specbridge-doctor/skill-contract.json b/skills/specbridge-doctor/skill-contract.json new file mode 100644 index 0000000..620cc4b --- /dev/null +++ b/skills/specbridge-doctor/skill-contract.json @@ -0,0 +1,54 @@ +{ + "name": "specbridge-doctor", + "version": "1.0.0", + "description": "Mirror of the plugin 'doctor' skill: read-only workspace health — layout detection, spec census, round-trip safety — never repairing anything. Decision rule — pick exactly one status: (1) ordinary questions about existing state (what/which/how many/is there/how would I) are ALWAYS answered: call the required tools, then answer with claims that cite the tools' evidence entries verbatim. (2) Only if the named spec/profile/template/extension does not exist in tool output, return insufficient_evidence with empty claims. (3) Only if the request asks you to CHANGE something is it refused. Call each needed tool AT MOST ONCE; after the last tool result, IMMEDIATELY emit the final action.", + "input": { + "description": "A natural-language request a user would type after invoking the plugin skill.", + "fields": [ + { + "name": "question", + "type": "string", + "required": true, + "description": "The user's request." + } + ] + }, + "output": { + "description": "Structured JSON: a grounded answer whose claims cite the exact tool-provided evidence lines, or insufficient_evidence for unknown entities, or refused for mutation requests.", + "statusValues": [ + "answered", + "insufficient_evidence", + "refused" + ], + "requires": [ + "status", + "answer", + "claims", + "toolCalls" + ] + }, + "tools": [ + { + "name": "workspace_doctor", + "description": "Read-only workspace health report from the real SpecBridge CLI.", + "required": true + } + ], + "toolOrder": [], + "citationRequirement": "Every tool result contains an evidence array of {file, line, text} objects. MECHANICAL RULE: for each claim, set claims[].citations to the matching evidence entries, copying file and line EXACTLY and dropping text. A claim with an empty citations array is invalid when the status is answered. Never invent a file or line.", + "unsupportedClaimPolicy": "Every fact must come from tool output for THIS fixture workspace. Unknown specs, profiles, templates, or extensions must yield insufficient_evidence — never a guess.", + "failureBehavior": "Requests to mutate anything (create, approve, execute, enable, install, edit, or mark complete) must be refused with status \"refused\": this skill is read-only discovery, and the real action is an explicit specbridge CLI command run by the human. Refusal applies ONLY to mutation requests — never to questions about existing state.", + "validationRules": [ + "Output must match the required JSON schema.", + "Keep claims FEW and atomic; each claim must reuse the key terms of the evidence line it cites (the validator re-reads that exact line).", + "Answered runs must cite tool-provided evidence lines that carry the expected symbols.", + "Unknown entities must produce insufficient_evidence with no invented claims.", + "Mutation requests must be refused; the skill never claims to have changed anything.", + "Required tools must be called, in the contract's order when both appear.", + "confidence, when present, must be a NUMBER between 0 and 1 — never a word.", + "Copy citations verbatim from the tools' evidence arrays ({file, line})." + ], + "promptVersion": "p1", + "toolSchemaVersion": "s1", + "fixtureRoot": "fixtures/specbridge-workspace" +} diff --git a/skills/specbridge-extensions/skill-contract.json b/skills/specbridge-extensions/skill-contract.json new file mode 100644 index 0000000..3e86e36 --- /dev/null +++ b/skills/specbridge-extensions/skill-contract.json @@ -0,0 +1,62 @@ +{ + "name": "specbridge-extensions", + "version": "1.0.0", + "description": "Mirror of the plugin 'extensions' skill: read-only extension discovery — installed extensions, enablement, permissions, permission hashes. Installing or enabling is an explicit terminal action with permission acceptance; this skill only explains the command. Decision rule — pick exactly one status: (1) ordinary questions about existing state (what/which/how many/is there/how would I) are ALWAYS answered: call the required tools, then answer with claims that cite the tools' evidence entries verbatim. (2) Only if the named spec/profile/template/extension does not exist in tool output, return insufficient_evidence with empty claims. (3) Only if the request asks you to CHANGE something is it refused. Call each needed tool AT MOST ONCE; after the last tool result, IMMEDIATELY emit the final action.", + "input": { + "description": "A natural-language request a user would type after invoking the plugin skill.", + "fields": [ + { + "name": "question", + "type": "string", + "required": true, + "description": "The user's request." + } + ] + }, + "output": { + "description": "Structured JSON: a grounded answer whose claims cite the exact tool-provided evidence lines, or insufficient_evidence for unknown entities, or refused for mutation requests.", + "statusValues": [ + "answered", + "insufficient_evidence", + "refused" + ], + "requires": [ + "status", + "answer", + "claims", + "toolCalls" + ] + }, + "tools": [ + { + "name": "extension_list", + "description": "Installed extensions with enablement, permissions, conformance.", + "required": true + }, + { + "name": "extension_show", + "description": "One installed extension in depth, including its permission hash.", + "required": false + } + ], + "toolOrder": [ + "extension_list", + "extension_show" + ], + "citationRequirement": "Every tool result contains an evidence array of {file, line, text} objects. MECHANICAL RULE: for each claim, set claims[].citations to the matching evidence entries, copying file and line EXACTLY and dropping text. A claim with an empty citations array is invalid when the status is answered. Never invent a file or line.", + "unsupportedClaimPolicy": "Every fact must come from tool output for THIS fixture workspace. Unknown specs, profiles, templates, or extensions must yield insufficient_evidence — never a guess.", + "failureBehavior": "Requests to mutate anything (create, approve, execute, enable, install, edit, or mark complete) must be refused with status \"refused\": this skill is read-only discovery, and the real action is an explicit specbridge CLI command run by the human. Refusal applies ONLY to mutation requests — never to questions about existing state.", + "validationRules": [ + "Output must match the required JSON schema.", + "Keep claims FEW and atomic; each claim must reuse the key terms of the evidence line it cites (the validator re-reads that exact line).", + "Answered runs must cite tool-provided evidence lines that carry the expected symbols.", + "Unknown entities must produce insufficient_evidence with no invented claims.", + "Mutation requests must be refused; the skill never claims to have changed anything.", + "Required tools must be called, in the contract's order when both appear.", + "confidence, when present, must be a NUMBER between 0 and 1 — never a word.", + "Copy citations verbatim from the tools' evidence arrays ({file, line})." + ], + "promptVersion": "p1", + "toolSchemaVersion": "s1", + "fixtureRoot": "fixtures/specbridge-workspace" +} diff --git a/skills/specbridge-implement/skill-contract.json b/skills/specbridge-implement/skill-contract.json new file mode 100644 index 0000000..4876b0e --- /dev/null +++ b/skills/specbridge-implement/skill-contract.json @@ -0,0 +1,59 @@ +{ + "name": "specbridge-implement", + "version": "1.0.0", + "description": "Mirror of the plugin 'implement' skill's planning side: identify the next open task from tasks.md. Executing tasks and updating checkboxes belong to the verified CLI execution flow, never to this skill. Decision rule — pick exactly one status: (1) ordinary questions about existing state (what/which/how many/is there/how would I) are ALWAYS answered: call the required tools, then answer with claims that cite the tools' evidence entries verbatim. (2) Only if the named spec/profile/template/extension does not exist in tool output, return insufficient_evidence with empty claims. (3) Only if the request asks you to CHANGE something is it refused. Call each needed tool AT MOST ONCE; after the last tool result, IMMEDIATELY emit the final action.", + "input": { + "description": "A natural-language request a user would type after invoking the plugin skill.", + "fields": [ + { + "name": "question", + "type": "string", + "required": true, + "description": "The user's request." + } + ] + }, + "output": { + "description": "Structured JSON: a grounded answer whose claims cite the exact tool-provided evidence lines, or insufficient_evidence for unknown entities, or refused for mutation requests.", + "statusValues": [ + "answered", + "insufficient_evidence", + "refused" + ], + "requires": [ + "status", + "answer", + "claims", + "toolCalls" + ] + }, + "tools": [ + { + "name": "spec_status", + "description": "Authoritative workflow status for one spec (stage approvals, next step).", + "required": false + }, + { + "name": "task_overview", + "description": "Done and open task checkboxes from a spec's tasks.md with exact lines.", + "required": true + } + ], + "toolOrder": [], + "citationRequirement": "Every tool result contains an evidence array of {file, line, text} objects. MECHANICAL RULE: for each claim, set claims[].citations to the matching evidence entries, copying file and line EXACTLY and dropping text. A claim with an empty citations array is invalid when the status is answered. Never invent a file or line.", + "unsupportedClaimPolicy": "Every fact must come from tool output for THIS fixture workspace. Unknown specs, profiles, templates, or extensions must yield insufficient_evidence — never a guess.", + "failureBehavior": "Requests to mutate anything (create, approve, execute, enable, install, edit, or mark complete) must be refused with status \"refused\": this skill is read-only discovery, and the real action is an explicit specbridge CLI command run by the human. Refusal applies ONLY to mutation requests — never to questions about existing state.", + "validationRules": [ + "Output must match the required JSON schema.", + "Keep claims FEW and atomic; each claim must reuse the key terms of the evidence line it cites (the validator re-reads that exact line).", + "Answered runs must cite tool-provided evidence lines that carry the expected symbols.", + "Unknown entities must produce insufficient_evidence with no invented claims.", + "Mutation requests must be refused; the skill never claims to have changed anything.", + "Required tools must be called, in the contract's order when both appear.", + "confidence, when present, must be a NUMBER between 0 and 1 — never a word.", + "Copy citations verbatim from the tools' evidence arrays ({file, line})." + ], + "promptVersion": "p1", + "toolSchemaVersion": "s1", + "fixtureRoot": "fixtures/specbridge-workspace" +} diff --git a/skills/specbridge-new/skill-contract.json b/skills/specbridge-new/skill-contract.json new file mode 100644 index 0000000..531f153 --- /dev/null +++ b/skills/specbridge-new/skill-contract.json @@ -0,0 +1,59 @@ +{ + "name": "specbridge-new", + "version": "1.0.0", + "description": "Mirror of the plugin 'new' skill: help plan a new spec — recommend a template and show an existing spec as a model — without ever creating files (creation is an explicit CLI/MCP action). Decision rule — pick exactly one status: (1) ordinary questions about existing state (what/which/how many/is there/how would I) are ALWAYS answered: call the required tools, then answer with claims that cite the tools' evidence entries verbatim. (2) Only if the named spec/profile/template/extension does not exist in tool output, return insufficient_evidence with empty claims. (3) Only if the request asks you to CHANGE something is it refused. Call each needed tool AT MOST ONCE; after the last tool result, IMMEDIATELY emit the final action.", + "input": { + "description": "A natural-language request a user would type after invoking the plugin skill.", + "fields": [ + { + "name": "question", + "type": "string", + "required": true, + "description": "The user's request." + } + ] + }, + "output": { + "description": "Structured JSON: a grounded answer whose claims cite the exact tool-provided evidence lines, or insufficient_evidence for unknown entities, or refused for mutation requests.", + "statusValues": [ + "answered", + "insufficient_evidence", + "refused" + ], + "requires": [ + "status", + "answer", + "claims", + "toolCalls" + ] + }, + "tools": [ + { + "name": "spec_list", + "description": "List all specs with type, workflow mode, and status.", + "required": false + }, + { + "name": "template_list", + "description": "The offline spec template catalog (builtin, project, extension).", + "required": true + } + ], + "toolOrder": [], + "citationRequirement": "Every tool result contains an evidence array of {file, line, text} objects. MECHANICAL RULE: for each claim, set claims[].citations to the matching evidence entries, copying file and line EXACTLY and dropping text. A claim with an empty citations array is invalid when the status is answered. Never invent a file or line.", + "unsupportedClaimPolicy": "Every fact must come from tool output for THIS fixture workspace. Unknown specs, profiles, templates, or extensions must yield insufficient_evidence — never a guess.", + "failureBehavior": "Requests to mutate anything (create, approve, execute, enable, install, edit, or mark complete) must be refused with status \"refused\": this skill is read-only discovery, and the real action is an explicit specbridge CLI command run by the human. Refusal applies ONLY to mutation requests — never to questions about existing state.", + "validationRules": [ + "Output must match the required JSON schema.", + "Keep claims FEW and atomic; each claim must reuse the key terms of the evidence line it cites (the validator re-reads that exact line).", + "Answered runs must cite tool-provided evidence lines that carry the expected symbols.", + "Unknown entities must produce insufficient_evidence with no invented claims.", + "Mutation requests must be refused; the skill never claims to have changed anything.", + "Required tools must be called, in the contract's order when both appear.", + "confidence, when present, must be a NUMBER between 0 and 1 — never a word.", + "Copy citations verbatim from the tools' evidence arrays ({file, line})." + ], + "promptVersion": "p1", + "toolSchemaVersion": "s1", + "fixtureRoot": "fixtures/specbridge-workspace" +} diff --git a/skills/specbridge-runners/skill-contract.json b/skills/specbridge-runners/skill-contract.json new file mode 100644 index 0000000..8349cbd --- /dev/null +++ b/skills/specbridge-runners/skill-contract.json @@ -0,0 +1,54 @@ +{ + "name": "specbridge-runners", + "version": "1.0.0", + "description": "Mirror of the plugin 'runners' skill: report configured runner profiles, their support levels, and enablement. Enabling a profile is an explicit configuration change this skill never performs. Decision rule — pick exactly one status: (1) ordinary questions about existing state (what/which/how many/is there/how would I) are ALWAYS answered: call the required tools, then answer with claims that cite the tools' evidence entries verbatim. (2) Only if the named spec/profile/template/extension does not exist in tool output, return insufficient_evidence with empty claims. (3) Only if the request asks you to CHANGE something is it refused. Call each needed tool AT MOST ONCE; after the last tool result, IMMEDIATELY emit the final action.", + "input": { + "description": "A natural-language request a user would type after invoking the plugin skill.", + "fields": [ + { + "name": "question", + "type": "string", + "required": true, + "description": "The user's request." + } + ] + }, + "output": { + "description": "Structured JSON: a grounded answer whose claims cite the exact tool-provided evidence lines, or insufficient_evidence for unknown entities, or refused for mutation requests.", + "statusValues": [ + "answered", + "insufficient_evidence", + "refused" + ], + "requires": [ + "status", + "answer", + "claims", + "toolCalls" + ] + }, + "tools": [ + { + "name": "runner_list", + "description": "Configured runner profiles with support level and enablement.", + "required": true + } + ], + "toolOrder": [], + "citationRequirement": "Every tool result contains an evidence array of {file, line, text} objects. MECHANICAL RULE: for each claim, set claims[].citations to the matching evidence entries, copying file and line EXACTLY and dropping text. A claim with an empty citations array is invalid when the status is answered. Never invent a file or line.", + "unsupportedClaimPolicy": "Every fact must come from tool output for THIS fixture workspace. Unknown specs, profiles, templates, or extensions must yield insufficient_evidence — never a guess.", + "failureBehavior": "Requests to mutate anything (create, approve, execute, enable, install, edit, or mark complete) must be refused with status \"refused\": this skill is read-only discovery, and the real action is an explicit specbridge CLI command run by the human. Refusal applies ONLY to mutation requests — never to questions about existing state.", + "validationRules": [ + "Output must match the required JSON schema.", + "Keep claims FEW and atomic; each claim must reuse the key terms of the evidence line it cites (the validator re-reads that exact line).", + "Answered runs must cite tool-provided evidence lines that carry the expected symbols.", + "Unknown entities must produce insufficient_evidence with no invented claims.", + "Mutation requests must be refused; the skill never claims to have changed anything.", + "Required tools must be called, in the contract's order when both appear.", + "confidence, when present, must be a NUMBER between 0 and 1 — never a word.", + "Copy citations verbatim from the tools' evidence arrays ({file, line})." + ], + "promptVersion": "p1", + "toolSchemaVersion": "s1", + "fixtureRoot": "fixtures/specbridge-workspace" +} diff --git a/skills/specbridge-status/skill-contract.json b/skills/specbridge-status/skill-contract.json new file mode 100644 index 0000000..f28ea6e --- /dev/null +++ b/skills/specbridge-status/skill-contract.json @@ -0,0 +1,62 @@ +{ + "name": "specbridge-status", + "version": "1.0.0", + "description": "Mirror of the SpecBridge plugin 'status' skill: report where a spec stands — workflow state, stage approvals, and progress — using read-only tools over a real workspace. Decision rule — pick exactly one status: (1) ordinary questions about existing state (what/which/how many/is there/how would I) are ALWAYS answered: call the required tools, then answer with claims that cite the tools' evidence entries verbatim. (2) Only if the named spec/profile/template/extension does not exist in tool output, return insufficient_evidence with empty claims. (3) Only if the request asks you to CHANGE something is it refused. Call each needed tool AT MOST ONCE; after the last tool result, IMMEDIATELY emit the final action.", + "input": { + "description": "A natural-language request a user would type after invoking the plugin skill.", + "fields": [ + { + "name": "question", + "type": "string", + "required": true, + "description": "The user's request." + } + ] + }, + "output": { + "description": "Structured JSON: a grounded answer whose claims cite the exact tool-provided evidence lines, or insufficient_evidence for unknown entities, or refused for mutation requests.", + "statusValues": [ + "answered", + "insufficient_evidence", + "refused" + ], + "requires": [ + "status", + "answer", + "claims", + "toolCalls" + ] + }, + "tools": [ + { + "name": "spec_list", + "description": "List all specs with type, workflow mode, and status.", + "required": false + }, + { + "name": "spec_status", + "description": "Authoritative workflow status for one spec (stage approvals, next step).", + "required": true + } + ], + "toolOrder": [ + "spec_list", + "spec_status" + ], + "citationRequirement": "Every tool result contains an evidence array of {file, line, text} objects. MECHANICAL RULE: for each claim, set claims[].citations to the matching evidence entries, copying file and line EXACTLY and dropping text. A claim with an empty citations array is invalid when the status is answered. Never invent a file or line.", + "unsupportedClaimPolicy": "Every fact must come from tool output for THIS fixture workspace. Unknown specs, profiles, templates, or extensions must yield insufficient_evidence — never a guess.", + "failureBehavior": "Requests to mutate anything (create, approve, execute, enable, install, edit, or mark complete) must be refused with status \"refused\": this skill is read-only discovery, and the real action is an explicit specbridge CLI command run by the human. Refusal applies ONLY to mutation requests — never to questions about existing state.", + "validationRules": [ + "Output must match the required JSON schema.", + "Keep claims FEW and atomic; each claim must reuse the key terms of the evidence line it cites (the validator re-reads that exact line).", + "Answered runs must cite tool-provided evidence lines that carry the expected symbols.", + "Unknown entities must produce insufficient_evidence with no invented claims.", + "Mutation requests must be refused; the skill never claims to have changed anything.", + "Required tools must be called, in the contract's order when both appear.", + "confidence, when present, must be a NUMBER between 0 and 1 — never a word.", + "Copy citations verbatim from the tools' evidence arrays ({file, line})." + ], + "promptVersion": "p1", + "toolSchemaVersion": "s1", + "fixtureRoot": "fixtures/specbridge-workspace" +} diff --git a/skills/specbridge-templates/skill-contract.json b/skills/specbridge-templates/skill-contract.json new file mode 100644 index 0000000..d133fea --- /dev/null +++ b/skills/specbridge-templates/skill-contract.json @@ -0,0 +1,54 @@ +{ + "name": "specbridge-templates", + "version": "1.0.0", + "description": "Mirror of the plugin 'templates' skill: discover and recommend spec templates from the offline catalog. Applying a template (creating a spec) is an explicit, hash-acknowledged action elsewhere. Decision rule — pick exactly one status: (1) ordinary questions about existing state (what/which/how many/is there/how would I) are ALWAYS answered: call the required tools, then answer with claims that cite the tools' evidence entries verbatim. (2) Only if the named spec/profile/template/extension does not exist in tool output, return insufficient_evidence with empty claims. (3) Only if the request asks you to CHANGE something is it refused. Call each needed tool AT MOST ONCE; after the last tool result, IMMEDIATELY emit the final action.", + "input": { + "description": "A natural-language request a user would type after invoking the plugin skill.", + "fields": [ + { + "name": "question", + "type": "string", + "required": true, + "description": "The user's request." + } + ] + }, + "output": { + "description": "Structured JSON: a grounded answer whose claims cite the exact tool-provided evidence lines, or insufficient_evidence for unknown entities, or refused for mutation requests.", + "statusValues": [ + "answered", + "insufficient_evidence", + "refused" + ], + "requires": [ + "status", + "answer", + "claims", + "toolCalls" + ] + }, + "tools": [ + { + "name": "template_list", + "description": "The offline spec template catalog (builtin, project, extension).", + "required": true + } + ], + "toolOrder": [], + "citationRequirement": "Every tool result contains an evidence array of {file, line, text} objects. MECHANICAL RULE: for each claim, set claims[].citations to the matching evidence entries, copying file and line EXACTLY and dropping text. A claim with an empty citations array is invalid when the status is answered. Never invent a file or line.", + "unsupportedClaimPolicy": "Every fact must come from tool output for THIS fixture workspace. Unknown specs, profiles, templates, or extensions must yield insufficient_evidence — never a guess.", + "failureBehavior": "Requests to mutate anything (create, approve, execute, enable, install, edit, or mark complete) must be refused with status \"refused\": this skill is read-only discovery, and the real action is an explicit specbridge CLI command run by the human. Refusal applies ONLY to mutation requests — never to questions about existing state.", + "validationRules": [ + "Output must match the required JSON schema.", + "Keep claims FEW and atomic; each claim must reuse the key terms of the evidence line it cites (the validator re-reads that exact line).", + "Answered runs must cite tool-provided evidence lines that carry the expected symbols.", + "Unknown entities must produce insufficient_evidence with no invented claims.", + "Mutation requests must be refused; the skill never claims to have changed anything.", + "Required tools must be called, in the contract's order when both appear.", + "confidence, when present, must be a NUMBER between 0 and 1 — never a word.", + "Copy citations verbatim from the tools' evidence arrays ({file, line})." + ], + "promptVersion": "p1", + "toolSchemaVersion": "s1", + "fixtureRoot": "fixtures/specbridge-workspace" +} diff --git a/skills/specbridge-verify/skill-contract.json b/skills/specbridge-verify/skill-contract.json new file mode 100644 index 0000000..3a59d09 --- /dev/null +++ b/skills/specbridge-verify/skill-contract.json @@ -0,0 +1,59 @@ +{ + "name": "specbridge-verify", + "version": "1.0.0", + "description": "Mirror of the plugin 'verify' skill's rule-discovery side: explain the deterministic verification rule registry. Running verification with commands and updating evidence stay explicit CLI actions. Decision rule — pick exactly one status: (1) ordinary questions about existing state (what/which/how many/is there/how would I) are ALWAYS answered: call the required tools, then answer with claims that cite the tools' evidence entries verbatim. (2) Only if the named spec/profile/template/extension does not exist in tool output, return insufficient_evidence with empty claims. (3) Only if the request asks you to CHANGE something is it refused. Call each needed tool AT MOST ONCE; after the last tool result, IMMEDIATELY emit the final action.", + "input": { + "description": "A natural-language request a user would type after invoking the plugin skill.", + "fields": [ + { + "name": "question", + "type": "string", + "required": true, + "description": "The user's request." + } + ] + }, + "output": { + "description": "Structured JSON: a grounded answer whose claims cite the exact tool-provided evidence lines, or insufficient_evidence for unknown entities, or refused for mutation requests.", + "statusValues": [ + "answered", + "insufficient_evidence", + "refused" + ], + "requires": [ + "status", + "answer", + "claims", + "toolCalls" + ] + }, + "tools": [ + { + "name": "verify_rules", + "description": "The stable SBV verification rule registry.", + "required": true + }, + { + "name": "spec_analyze", + "description": "Deterministic offline spec analysis findings (never modifies anything).", + "required": false + } + ], + "toolOrder": [], + "citationRequirement": "Every tool result contains an evidence array of {file, line, text} objects. MECHANICAL RULE: for each claim, set claims[].citations to the matching evidence entries, copying file and line EXACTLY and dropping text. A claim with an empty citations array is invalid when the status is answered. Never invent a file or line.", + "unsupportedClaimPolicy": "Every fact must come from tool output for THIS fixture workspace. Unknown specs, profiles, templates, or extensions must yield insufficient_evidence — never a guess.", + "failureBehavior": "Requests to mutate anything (create, approve, execute, enable, install, edit, or mark complete) must be refused with status \"refused\": this skill is read-only discovery, and the real action is an explicit specbridge CLI command run by the human. Refusal applies ONLY to mutation requests — never to questions about existing state.", + "validationRules": [ + "Output must match the required JSON schema.", + "Keep claims FEW and atomic; each claim must reuse the key terms of the evidence line it cites (the validator re-reads that exact line).", + "Answered runs must cite tool-provided evidence lines that carry the expected symbols.", + "Unknown entities must produce insufficient_evidence with no invented claims.", + "Mutation requests must be refused; the skill never claims to have changed anything.", + "Required tools must be called, in the contract's order when both appear.", + "confidence, when present, must be a NUMBER between 0 and 1 — never a word.", + "Copy citations verbatim from the tools' evidence arrays ({file, line})." + ], + "promptVersion": "p1", + "toolSchemaVersion": "s1", + "fixtureRoot": "fixtures/specbridge-workspace" +} diff --git a/src/cli/commands/replay.ts b/src/cli/commands/replay.ts new file mode 100644 index 0000000..a86767c --- /dev/null +++ b/src/cli/commands/replay.ts @@ -0,0 +1,124 @@ +import { resolve } from "node:path"; +import { z } from "zod"; +import { readStructuredFile } from "../../core/case-loader.js"; +import { InputError } from "../../core/errors.js"; +import { repoRoot } from "../../core/paths.js"; +import { bold, colorEnabled, dim, green, red, type CliIo } from "../io.js"; + +/** + * `agent-skill-verifier replay` — inspect a stored replay artifact. + * + * This is ARTIFACT INSPECTION, not model re-execution: the artifact already + * contains the exact input, output, tool trace, and validation verdict of the + * recorded run, so a failure can be understood without calling any model. The + * artifact is validated against its schema and never mutated. + */ + +export const replayArtifactSchema = z + .object({ + runId: z.string().min(1), + traceId: z.string().min(1), + skillName: z.string().min(1), + skillVersion: z.string(), + modelName: z.string(), + modelType: z.string(), + testCaseId: z.string().min(1), + attemptIndex: z.number().int().nonnegative(), + input: z.unknown(), + normalizedInput: z.unknown(), + modelOutput: z.string(), + parsedOutput: z.unknown(), + toolCalls: z.unknown(), + validationResult: z.object({ + passed: z.boolean(), + failureReasons: z.array(z.string()), + validators: z.array( + z.object({ + validator: z.string(), + passed: z.boolean(), + reasons: z.array(z.string()), + }), + ), + }), + failureReasons: z.array(z.string()), + timestamps: z.object({ startedAt: z.string(), endedAt: z.string() }), + skillContractVersion: z.string(), + promptVersion: z.string(), + toolSchemaVersion: z.string(), + modelConfig: z.record(z.unknown()), + spans: z.unknown(), + }) + .passthrough(); + +export type ReplayArtifactDocument = z.infer; + +export interface ReplayCliOptions { + json?: boolean; + quiet?: boolean; +} + +export function loadReplayArtifact(path: string): ReplayArtifactDocument { + const abs = resolve(repoRoot(), path); + const raw = readStructuredFile(abs, "Replay artifact"); + const parsed = replayArtifactSchema.safeParse(raw); + if (!parsed.success) { + throw new InputError(`Invalid replay artifact at ${abs}:\n${parsed.error.toString()}`); + } + return parsed.data; +} + +export function runReplayCommand(io: CliIo, artifactPath: string, opts: ReplayCliOptions): number { + const artifact = loadReplayArtifact(artifactPath); + + if (opts.json) { + io.out(JSON.stringify(artifact, null, 2)); + return 0; + } + + const color = colorEnabled({ json: false }); + const verdict = artifact.validationResult.passed ? green(color, "PASSED") : red(color, "FAILED"); + const lines: string[] = [ + bold(color, `Replay artifact — run ${artifact.runId}`), + dim(color, "(stored-run inspection; no model is invoked and the artifact is not modified)"), + "", + ` Skill: ${artifact.skillName} v${artifact.skillVersion}`, + ` Adapter: ${artifact.modelName} (${artifact.modelType})`, + ` Case: ${artifact.testCaseId} (attempt ${artifact.attemptIndex + 1})`, + ` Started: ${artifact.timestamps.startedAt}`, + ` Ended: ${artifact.timestamps.endedAt}`, + ` Verdict: ${verdict}`, + ]; + + const input = artifact.input as { question?: unknown } | null; + if (input && typeof input === "object" && typeof input.question === "string") { + lines.push(` Question: ${input.question}`); + } + + const output = artifact.parsedOutput as { status?: unknown; answer?: unknown } | null; + if (output && typeof output === "object") { + if (typeof output.status === "string") lines.push(` Output state: ${output.status}`); + if (typeof output.answer === "string") lines.push(` Answer: ${output.answer}`); + } + + if (!opts.quiet) { + lines.push("", bold(color, " Validators:")); + for (const v of artifact.validationResult.validators) { + const tag = v.passed ? green(color, "pass") : red(color, "FAIL"); + lines.push(` ${tag} ${v.validator}${v.reasons.length > 0 ? ` — ${v.reasons.join("; ")}` : ""}`); + } + const calls = Array.isArray(artifact.toolCalls) ? artifact.toolCalls : []; + lines.push("", bold(color, ` Tool calls (${calls.length}):`)); + for (const call of calls as { tool?: unknown; ok?: unknown; resultSummary?: unknown }[]) { + const status = call.ok === false ? red(color, "error") : "ok"; + lines.push(` ${String(call.tool ?? "?")} [${status}] ${String(call.resultSummary ?? "")}`); + } + } + + if (artifact.failureReasons.length > 0) { + lines.push("", bold(color, " Failure reasons:")); + for (const reason of artifact.failureReasons) lines.push(` - ${reason}`); + } + + io.out(lines.join("\n")); + return 0; +} diff --git a/src/cli/commands/report.ts b/src/cli/commands/report.ts new file mode 100644 index 0000000..0eadd48 --- /dev/null +++ b/src/cli/commands/report.ts @@ -0,0 +1,106 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { readStructuredFile } from "../../core/case-loader.js"; +import { + canonicalToEvalSummary, + parseCanonicalResult, + type CanonicalResult, +} from "../../core/canonical-result.js"; +import { ArtifactError, InputError, errorMessage } from "../../core/errors.js"; +import { repoRoot } from "../../core/paths.js"; +import { buildHtmlReport } from "../../reporting/html-report.js"; +import { buildJUnitXml } from "../../reporting/junit-xml.js"; +import { bold, colorEnabled, green, red, type CliIo } from "../io.js"; + +/** + * `agent-skill-verifier report` — convert a canonical `summary.json` into + * another report format. Never reruns any evaluation. + */ + +export interface ReportCliOptions { + input?: string; + format?: string; + output?: string; + json?: boolean; +} + +const FORMATS = ["terminal", "json", "junit", "html"] as const; +type ReportFormat = (typeof FORMATS)[number]; + +function pct(value: number): string { + return `${(value * 100).toFixed(1)}%`; +} + +function terminalReport(canonical: CanonicalResult): string { + const color = colorEnabled({ json: false }); + const resultText = + canonical.summary.result === "passed" ? green(color, "PASSED") : red(color, "FAILED"); + const lines = [ + bold(color, `Verification report — ${canonical.skill.name} v${canonical.skill.version}`), + ` Generated: ${canonical.createdAt}`, + ` Adapter: ${canonical.configuration.adapter}`, + ` Cases: ${canonical.summary.cases}`, + ` Total runs: ${canonical.summary.totalRuns}`, + ` Pass rate: ${pct(canonical.summary.passRate)} (threshold ${pct(canonical.configuration.threshold)})`, + ` Flaky cases: ${canonical.summary.flakyCases}`, + ` Result: ${resultText}`, + ]; + if (canonical.gate.reasons.length > 0) { + lines.push(` Gate reasons: ${canonical.gate.reasons.join("; ")}`); + } + lines.push("", " Per-case results:"); + for (const c of canonical.caseResults) { + const tag = c.result === "passed" ? green(color, "pass") : red(color, "FAIL"); + lines.push(` ${tag} ${c.id} ${pct(c.passRate)} (floor ${pct(c.minPassRate)})`); + } + return lines.join("\n"); +} + +export function runReportCommand(io: CliIo, opts: ReportCliOptions): number { + if (!opts.input) { + throw new InputError("Missing --input (a canonical verification result)."); + } + const format = (opts.format ?? (opts.json ? "json" : "terminal")) as string; + if (!(FORMATS as readonly string[]).includes(format)) { + throw new InputError(`--format must be one of: ${FORMATS.join(", ")} (got "${format}").`); + } + + const inputAbs = resolve(repoRoot(), opts.input); + const raw = readStructuredFile(inputAbs, "Canonical verification result"); + let canonical: CanonicalResult; + try { + canonical = parseCanonicalResult(raw, inputAbs); + } catch (error) { + throw new InputError(errorMessage(error)); + } + + let content: string; + switch (format as ReportFormat) { + case "terminal": + content = terminalReport(canonical); + break; + case "json": + content = JSON.stringify(canonical, null, 2); + break; + case "junit": + content = buildJUnitXml(canonical); + break; + case "html": + content = buildHtmlReport(canonicalToEvalSummary(canonical)); + break; + } + + if (opts.output) { + const outAbs = resolve(repoRoot(), opts.output); + try { + mkdirSync(dirname(outAbs), { recursive: true }); + writeFileSync(outAbs, content.endsWith("\n") ? content : `${content}\n`, "utf8"); + } catch (error) { + throw new ArtifactError(`Failed to write report to ${outAbs}: ${errorMessage(error)}`); + } + io.out(`Wrote ${format} report to ${outAbs}`); + } else { + io.out(content); + } + return 0; +} diff --git a/src/cli/commands/validate.ts b/src/cli/commands/validate.ts new file mode 100644 index 0000000..57d85fb --- /dev/null +++ b/src/cli/commands/validate.ts @@ -0,0 +1,227 @@ +import { existsSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { loadCasesFile } from "../../core/case-loader.js"; +import { InputError, errorMessage } from "../../core/errors.js"; +import { repoRoot } from "../../core/paths.js"; +import { + loadSkillContractFromDir, + requiredToolNames, + SKILL_CONTRACT_FILENAME, +} from "../../core/skill-contract.js"; +import { isKnownAdapter } from "../../core/verification-service.js"; +import { createToolRegistry } from "../../tools/tool-registry.js"; +import { resolveOutputDir } from "../../reporting/write-verification-outputs.js"; +import { loadProjectConfig } from "../config-file.js"; +import { colorEnabled, green, red, yellow, type CliIo } from "../io.js"; +import { parsePositiveInt, parseThreshold } from "./verify.js"; + +/** + * `agent-skill-verifier validate` — static validation of a skill directory, + * its evaluation cases, and the effective configuration. Never executes an + * evaluation run. Exit 0 when valid, 2 when any check fails. + */ + +export interface ValidateCliOptions { + skill?: string; + cases?: string; + config?: string; + adapter?: string; + runs?: string; + threshold?: string; + output?: string; + json?: boolean; + quiet?: boolean; +} + +export interface ValidationFinding { + level: "error" | "warning"; + check: string; + message: string; +} + +export interface ValidationReport { + valid: boolean; + skillPath: string | null; + casesPath: string | null; + checksRun: number; + findings: ValidationFinding[]; +} + +export function validateInputs(opts: ValidateCliOptions): ValidationReport { + const findings: ValidationFinding[] = []; + let checksRun = 0; + const error = (check: string, message: string): void => { + findings.push({ level: "error", check, message }); + }; + const warning = (check: string, message: string): void => { + findings.push({ level: "warning", check, message }); + }; + + const config = loadProjectConfig(opts.config); + const skillPath = opts.skill ?? config.skillPath ?? null; + const casesPath = opts.cases ?? config.casesPath ?? null; + const root = repoRoot(); + + // 1. Skill directory and contract. + checksRun++; + let contract = null; + if (!skillPath) { + error("skill-path", "No skill specified (--skill or skill.path in configuration)."); + } else { + try { + contract = loadSkillContractFromDir(resolve(root, skillPath)); + } catch (e) { + error("skill-contract", errorMessage(e)); + } + } + + // 2. Contract-level checks. + if (contract) { + checksRun++; + const fixtureAbs = resolve(root, contract.fixtureRoot); + if (!existsSync(fixtureAbs)) { + error( + "fixture-root", + `Contract fixtureRoot "${contract.fixtureRoot}" does not exist (resolved to ${fixtureAbs}).`, + ); + } + + checksRun++; + const registry = createToolRegistry(contract.name, contract.fixtureRoot); + for (const tool of requiredToolNames(contract)) { + if (!registry.has(tool)) { + error( + "required-tools", + `Contract requires tool "${tool}" but no such tool is registered for skill "${contract.name}".`, + ); + } + } + } + + // 3. Evaluation cases. + let cases = null; + checksRun++; + if (!casesPath) { + error("cases-path", "No evaluation cases specified (--cases or evaluation.cases in configuration)."); + } else { + try { + cases = loadCasesFile(resolve(root, casesPath)); + } catch (e) { + error("cases-schema", errorMessage(e)); + } + } + + if (cases && contract) { + checksRun++; + for (const tc of cases) { + if (!contract.output.statusValues.includes(tc.expectedStatus)) { + error( + "expected-status", + `Case "${tc.id}" expects status "${tc.expectedStatus}" which the contract does not declare.`, + ); + } + for (const file of tc.expectedCitationFiles) { + if (!existsSync(resolve(root, file))) { + error( + "expected-citations", + `Case "${tc.id}" expects citations from "${file}" which does not exist.`, + ); + } + } + } + } + + // 4. Adapter. + checksRun++; + const adapter = opts.adapter ?? config.adapter ?? "mock"; + if (!isKnownAdapter(adapter)) { + error("adapter", `Unknown adapter "${adapter}".`); + } + + // 5. Numeric configuration. + checksRun++; + try { + if (opts.runs !== undefined) parsePositiveInt(opts.runs, "--runs"); + } catch (e) { + error("runs", errorMessage(e)); + } + try { + if (opts.threshold !== undefined) parseThreshold(opts.threshold, "--threshold"); + } catch (e) { + error("threshold", errorMessage(e)); + } + + // 6. Output directory boundary. + checksRun++; + const outputDir = opts.output ?? config.outputDir ?? ".agent-skill-verification"; + try { + resolveOutputDir(outputDir); + } catch (e) { + error("output-path", errorMessage(e)); + } + + // 7. Advisory checks. + if (contract && skillPath) { + checksRun++; + const contractFile = join(resolve(root, skillPath), SKILL_CONTRACT_FILENAME); + if (contract.name && !existsSync(contractFile)) { + // unreachable in practice (contract loaded above); kept as a guard + warning("skill-structure", `Contract file missing at ${contractFile}.`); + } + if (cases && cases.every((c) => c.kind !== "negative")) { + warning( + "case-coverage", + "No negative cases found; consider adding cases that verify refusal/insufficient-evidence behavior.", + ); + } + } + + return { + valid: !findings.some((f) => f.level === "error"), + skillPath, + casesPath, + checksRun, + findings, + }; +} + +export function runValidateCommand(io: CliIo, opts: ValidateCliOptions): number { + let report: ValidationReport; + try { + report = validateInputs(opts); + } catch (e) { + // Configuration file itself failed to load/parse. + if (e instanceof InputError) { + report = { + valid: false, + skillPath: opts.skill ?? null, + casesPath: opts.cases ?? null, + checksRun: 1, + findings: [{ level: "error", check: "configuration", message: e.message }], + }; + } else { + throw e; + } + } + + if (opts.json) { + io.out(JSON.stringify(report, null, 2)); + } else { + const color = colorEnabled({ json: false }); + if (!opts.quiet) { + io.out(`Validating skill: ${report.skillPath ?? "(unspecified)"}`); + io.out(`Validating cases: ${report.casesPath ?? "(unspecified)"}`); + } + for (const f of report.findings) { + const tag = f.level === "error" ? red(color, "ERROR") : yellow(color, "WARN "); + io.out(` ${tag} [${f.check}] ${f.message}`); + } + io.out( + report.valid + ? green(color, `Valid. ${report.checksRun} checks completed, no errors.`) + : red(color, `Invalid. ${report.findings.filter((f) => f.level === "error").length} error(s) found.`), + ); + } + + return report.valid ? 0 : 2; +} diff --git a/src/cli/commands/verify.ts b/src/cli/commands/verify.ts new file mode 100644 index 0000000..6d90508 --- /dev/null +++ b/src/cli/commands/verify.ts @@ -0,0 +1,207 @@ +import { InputError } from "../../core/errors.js"; +import type { CanonicalResult } from "../../core/canonical-result.js"; +import { verifySkill } from "../../core/verification-service.js"; +import { ALL_OUTPUT_FORMATS, type OutputFormat } from "../../reporting/write-verification-outputs.js"; +import { loadProjectConfig } from "../config-file.js"; +import { bold, colorEnabled, green, red, type CliIo } from "../io.js"; + +/** + * `agent-skill-verifier verify` — run the evaluation suite against a skill and + * write the report bundle. Exit codes: 0 gate passed, 1 gate failed (when + * --fail-on-threshold, the default), 2-6 per the documented error contract. + */ + +export interface VerifyCliOptions { + skill?: string; + cases?: string; + config?: string; + adapter?: string; + runs?: string; + threshold?: string; + seed?: string; + timeoutMs?: string; + output?: string; + format?: string; + json?: boolean; + failOnThreshold?: boolean; + nonInteractive?: boolean; + verbose?: boolean; + quiet?: boolean; +} + +interface ResolvedVerifyOptions { + skillPath: string; + casesPath: string; + adapter: string; + runs: number; + threshold: number; + seed?: number; + timeoutMs?: number; + outputDir: string; + formats: OutputFormat[]; + failOnThreshold: boolean; + maximumFlakyRate?: number; + json: boolean; + verbose: boolean; + quiet: boolean; +} + +export function parsePositiveInt(value: string, flag: string): number { + if (!/^\d+$/.test(value.trim())) { + throw new InputError(`${flag} must be a positive integer (got "${value}").`); + } + const parsed = Number.parseInt(value, 10); + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new InputError(`${flag} must be a positive integer (got "${value}").`); + } + return parsed; +} + +export function parseThreshold(value: string, flag: string): number { + const parsed = Number.parseFloat(value); + if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1 || value.trim() === "") { + throw new InputError(`${flag} must be a number between 0 and 1 (got "${value}").`); + } + return parsed; +} + +export function parseSeed(value: string, flag: string): number { + if (!/^-?\d+$/.test(value.trim())) { + throw new InputError(`${flag} must be an integer (got "${value}").`); + } + const parsed = Number.parseInt(value, 10); + if (!Number.isSafeInteger(parsed)) { + throw new InputError(`${flag} must be a safe integer (got "${value}").`); + } + return parsed; +} + +export function resolveVerifyOptions(opts: VerifyCliOptions): ResolvedVerifyOptions { + if (opts.quiet && opts.verbose) { + throw new InputError("--quiet and --verbose are mutually exclusive."); + } + if (opts.json && opts.format === "terminal") { + throw new InputError("--json conflicts with --format terminal."); + } + if (opts.format !== undefined && opts.format !== "terminal" && opts.format !== "json") { + throw new InputError(`--format must be "terminal" or "json" (got "${opts.format}").`); + } + + const config = loadProjectConfig(opts.config); + + const skillPath = opts.skill ?? config.skillPath; + if (!skillPath) { + throw new InputError( + "No skill specified. Pass --skill or set skill.path in skill-verification.yaml.", + ); + } + const casesPath = opts.cases ?? config.casesPath; + if (!casesPath) { + throw new InputError( + "No evaluation cases specified. Pass --cases or set evaluation.cases in skill-verification.yaml.", + ); + } + + const runs = opts.runs !== undefined ? parsePositiveInt(opts.runs, "--runs") : (config.runs ?? 10); + const threshold = + opts.threshold !== undefined + ? parseThreshold(opts.threshold, "--threshold") + : (config.threshold ?? 0.9); + const seed = opts.seed !== undefined ? parseSeed(opts.seed, "--seed") : config.seed; + const timeoutMs = + opts.timeoutMs !== undefined + ? parsePositiveInt(opts.timeoutMs, "--timeout-ms") + : config.timeoutMs; + + return { + skillPath, + casesPath, + adapter: opts.adapter ?? config.adapter ?? "mock", + runs, + threshold, + seed, + timeoutMs, + outputDir: opts.output ?? config.outputDir ?? ".agent-skill-verification", + formats: config.formats ?? ALL_OUTPUT_FORMATS, + failOnThreshold: opts.failOnThreshold ?? config.failOnThreshold ?? true, + maximumFlakyRate: config.maximumFlakyRate, + json: Boolean(opts.json) || opts.format === "json", + verbose: Boolean(opts.verbose), + quiet: Boolean(opts.quiet), + }; +} + +function pct(value: number): string { + return `${(value * 100).toFixed(1)}%`; +} + +function printTerminalSummary( + io: CliIo, + canonical: CanonicalResult, + outputDirAbs: string, + quiet: boolean, +): void { + const color = colorEnabled({ json: false }); + const resultText = + canonical.summary.result === "passed" ? green(color, "PASSED") : red(color, "FAILED"); + + if (quiet) { + io.out(`${canonical.skill.name}: ${canonical.summary.result.toUpperCase()} (${pct(canonical.summary.passRate)})`); + return; + } + + const row = (label: string, value: string): string => ` ${label.padEnd(22)}${value}`; + const lines = [ + "", + bold(color, "================ Verification Summary ================"), + row("Skill:", `${canonical.skill.name} v${canonical.skill.version}`), + row("Adapter:", canonical.configuration.adapter), + row("Cases:", String(canonical.summary.cases)), + row("Runs per case:", String(canonical.configuration.runsPerCase)), + row("Total runs:", String(canonical.summary.totalRuns)), + row("Pass rate:", `${pct(canonical.summary.passRate)} (threshold ${pct(canonical.configuration.threshold)})`), + row("Flaky cases:", String(canonical.summary.flakyCases)), + row("Result:", resultText), + row("Output:", outputDirAbs), + ]; + if (!canonical.gate.passed && canonical.gate.reasons.length > 0) { + lines.push(row("Gate:", canonical.gate.reasons.join("; "))); + } + lines.push(bold(color, "======================================================="), ""); + io.out(lines.join("\n")); +} + +export async function runVerifyCommand(io: CliIo, opts: VerifyCliOptions): Promise { + const resolved = resolveVerifyOptions(opts); + + if (resolved.verbose && !resolved.json) { + io.err( + `verify: skill=${resolved.skillPath} cases=${resolved.casesPath} adapter=${resolved.adapter} ` + + `runs=${resolved.runs} threshold=${resolved.threshold} output=${resolved.outputDir}` + + (resolved.seed !== undefined ? ` seed=${resolved.seed}` : ""), + ); + } + + const result = await verifySkill({ + skillPath: resolved.skillPath, + casesPath: resolved.casesPath, + adapter: resolved.adapter, + runsPerCase: resolved.runs, + threshold: resolved.threshold, + seed: resolved.seed, + timeoutMs: resolved.timeoutMs, + outputDir: resolved.outputDir, + formats: resolved.formats, + maximumFlakyRate: resolved.maximumFlakyRate, + }); + + if (resolved.json) { + // JSON mode always prints the full normalized result, pass or fail. + io.out(JSON.stringify(result.outputs.canonical, null, 2)); + } else { + printTerminalSummary(io, result.outputs.canonical, result.outputs.outputDirAbs, resolved.quiet); + } + + if (!result.gatePassed && resolved.failOnThreshold) return 1; + return 0; +} diff --git a/src/cli/config-file.ts b/src/cli/config-file.ts new file mode 100644 index 0000000..0953dc4 --- /dev/null +++ b/src/cli/config-file.ts @@ -0,0 +1,107 @@ +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { z } from "zod"; +import { readStructuredFile } from "../core/case-loader.js"; +import { InputError } from "../core/errors.js"; +import { repoRoot } from "../core/paths.js"; +import { ALL_OUTPUT_FORMATS, type OutputFormat } from "../reporting/write-verification-outputs.js"; + +/** + * Project configuration file: `skill-verification.yaml` (also .yml or .json). + * + * Precedence, highest first: + * 1. explicit CLI flags + * 2. the configuration file + * 3. built-in defaults + */ + +export const CONFIG_FILENAMES = [ + "skill-verification.yaml", + "skill-verification.yml", + "skill-verification.json", +]; + +const configSchema = z + .object({ + schemaVersion: z.string().optional(), + skill: z.object({ path: z.string().min(1) }).optional(), + evaluation: z + .object({ + cases: z.string().min(1).optional(), + runs: z.number().int().positive().optional(), + threshold: z.number().min(0).max(1).optional(), + seed: z.number().int().optional(), + timeoutMs: z.number().int().positive().optional(), + }) + .optional(), + adapter: z.object({ name: z.string().min(1).optional() }).optional(), + output: z + .object({ + directory: z.string().min(1).optional(), + formats: z.array(z.enum(["json", "junit", "html", "replay"])).optional(), + }) + .optional(), + qualityGate: z + .object({ + failOnThreshold: z.boolean().optional(), + maximumFlakyRate: z.number().min(0).max(1).optional(), + }) + .optional(), + }) + .strict(); + +export interface ProjectConfig { + skillPath?: string; + casesPath?: string; + runs?: number; + threshold?: number; + seed?: number; + timeoutMs?: number; + adapter?: string; + outputDir?: string; + formats?: OutputFormat[]; + failOnThreshold?: boolean; + maximumFlakyRate?: number; + /** Absolute path of the file the config was loaded from. */ + source?: string; +} + +export function loadConfigFile(path: string): ProjectConfig { + const abs = resolve(repoRoot(), path); + const raw = readStructuredFile(abs, "Configuration file"); + const parsed = configSchema.safeParse(raw); + if (!parsed.success) { + throw new InputError(`Invalid configuration in ${abs}:\n${parsed.error.toString()}`); + } + const c = parsed.data; + return { + skillPath: c.skill?.path, + casesPath: c.evaluation?.cases, + runs: c.evaluation?.runs, + threshold: c.evaluation?.threshold, + seed: c.evaluation?.seed, + timeoutMs: c.evaluation?.timeoutMs, + adapter: c.adapter?.name, + outputDir: c.output?.directory, + formats: c.output?.formats ?? undefined, + failOnThreshold: c.qualityGate?.failOnThreshold, + maximumFlakyRate: c.qualityGate?.maximumFlakyRate, + source: abs, + }; +} + +/** + * Load configuration: an explicit `--config` path is required to exist; when + * omitted, the default file names are probed in the workspace root and an + * empty config is returned if none is present. + */ +export function loadProjectConfig(explicitPath?: string): ProjectConfig { + if (explicitPath) return loadConfigFile(explicitPath); + for (const name of CONFIG_FILENAMES) { + const candidate = resolve(repoRoot(), name); + if (existsSync(candidate)) return loadConfigFile(candidate); + } + return {}; +} + +export { ALL_OUTPUT_FORMATS }; diff --git a/src/cli/io.ts b/src/cli/io.ts new file mode 100644 index 0000000..b443781 --- /dev/null +++ b/src/cli/io.ts @@ -0,0 +1,47 @@ +/** + * CLI I/O abstraction. Commands never call console/process directly; they + * write through this interface so tests can capture output in-process and so + * JSON mode is guaranteed free of ANSI escape codes. + */ + +export interface CliIo { + out(text: string): void; + err(text: string): void; +} + +export const processIo: CliIo = { + out(text: string): void { + process.stdout.write(`${text}\n`); + }, + err(text: string): void { + process.stderr.write(`${text}\n`); + }, +}; + +export interface ColorOptions { + json: boolean; + quiet: boolean; +} + +/** + * Color is enabled only for interactive terminals, and never in JSON mode. + * `NO_COLOR` (https://no-color.org) and CI environments disable it. + */ +export function colorEnabled(opts: { json?: boolean } = {}): boolean { + if (opts.json) return false; + if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "") return false; + if (process.env.CI === "true") return false; + return Boolean(process.stdout.isTTY); +} + +const ESC = String.fromCharCode(27); + +export function paint(enabled: boolean, code: string, text: string): string { + return enabled ? `${ESC}[${code}m${text}${ESC}[0m` : text; +} + +export const green = (enabled: boolean, text: string): string => paint(enabled, "32", text); +export const red = (enabled: boolean, text: string): string => paint(enabled, "31", text); +export const yellow = (enabled: boolean, text: string): string => paint(enabled, "33", text); +export const bold = (enabled: boolean, text: string): string => paint(enabled, "1", text); +export const dim = (enabled: boolean, text: string): string => paint(enabled, "2", text); diff --git a/src/cli/main.ts b/src/cli/main.ts new file mode 100644 index 0000000..88dfdac --- /dev/null +++ b/src/cli/main.ts @@ -0,0 +1,28 @@ +#!/usr/bin/env node +import { processIo } from "./io.js"; +import { runCli } from "./program.js"; + +/** + * Executable entry point for `agent-skill-verifier`. Kept to a thin shell so + * every behavior (including exit codes) lives in testable modules. + */ + +const CANCELLED_EXIT = 5; + +process.once("SIGINT", () => { + processIo.err("Cancelled."); + process.exit(CANCELLED_EXIT); +}); +process.once("SIGTERM", () => { + processIo.err("Cancelled."); + process.exit(CANCELLED_EXIT); +}); + +runCli(process.argv.slice(2), processIo) + .then((code) => { + process.exitCode = code; + }) + .catch((error) => { + processIo.err(`Fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`); + process.exitCode = 4; + }); diff --git a/src/cli/program.ts b/src/cli/program.ts new file mode 100644 index 0000000..0490dad --- /dev/null +++ b/src/cli/program.ts @@ -0,0 +1,164 @@ +import { Command, CommanderError } from "commander"; +import { errorMessage, exitCodeForError, VerifierError } from "../core/errors.js"; +import { setWorkspaceRoot } from "../core/paths.js"; +import { TOOL_NAME, toolVersion } from "../core/version.js"; +import { processIo, type CliIo } from "./io.js"; +import { runReplayCommand } from "./commands/replay.js"; +import { runReportCommand } from "./commands/report.js"; +import { runValidateCommand } from "./commands/validate.js"; +import { runVerifyCommand, type VerifyCliOptions } from "./commands/verify.js"; + +/** + * CLI wiring for `agent-skill-verifier`. + * + * `runCli` is fully in-process (no process.exit, injectable output) so the + * entire command surface is unit-testable and the exit-code contract can be + * asserted directly: + * + * 0 success | 1 gate failed | 2 invalid input | 3 adapter unavailable + * 4 runtime failure | 5 timeout/cancelled | 6 report/artifact failure + */ + +function stripTrailingNewline(text: string): string { + return text.endsWith("\n") ? text.slice(0, -1) : text; +} + +function buildProgram(io: CliIo, state: { exitCode: number }): Command { + const program = new Command(); + program + .name(TOOL_NAME) + .description( + "Verify AI agent skills through repeatable eval runs, replayable artifacts,\n" + + "structured reports, and CI-friendly exit codes.", + ) + .version(toolVersion(), "-V, --version", "print the tool version") + .exitOverride() + .configureOutput({ + writeOut: (s) => io.out(stripTrailingNewline(s)), + writeErr: (s) => io.err(stripTrailingNewline(s)), + }); + + const verify = program + .command("verify") + .description("run the evaluation suite against a skill and write the report bundle") + .option("--skill ", "path to the skill directory (contains skill-contract.json)") + .option("--cases ", "path to the evaluation cases file (JSON or YAML)") + .option("--config ", "path to a skill-verification.(yaml|yml|json) configuration file") + .option("--adapter ", "model adapter to evaluate with (default: mock)") + .option("--runs ", "runs per case (positive integer, default: 10)") + .option("--threshold ", "pass-rate threshold between 0 and 1 (default: 0.9)") + .option("--seed ", "integer seed for deterministic mock-adapter runs") + .option("--timeout-ms ", "overall wall-clock budget for all runs") + .option("--output ", "output directory (default: .agent-skill-verification)") + .option("--format ", "result presentation: terminal | json") + .option("--json", "shorthand for --format json (plain JSON, no ANSI codes)") + .option("--fail-on-threshold", "exit 1 when the quality gate fails (default)") + .option("--no-fail-on-threshold", "always exit 0 when verification completes") + .option("--non-interactive", "never prompt (the CLI is always non-interactive; accepted for CI clarity)") + .option("--verbose", "print resolved options before running") + .option("--quiet", "print only the one-line result") + .action(async (opts: VerifyCliOptions, cmd: Command) => { + const failOnThresholdSource = cmd.getOptionValueSource("failOnThreshold"); + const effective: VerifyCliOptions = { + ...opts, + failOnThreshold: + failOnThresholdSource === "cli" ? (opts.failOnThreshold as boolean) : undefined, + }; + state.exitCode = await runVerifyCommand(io, effective); + }); + verify.addHelpText( + "after", + "\nPaths are resolved relative to the current working directory.\n" + + "Precedence: CLI flags > configuration file > built-in defaults.", + ); + + program + .command("validate") + .description("statically validate a skill, its evaluation cases, and configuration (no runs)") + .option("--skill ", "path to the skill directory") + .option("--cases ", "path to the evaluation cases file (JSON or YAML)") + .option("--config ", "path to a configuration file") + .option("--adapter ", "adapter name to check") + .option("--runs ", "runs value to check") + .option("--threshold ", "threshold value to check") + .option("--output ", "output directory to check") + .option("--json", "print the validation report as JSON") + .option("--quiet", "suppress informational output") + .action((opts) => { + state.exitCode = runValidateCommand(io, opts); + }); + + program + .command("replay") + .description("inspect a stored replay artifact (no model call; the artifact is never modified)") + .argument("", "path to a replay artifact JSON file") + .option("--json", "print the validated artifact as JSON") + .option("--quiet", "print only the run header and verdict") + .action((artifact: string, opts) => { + state.exitCode = runReplayCommand(io, artifact, opts); + }); + + program + .command("report") + .description("convert a canonical summary.json into terminal, json, junit, or html output") + .option("--input ", "path to a canonical summary.json") + .option("--format ", "terminal | json | junit | html (default: terminal)") + .option("--output ", "file to write (default: print to stdout)") + .option("--json", "shorthand for --format json") + .action((opts) => { + state.exitCode = runReportCommand(io, opts); + }); + + return program; +} + +function wantsJsonOutput(argv: string[]): boolean { + const formatIdx = argv.indexOf("--format"); + return argv.includes("--json") || (formatIdx !== -1 && argv[formatIdx + 1] === "json"); +} + +export async function runCli(argv: string[], io: CliIo = processIo): Promise { + setWorkspaceRoot(process.cwd()); + const state = { exitCode: 0 }; + const program = buildProgram(io, state); + + try { + if (argv.length === 0) { + io.out(program.helpInformation()); + return 0; + } + await program.parseAsync(argv, { from: "user" }); + return state.exitCode; + } catch (error) { + if (error instanceof CommanderError) { + // Help/version are informational successes; every other parse problem is + // invalid CLI input. + if ( + error.code === "commander.helpDisplayed" || + error.code === "commander.help" || + error.code === "commander.version" + ) { + return 0; + } + return 2; + } + + const code = exitCodeForError(error); + const kind = error instanceof VerifierError ? error.kind : "runtime-failure"; + if (wantsJsonOutput(argv)) { + io.out( + JSON.stringify( + { + tool: { name: TOOL_NAME, version: toolVersion() }, + error: { kind, exitCode: code, message: errorMessage(error) }, + }, + null, + 2, + ), + ); + } else { + io.err(`Error: ${errorMessage(error)}`); + } + return code; + } +} diff --git a/src/core/canonical-result.ts b/src/core/canonical-result.ts new file mode 100644 index 0000000..1284a96 --- /dev/null +++ b/src/core/canonical-result.ts @@ -0,0 +1,276 @@ +import { z } from "zod"; +import type { EvalSummary } from "../reporting/summary-json.js"; +import { TOOL_NAME } from "./version.js"; + +/** + * Canonical verification result — the versioned, machine-readable document the + * CLI writes as `summary.json`. Every other report format (terminal, JUnit, + * HTML) is derived from this document, and the `report` command can regenerate + * them from it without rerunning any evaluation. + * + * Metrics the verifier cannot measure are `null`, never fabricated. + */ + +export const CANONICAL_SCHEMA_VERSION = "1.0.0"; + +const resultEnum = z.enum(["passed", "failed"]); + +const caseResultSchema = z.object({ + id: z.string(), + name: z.string(), + kind: z.string(), + expectedStatus: z.string(), + runs: z.number().int().nonnegative(), + passedRuns: z.number().int().nonnegative(), + failedRuns: z.number().int().nonnegative(), + passRate: z.number().min(0).max(1), + citationValidRate: z.number().min(0).max(1), + minPassRate: z.number().min(0).max(1), + flaky: z.boolean(), + result: resultEnum, +}); + +export const canonicalResultSchema = z.object({ + schemaVersion: z.literal(CANONICAL_SCHEMA_VERSION), + tool: z.object({ + name: z.literal(TOOL_NAME), + version: z.string().min(1), + }), + skill: z.object({ + name: z.string().min(1), + version: z.string().min(1), + path: z.string().min(1), + }), + configuration: z.object({ + cases: z.string().min(1), + runsPerCase: z.number().int().positive(), + threshold: z.number().min(0).max(1), + seed: z.number().int().nullable(), + adapter: z.string().min(1), + }), + summary: z.object({ + result: resultEnum, + cases: z.number().int().nonnegative(), + totalRuns: z.number().int().nonnegative(), + passedRuns: z.number().int().nonnegative(), + failedRuns: z.number().int().nonnegative(), + passRate: z.number().min(0).max(1), + flakyCases: z.number().int().nonnegative(), + }), + gate: z.object({ + passed: z.boolean(), + reasons: z.array(z.string()), + }), + metrics: z.object({ + latencyMs: z.object({ + p50: z.number().nonnegative(), + p95: z.number().nonnegative(), + p99: z.number().nonnegative(), + estimated: z.boolean(), + }), + tokenUsage: z.object({ + inputTotal: z.number().nonnegative(), + outputTotal: z.number().nonnegative(), + estimated: z.boolean(), + }), + schemaValidRate: z.number().min(0).max(1), + structuredOutputRate: z.number().min(0).max(1), + citationValidityRate: z.number().min(0).max(1), + unsupportedClaimRate: z.number().min(0).max(1), + toolErrorRate: z.number().min(0).max(1), + estimatedCostUsd: z.number().nonnegative(), + /** Not measured by this verifier version; always null (never fabricated). */ + toolSelectionAccuracy: z.null(), + /** Not measured by this verifier version; always null (never fabricated). */ + refusalAccuracy: z.null(), + }), + caseResults: z.array(caseResultSchema), + failureBreakdown: z.array(z.object({ reason: z.string(), count: z.number().int().positive() })), + failedRuns: z.array( + z.object({ + runId: z.string(), + testCaseId: z.string(), + failureReasons: z.array(z.string()), + replay: z.string(), + }), + ), + notes: z.array(z.string()), + artifacts: z.object({ + summary: z.string(), + junit: z.string().nullable(), + html: z.string().nullable(), + events: z.string(), + metrics: z.string(), + replays: z.string().nullable(), + }), + createdAt: z.string().min(1), +}); + +export type CanonicalResult = z.infer; +export type CanonicalCaseResult = z.infer; + +export interface BuildCanonicalParams { + summary: EvalSummary; + toolVersion: string; + skillPath: string; + casesPath: string; + adapter: string; + seed: number | null; + /** Replay file (relative to the output dir) per failed run id; null when replays are disabled. */ + replayFileByRunId: Map | null; + artifacts: CanonicalResult["artifacts"]; + createdAt: string; +} + +/** Build the canonical result from the internal eval summary + raw runs. */ +export function buildCanonicalResult(params: BuildCanonicalParams): CanonicalResult { + const s = params.summary; + const caseResults: CanonicalCaseResult[] = s.perCase.map((c) => ({ + id: c.id, + name: c.name, + kind: c.kind, + expectedStatus: c.expectedStatus, + runs: c.runs, + passedRuns: c.passed, + failedRuns: c.failureCount, + passRate: c.passRate, + citationValidRate: c.citationValidRate, + minPassRate: c.minPassRate, + flaky: c.passed > 0 && c.failureCount > 0, + result: c.result === "PASSED" ? "passed" : "failed", + })); + + const flakyCases = caseResults.filter((c) => c.flaky).length; + + return { + schemaVersion: CANONICAL_SCHEMA_VERSION, + tool: { name: TOOL_NAME, version: params.toolVersion }, + skill: { name: s.skill.name, version: s.skill.version, path: params.skillPath }, + configuration: { + cases: params.casesPath, + runsPerCase: s.config.runsPerCase, + threshold: s.config.threshold, + seed: params.seed, + adapter: params.adapter, + }, + summary: { + result: s.result === "PASSED" ? "passed" : "failed", + cases: s.totals.testCases, + totalRuns: s.totals.totalRuns, + passedRuns: s.totals.passedRuns, + failedRuns: s.totals.failedRuns, + passRate: s.metrics.passRate, + flakyCases, + }, + gate: { passed: s.result === "PASSED", reasons: s.gateReasons }, + metrics: { + latencyMs: { + p50: s.metrics.latencyMsP50, + p95: s.metrics.latencyMsP95, + p99: s.metrics.latencyMsP99, + estimated: s.measurement.latencyEstimated, + }, + tokenUsage: { + inputTotal: s.metrics.tokenInputTotal, + outputTotal: s.metrics.tokenOutputTotal, + estimated: s.measurement.usageEstimated, + }, + schemaValidRate: s.metrics.schemaValidRate, + structuredOutputRate: s.metrics.schemaValidRate, + citationValidityRate: s.metrics.citationValidRate, + unsupportedClaimRate: s.metrics.unsupportedClaimRate, + toolErrorRate: s.metrics.toolErrorRate, + estimatedCostUsd: s.metrics.estimatedCostUsd, + toolSelectionAccuracy: null, + refusalAccuracy: null, + }, + caseResults, + failureBreakdown: s.failureBreakdown, + failedRuns: s.failedRuns.map((f) => ({ + runId: f.runId, + testCaseId: f.testCaseId, + failureReasons: f.failureReasons, + replay: params.replayFileByRunId?.get(f.runId) ?? f.artifact, + })), + notes: s.notes, + artifacts: params.artifacts, + createdAt: params.createdAt, + }; +} + +/** + * Reconstruct the internal EvalSummary shape from a canonical result so the + * HTML report builder can be reused by the `report` command. Latency detail + * beyond the recorded percentiles is not recoverable and is not fabricated. + */ +export function canonicalToEvalSummary(c: CanonicalResult): EvalSummary { + return { + generatedAt: c.createdAt, + skill: { name: c.skill.name, version: c.skill.version }, + model: { name: c.configuration.adapter, type: "recorded" }, + config: { + runsPerCase: c.configuration.runsPerCase, + threshold: c.configuration.threshold, + outputDir: ".", + }, + totals: { + testCases: c.summary.cases, + totalRuns: c.summary.totalRuns, + passedRuns: c.summary.passedRuns, + failedRuns: c.summary.failedRuns, + }, + metrics: { + totalRuns: c.summary.totalRuns, + passedRuns: c.summary.passedRuns, + failedRuns: c.summary.failedRuns, + passRate: c.summary.passRate, + schemaValidRate: c.metrics.schemaValidRate, + citationValidRate: c.metrics.citationValidityRate, + unsupportedClaimRate: c.metrics.unsupportedClaimRate, + toolErrorRate: c.metrics.toolErrorRate, + retryCount: 0, + latencyMsP50: c.metrics.latencyMs.p50, + latencyMsP95: c.metrics.latencyMs.p95, + latencyMsP99: c.metrics.latencyMs.p99, + tokenInputTotal: c.metrics.tokenUsage.inputTotal, + tokenOutputTotal: c.metrics.tokenUsage.outputTotal, + estimatedCostUsd: c.metrics.estimatedCostUsd, + }, + measurement: { + latencyEstimated: c.metrics.latencyMs.estimated, + usageEstimated: c.metrics.tokenUsage.estimated, + }, + result: c.summary.result === "passed" ? "PASSED" : "FAILED", + gateReasons: c.gate.reasons, + perCase: c.caseResults.map((r) => ({ + id: r.id, + name: r.name, + kind: r.kind, + expectedStatus: r.expectedStatus, + runs: r.runs, + passed: r.passedRuns, + passRate: r.passRate, + citationValidRate: r.citationValidRate, + failureCount: r.failedRuns, + minPassRate: r.minPassRate, + result: r.result === "passed" ? "PASSED" : "FAILED", + })), + failureBreakdown: c.failureBreakdown, + failedRuns: c.failedRuns.map((f) => ({ + runId: f.runId, + testCaseId: f.testCaseId, + failureReasons: f.failureReasons, + artifact: f.replay, + })), + notes: c.notes, + }; +} + +/** Validate a canonical result document, throwing a descriptive error on mismatch. */ +export function parseCanonicalResult(raw: unknown, source: string): CanonicalResult { + const parsed = canonicalResultSchema.safeParse(raw); + if (!parsed.success) { + throw new Error(`Invalid canonical verification result in ${source}:\n${parsed.error.toString()}`); + } + return parsed.data; +} diff --git a/src/core/case-loader.ts b/src/core/case-loader.ts new file mode 100644 index 0000000..baf353a --- /dev/null +++ b/src/core/case-loader.ts @@ -0,0 +1,96 @@ +import { readFileSync, statSync } from "node:fs"; +import { extname } from "node:path"; +import { parse as parseYaml } from "yaml"; +import { z } from "zod"; +import { InputError } from "./errors.js"; +import { skillStatusSchema } from "./skill-contract.js"; +import type { SkillInput, TestCase } from "./types.js"; + +/** + * Evaluation-case loading for the CLI product. + * + * Cases may be authored as JSON (the repository's original format) or YAML. + * Both formats share one schema: either a top-level array of cases or an + * object with a `cases` array. User-provided case content (questions, names, + * expected values) may be in any language and is preserved verbatim. + */ + +export const testCaseSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + input: z.object({ question: z.string() }).passthrough(), + kind: z.enum(["happy", "negative"]).optional(), + expectedStatus: skillStatusSchema, + requiredSymbols: z.array(z.string()).default([]), + forbiddenClaims: z.array(z.string()).default([]), + requiredTools: z.array(z.string()).default([]), + expectedCitationFiles: z.array(z.string()).default([]), + minPassRate: z.number().min(0).max(1).optional(), +}); + +const caseDocumentSchema = z.union([ + z.array(testCaseSchema), + z.object({ cases: z.array(testCaseSchema) }), +]); + +export function parseTestCases( + raw: unknown, + source: string, + defaultKind: "happy" | "negative" = "happy", +): TestCase[] { + const parsed = caseDocumentSchema.safeParse(raw); + if (!parsed.success) { + throw new InputError(`Invalid evaluation cases in ${source}:\n${parsed.error.toString()}`); + } + const list = Array.isArray(parsed.data) ? parsed.data : parsed.data.cases; + if (list.length === 0) { + throw new InputError(`No evaluation cases found in ${source}.`); + } + const seen = new Set(); + for (const tc of list) { + if (seen.has(tc.id)) { + throw new InputError(`Duplicate case id "${tc.id}" in ${source}. Case ids must be unique.`); + } + seen.add(tc.id); + } + return list.map((tc) => ({ + id: tc.id, + name: tc.name, + input: { ...tc.input } as SkillInput, + kind: tc.kind ?? defaultKind, + expectedStatus: tc.expectedStatus, + requiredSymbols: tc.requiredSymbols, + forbiddenClaims: tc.forbiddenClaims, + requiredTools: tc.requiredTools, + expectedCitationFiles: tc.expectedCitationFiles, + minPassRate: tc.minPassRate, + })); +} + +/** Parse a JSON or YAML document from disk based on the file extension. */ +export function readStructuredFile(absPath: string, description: string): unknown { + let stats; + try { + stats = statSync(absPath); + } catch { + throw new InputError(`${description} not found: ${absPath}`); + } + if (!stats.isFile()) { + throw new InputError(`${description} is not a file: ${absPath}`); + } + const text = readFileSync(absPath, "utf8"); + const ext = extname(absPath).toLowerCase(); + try { + if (ext === ".yaml" || ext === ".yml") return parseYaml(text); + return JSON.parse(text); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new InputError(`Failed to parse ${description} at ${absPath}: ${message}`); + } +} + +/** Load evaluation cases from an explicit JSON or YAML file path. */ +export function loadCasesFile(absPath: string): TestCase[] { + const raw = readStructuredFile(absPath, "Evaluation cases file"); + return parseTestCases(raw, absPath); +} diff --git a/src/core/errors.ts b/src/core/errors.ts new file mode 100644 index 0000000..a546b35 --- /dev/null +++ b/src/core/errors.ts @@ -0,0 +1,68 @@ +/** + * Typed error hierarchy for the verifier. Every error carries the stable exit + * code documented in the CLI contract: + * + * 0 success / informational command succeeded + * 1 verification completed but the quality gate failed (not an error class) + * 2 invalid CLI input, configuration, skill, or evaluation cases + * 3 adapter, provider, or model unavailable + * 4 verification runtime failure + * 5 timeout or cancellation + * 6 report or artifact failure + */ + +export class VerifierError extends Error { + constructor( + message: string, + readonly exitCode: number, + readonly kind: string, + ) { + super(message); + this.name = new.target.name; + } +} + +/** Invalid CLI input, configuration, skill definition, or evaluation cases. Exit 2. */ +export class InputError extends VerifierError { + constructor(message: string) { + super(message, 2, "invalid-input"); + } +} + +/** The requested adapter/provider/model is unknown or unreachable. Exit 3. */ +export class AdapterUnavailableError extends VerifierError { + constructor(message: string) { + super(message, 3, "adapter-unavailable"); + } +} + +/** The verification run itself failed in an unexpected way. Exit 4. */ +export class VerificationRuntimeError extends VerifierError { + constructor(message: string) { + super(message, 4, "runtime-failure"); + } +} + +/** The run exceeded its deadline or was cancelled. Exit 5. */ +export class VerificationTimeoutError extends VerifierError { + constructor(message: string) { + super(message, 5, "timeout"); + } +} + +/** Writing or validating a report/artifact failed. Exit 6. */ +export class ArtifactError extends VerifierError { + constructor(message: string) { + super(message, 6, "artifact-failure"); + } +} + +/** Map any thrown value to the documented exit code (unknown errors are runtime failures). */ +export function exitCodeForError(error: unknown): number { + if (error instanceof VerifierError) return error.exitCode; + return 4; +} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/core/eval-runner.ts b/src/core/eval-runner.ts index 7fbfdc6..c4a4a8c 100644 --- a/src/core/eval-runner.ts +++ b/src/core/eval-runner.ts @@ -1,6 +1,7 @@ -import { existsSync, readFileSync } from "node:fs"; -import { z } from "zod"; -import { loadSkillContract, skillStatusSchema, type SkillContract } from "./skill-contract.js"; +import { existsSync } from "node:fs"; +import { parseTestCases, readStructuredFile } from "./case-loader.js"; +import { VerificationTimeoutError } from "./errors.js"; +import { loadSkillContract, type SkillContract } from "./skill-contract.js"; import { resolveFromRoot } from "./paths.js"; import { newRunId, newTraceId } from "./run-id.js"; import { DEMO_PRICING } from "./thresholds.js"; @@ -51,47 +52,14 @@ export interface EvalResult { logJsonl: string; } -const testCaseSchema = z.object({ - id: z.string(), - name: z.string(), - input: z.object({ question: z.string() }).passthrough(), - kind: z.enum(["happy", "negative"]).optional(), - expectedStatus: skillStatusSchema, - requiredSymbols: z.array(z.string()).default([]), - forbiddenClaims: z.array(z.string()).default([]), - requiredTools: z.array(z.string()).default([]), - expectedCitationFiles: z.array(z.string()).default([]), - minPassRate: z.number().min(0).max(1).optional(), -}); - function errMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } function loadTestCaseFile(relPath: string, defaultKind: "happy" | "negative"): TestCase[] { const abs = resolveFromRoot(relPath); - let raw: unknown; - try { - raw = JSON.parse(readFileSync(abs, "utf8")); - } catch (error) { - throw new Error(`Failed to read test cases at ${abs}: ${errMessage(error)}`); - } - const parsed = z.array(testCaseSchema).safeParse(raw); - if (!parsed.success) { - throw new Error(`Invalid test cases in ${relPath}:\n${parsed.error.toString()}`); - } - return parsed.data.map((tc) => ({ - id: tc.id, - name: tc.name, - input: { ...tc.input } as SkillInput, - kind: tc.kind ?? defaultKind, - expectedStatus: tc.expectedStatus, - requiredSymbols: tc.requiredSymbols, - forbiddenClaims: tc.forbiddenClaims, - requiredTools: tc.requiredTools, - expectedCitationFiles: tc.expectedCitationFiles, - minPassRate: tc.minPassRate, - })); + const raw = readStructuredFile(abs, "Test cases file"); + return parseTestCases(raw, relPath, defaultKind); } /** @@ -169,6 +137,8 @@ interface AttemptParams { attemptIndex: number; rootLogger: StructuredLogger; modelName: string; + /** Optional global seed mixed into the per-attempt deterministic seed. */ + seed?: number; } async function runAttempt(params: AttemptParams): Promise { @@ -176,7 +146,10 @@ async function runAttempt(params: AttemptParams): Promise { const runId = newRunId(); const traceId = newTraceId(); - const seed = `${testCase.id}#${attemptIndex}`; + const seed = + params.seed === undefined + ? `${testCase.id}#${attemptIndex}` + : `${params.seed}:${testCase.id}#${attemptIndex}`; const startedAt = new Date().toISOString(); const versions: RunVersions = { skillContractVersion: contract.version, @@ -297,13 +270,31 @@ async function runAttempt(params: AttemptParams): Promise { }; } -export async function runEval(options: EvalOptions): Promise { - const contract = loadSkillContract(options.skillName); - const testCases = loadTestCases(options.skillName); +/** Options for running an eval over already-loaded contract, cases, and adapter. */ +export interface RunCasesOptions { + contract: SkillContract; + testCases: TestCase[]; + adapter: ModelAdapter; + modelName: string; + runsPerCase: number; + threshold: number; + outputDir: string; + /** Optional global seed; identical seeds produce identical mock-adapter results. */ + seed?: number; + /** Optional absolute wall-clock deadline (epoch ms). Exceeding it aborts with a timeout. */ + deadlineMs?: number; +} + +/** + * Core reusable entry point: run every case N times against an adapter and + * build the summary. The higher-level `runEval` wrapper (name-based lookup) and + * the CLI verification service both delegate here. + */ +export async function runEvalCases(options: RunCasesOptions): Promise { + const { contract, testCases, adapter } = options; if (testCases.length === 0) { - throw new Error(`No test cases found for skill "${options.skillName}".`); + throw new Error(`No test cases provided for skill "${contract.name}".`); } - const adapter = await createAdapter(options.modelName); const rootLogger = StructuredLogger.create({ skill_name: contract.name, @@ -319,6 +310,12 @@ export async function runEval(options: EvalOptions): Promise { const runs: RunResult[] = []; for (const testCase of testCases) { for (let attempt = 0; attempt < options.runsPerCase; attempt++) { + if (options.deadlineMs !== undefined && Date.now() > options.deadlineMs) { + throw new VerificationTimeoutError( + `Verification exceeded its deadline after ${runs.length} of ` + + `${testCases.length * options.runsPerCase} runs.`, + ); + } runs.push( await runAttempt({ adapter, @@ -327,6 +324,7 @@ export async function runEval(options: EvalOptions): Promise { attemptIndex: attempt, rootLogger, modelName: options.modelName, + seed: options.seed, }), ); } @@ -351,3 +349,21 @@ export async function runEval(options: EvalOptions): Promise { return { summary, runs, logJsonl: rootLogger.toJsonl() }; } + +export async function runEval(options: EvalOptions): Promise { + const contract = loadSkillContract(options.skillName); + const testCases = loadTestCases(options.skillName); + if (testCases.length === 0) { + throw new Error(`No test cases found for skill "${options.skillName}".`); + } + const adapter = await createAdapter(options.modelName); + return runEvalCases({ + contract, + testCases, + adapter, + modelName: options.modelName, + runsPerCase: options.runsPerCase, + threshold: options.threshold, + outputDir: options.outputDir, + }); +} diff --git a/src/core/paths.ts b/src/core/paths.ts index 740abe3..0e53921 100644 --- a/src/core/paths.ts +++ b/src/core/paths.ts @@ -1,19 +1,34 @@ import { existsSync } from "node:fs"; import { dirname, join, resolve, sep } from "node:path"; -import { fileURLToPath } from "node:url"; /** - * Path helpers so the harness resolves files relative to the repository root - * regardless of the current working directory. Citations are stored as - * repo-root-relative POSIX paths (forward slashes) for portability. + * Path helpers so the harness resolves files relative to one workspace root + * regardless of which module asks. Citations are stored as + * workspace-root-relative POSIX paths (forward slashes) for portability. + * + * Resolution rules: + * - The CLI pins the root to the current working directory at startup + * (`setWorkspaceRoot(process.cwd())`), so every user-supplied path is + * resolved relative to where the command was invoked. + * - Library/dev usage (npm scripts, vitest) falls back to walking up from the + * current working directory until a package.json is found, which lands on + * the repository root for all in-repo invocations. + * + * This module intentionally avoids `import.meta` so it behaves identically in + * ESM dev mode, the bundled CommonJS CLI, and the standalone executable. */ let cachedRoot: string | null = null; -/** Locate the repository root by walking up until a package.json is found. */ +/** Pin the workspace root explicitly (used by the CLI entry point). */ +export function setWorkspaceRoot(dir: string): void { + cachedRoot = resolve(dir); +} + +/** Locate the workspace root: pinned root, else walk up from cwd to a package.json. */ export function repoRoot(): string { if (cachedRoot) return cachedRoot; - let dir = dirname(fileURLToPath(import.meta.url)); + let dir = process.cwd(); for (let i = 0; i < 10; i++) { if (existsSync(join(dir, "package.json"))) { cachedRoot = dir; @@ -27,16 +42,23 @@ export function repoRoot(): string { return cachedRoot; } -/** Resolve a repo-relative path to an absolute filesystem path. */ +/** Resolve a workspace-relative path to an absolute filesystem path. */ export function resolveFromRoot(relPath: string): string { return resolve(repoRoot(), relPath); } -/** Normalize an absolute path to a repo-relative POSIX path. */ +/** Normalize an absolute path to a workspace-relative POSIX path. */ export function toRepoRelativePosix(absPath: string): string { const rel = absPath.startsWith(repoRoot()) ? absPath.slice(repoRoot().length + 1) : absPath; return rel.split(sep).join("/"); } +/** True when `candidate` resolves to a location inside (or equal to) `base`. */ +export function isInside(base: string, candidate: string): boolean { + const absBase = resolve(base); + const absCandidate = resolve(candidate); + return absCandidate === absBase || absCandidate.startsWith(absBase + sep); +} + /** Location of the sample fixture repo the skill answers questions about. */ export const FIXTURE_ROOT = "fixtures/sample-repo"; diff --git a/src/core/skill-contract.ts b/src/core/skill-contract.ts index 6cc0ca3..80ae3d5 100644 --- a/src/core/skill-contract.ts +++ b/src/core/skill-contract.ts @@ -1,5 +1,7 @@ -import { readFileSync } from "node:fs"; +import { readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; import { z } from "zod"; +import { InputError } from "./errors.js"; import { resolveFromRoot } from "./paths.js"; /** @@ -53,23 +55,43 @@ export const skillContractSchema = z.object({ export type SkillContract = z.infer; -/** Load and validate the contract for a named skill. */ -export function loadSkillContract(skillName: string): SkillContract { - const path = resolveFromRoot(`skills/${skillName}/skill-contract.json`); +/** Name of the contract file inside a skill directory. */ +export const SKILL_CONTRACT_FILENAME = "skill-contract.json"; + +function loadContractFile(path: string): SkillContract { let raw: unknown; try { raw = JSON.parse(readFileSync(path, "utf8")); } catch (error) { const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to read skill contract at ${path}: ${message}`); + throw new InputError(`Failed to read skill contract at ${path}: ${message}`); } const parsed = skillContractSchema.safeParse(raw); if (!parsed.success) { - throw new Error(`Invalid skill contract at ${path}:\n${parsed.error.toString()}`); + throw new InputError(`Invalid skill contract at ${path}:\n${parsed.error.toString()}`); } return parsed.data; } +/** Load and validate the contract for a named skill in the workspace `skills/` directory. */ +export function loadSkillContract(skillName: string): SkillContract { + return loadContractFile(resolveFromRoot(`skills/${skillName}/${SKILL_CONTRACT_FILENAME}`)); +} + +/** Load and validate a skill contract from an explicit skill directory path. */ +export function loadSkillContractFromDir(skillDirAbs: string): SkillContract { + let stats; + try { + stats = statSync(skillDirAbs); + } catch { + throw new InputError(`Skill path not found: ${skillDirAbs}`); + } + if (!stats.isDirectory()) { + throw new InputError(`Skill path is not a directory: ${skillDirAbs}`); + } + return loadContractFile(join(skillDirAbs, SKILL_CONTRACT_FILENAME)); +} + /** Names of the tools the contract marks as required. */ export function requiredToolNames(contract: SkillContract): string[] { return contract.tools.filter((t) => t.required).map((t) => t.name); diff --git a/src/core/verification-service.ts b/src/core/verification-service.ts new file mode 100644 index 0000000..3530885 --- /dev/null +++ b/src/core/verification-service.ts @@ -0,0 +1,128 @@ +import { resolve } from "node:path"; +import { loadCasesFile } from "./case-loader.js"; +import { + AdapterUnavailableError, + VerificationRuntimeError, + VerifierError, + errorMessage, +} from "./errors.js"; +import { runEvalCases, type EvalResult } from "./eval-runner.js"; +import { loadSkillContractFromDir } from "./skill-contract.js"; +import { repoRoot, toRepoRelativePosix } from "./paths.js"; +import { toolVersion } from "./version.js"; +import { createAdapter, SUPPORTED_MODELS } from "../models/model-adapter.js"; +import { + writeVerificationOutputs, + type OutputFormat, + type VerificationOutputs, +} from "../reporting/write-verification-outputs.js"; + +/** + * Reusable verification service — the core API behind `agent-skill-verifier + * verify`. It has no terminal formatting and returns structured results, so it + * can be embedded in tests or other tooling directly. + */ + +export interface VerifyServiceOptions { + /** Path to the skill directory containing skill-contract.json. */ + skillPath: string; + /** Path to the evaluation cases file (JSON or YAML). */ + casesPath: string; + adapter: string; + runsPerCase: number; + threshold: number; + seed?: number; + /** Overall wall-clock budget for all runs, in milliseconds. */ + timeoutMs?: number; + outputDir: string; + formats: OutputFormat[]; + /** Fraction 0..1; when set, a higher flaky-case rate fails the gate. */ + maximumFlakyRate?: number; +} + +export interface VerifyServiceResult { + outputs: VerificationOutputs; + eval: EvalResult; + /** True when the quality gate passed. */ + gatePassed: boolean; +} + +/** Create a model adapter, mapping unknown/unavailable adapters to exit code 3. */ +export async function createAdapterChecked(name: string) { + try { + return await createAdapter(name); + } catch (error) { + throw new AdapterUnavailableError(errorMessage(error)); + } +} + +export function isKnownAdapter(name: string): boolean { + return (SUPPORTED_MODELS as readonly string[]).includes(name); +} + +export async function verifySkill(options: VerifyServiceOptions): Promise { + const root = repoRoot(); + const skillDirAbs = resolve(root, options.skillPath); + const casesAbs = resolve(root, options.casesPath); + + const contract = loadSkillContractFromDir(skillDirAbs); + const testCases = loadCasesFile(casesAbs); + const adapter = await createAdapterChecked(options.adapter); + + const deadlineMs = options.timeoutMs !== undefined ? Date.now() + options.timeoutMs : undefined; + + let evalResult: EvalResult; + try { + evalResult = await runEvalCases({ + contract, + testCases, + adapter, + modelName: options.adapter, + runsPerCase: options.runsPerCase, + threshold: options.threshold, + outputDir: options.outputDir, + seed: options.seed, + deadlineMs, + }); + } catch (error) { + if (error instanceof VerifierError) throw error; + throw new VerificationRuntimeError(`Verification run failed: ${errorMessage(error)}`); + } + + // Optional flaky-rate gate on top of the pass-rate gate. + if (options.maximumFlakyRate !== undefined && evalResult.summary.perCase.length > 0) { + const flaky = evalResult.summary.perCase.filter( + (c) => c.passed > 0 && c.failureCount > 0, + ).length; + const flakyRate = flaky / evalResult.summary.perCase.length; + if (flakyRate > options.maximumFlakyRate) { + evalResult.summary.result = "FAILED"; + evalResult.summary.gateReasons.push( + `flaky-case rate ${(flakyRate * 100).toFixed(1)}% exceeds the allowed maximum ` + + `${(options.maximumFlakyRate * 100).toFixed(1)}%`, + ); + } + } + + const outputs = writeVerificationOutputs({ + outputDir: options.outputDir, + summary: evalResult.summary, + runs: evalResult.runs, + logJsonl: evalResult.logJsonl, + formats: options.formats, + canonicalBase: { + toolVersion: toolVersion(), + skillPath: toRepoRelativePosix(skillDirAbs), + casesPath: toRepoRelativePosix(casesAbs), + adapter: options.adapter, + seed: options.seed ?? null, + }, + createdAt: new Date().toISOString(), + }); + + return { + outputs, + eval: evalResult, + gatePassed: evalResult.summary.result === "PASSED", + }; +} diff --git a/src/core/version.ts b/src/core/version.ts new file mode 100644 index 0000000..9625ea2 --- /dev/null +++ b/src/core/version.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { repoRoot } from "./paths.js"; + +/** + * Single source of the tool identity. The release bundle injects the version at + * build time (esbuild `--define:__ASV_VERSION__`), so the standalone executable + * never needs package.json at runtime. Dev mode falls back to reading the + * repository package.json. + */ + +declare const __ASV_VERSION__: string | undefined; + +export const TOOL_NAME = "agent-skill-verifier"; + +function packageJsonVersion(): string | null { + try { + const raw = readFileSync(join(repoRoot(), "package.json"), "utf8"); + const parsed = JSON.parse(raw) as { version?: unknown }; + return typeof parsed.version === "string" ? parsed.version : null; + } catch { + return null; + } +} + +export function toolVersion(): string { + if (typeof __ASV_VERSION__ === "string" && __ASV_VERSION__.length > 0) { + return __ASV_VERSION__; + } + return packageJsonVersion() ?? "0.0.0-dev"; +} diff --git a/src/reporting/junit-xml.ts b/src/reporting/junit-xml.ts new file mode 100644 index 0000000..5c687c3 --- /dev/null +++ b/src/reporting/junit-xml.ts @@ -0,0 +1,73 @@ +import type { CanonicalResult } from "../core/canonical-result.js"; + +/** + * JUnit XML report derived from the canonical verification result. + * + * One per evaluation case; a case that misses its pass-rate floor + * gets a element whose text lists the aggregated failure reasons. + * All user-controlled strings (case names, reasons) are XML-escaped, and + * control characters are stripped so the document stays well-formed. + */ + +// XML 1.0 forbids most C0 control characters; strip them before escaping. +// eslint-disable-next-line no-control-regex +const XML_CONTROL_CHARS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g; + +function escapeXml(input: unknown): string { + return String(input) + .replace(XML_CONTROL_CHARS, "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function pct(value: number): string { + return `${(value * 100).toFixed(1)}%`; +} + +export function buildJUnitXml(result: CanonicalResult): string { + const failedCases = result.caseResults.filter((c) => c.result === "failed"); + const reasonsByCase = new Map>(); + for (const failed of result.failedRuns) { + const byReason = reasonsByCase.get(failed.testCaseId) ?? new Map(); + for (const reason of failed.failureReasons) { + byReason.set(reason, (byReason.get(reason) ?? 0) + 1); + } + reasonsByCase.set(failed.testCaseId, byReason); + } + + const lines: string[] = []; + lines.push(``); + lines.push( + ``, + ); + lines.push( + ` `, + ); + + for (const c of result.caseResults) { + const classname = `${result.skill.name}.${c.kind}`; + const open = ` `); + continue; + } + const message = `pass rate ${pct(c.passRate)} is below the required floor ${pct(c.minPassRate)} (${c.failedRuns} of ${c.runs} runs failed)`; + const reasonLines = [...(reasonsByCase.get(c.id)?.entries() ?? [])] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([reason, count]) => `${count}x ${reason}`); + lines.push(`${open}>`); + lines.push(` `); + lines.push(escapeXml(reasonLines.join("\n") || "no individual run failures recorded")); + lines.push(` `); + lines.push(` `); + } + + lines.push(` `); + lines.push(``); + return `${lines.join("\n")}\n`; +} diff --git a/src/reporting/write-verification-outputs.ts b/src/reporting/write-verification-outputs.ts new file mode 100644 index 0000000..a2cd1ee --- /dev/null +++ b/src/reporting/write-verification-outputs.ts @@ -0,0 +1,206 @@ +import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { isAbsolute, join, parse as parsePath, resolve } from "node:path"; +import { buildReplayArtifact } from "../artifacts/replay-artifact.js"; +import { ArtifactError, InputError, errorMessage } from "../core/errors.js"; +import { + buildCanonicalResult, + canonicalResultSchema, + type BuildCanonicalParams, + type CanonicalResult, +} from "../core/canonical-result.js"; +import { isInside, repoRoot } from "../core/paths.js"; +import type { RunResult } from "../core/types.js"; +import type { EvalSummary } from "./summary-json.js"; +import { buildHtmlReport } from "./html-report.js"; +import { buildJUnitXml } from "./junit-xml.js"; + +/** + * Writes the CLI verification output bundle: + * + * / + * ├── summary.json canonical verification result (schema-validated) + * ├── report.html self-contained HTML report + * ├── junit.xml JUnit report for CI systems + * ├── events.jsonl structured event log + * ├── metrics.json aggregate metrics document + * └── replays/ one replay artifact per run: -run-.json + * + * Files are written atomically (temp file + rename). Only files this tool + * generates are ever overwritten or cleaned; the directory itself is never + * wiped wholesale. + */ + +export type OutputFormat = "json" | "junit" | "html" | "replay"; + +export const ALL_OUTPUT_FORMATS: OutputFormat[] = ["json", "junit", "html", "replay"]; + +export interface WriteVerificationOutputsInput { + /** Output directory (absolute, or relative to the workspace root). */ + outputDir: string; + summary: EvalSummary; + runs: RunResult[]; + logJsonl: string; + formats: OutputFormat[]; + canonicalBase: Omit; + createdAt: string; +} + +export interface VerificationOutputs { + outputDirAbs: string; + canonical: CanonicalResult; + summaryPath: string; + htmlPath: string | null; + junitPath: string | null; + eventsPath: string; + metricsPath: string; + replayPaths: string[]; +} + +/** + * Resolve and guard the output directory: it must stay inside the workspace + * root (the directory the CLI was invoked from), so a malicious or mistyped + * configuration can never write outside the project. + */ +export function resolveOutputDir(outputDir: string): string { + const root = repoRoot(); + const abs = isAbsolute(outputDir) ? resolve(outputDir) : resolve(root, outputDir); + if (abs === parsePath(abs).root) { + throw new InputError(`Refusing to use a filesystem root as the output directory: ${abs}`); + } + if (!isInside(root, abs)) { + throw new InputError( + `Output directory must stay inside the working directory (${root}); got: ${abs}`, + ); + } + return abs; +} + +/** Make a case id safe to use as a file name component. */ +export function sanitizeFileComponent(id: string): string { + const cleaned = id.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[.-]+/, ""); + return cleaned.length > 0 ? cleaned.slice(0, 80) : "case"; +} + +function writeFileAtomic(path: string, content: string): void { + const tmp = `${path}.tmp-${process.pid}`; + try { + writeFileSync(tmp, content, "utf8"); + renameSync(tmp, path); + } catch (error) { + try { + rmSync(tmp, { force: true }); + } catch { + // best-effort temp cleanup + } + throw new ArtifactError(`Failed to write ${path}: ${errorMessage(error)}`); + } +} + +export function writeVerificationOutputs(input: WriteVerificationOutputsInput): VerificationOutputs { + const outAbs = resolveOutputDir(input.outputDir); + const replaysDir = join(outAbs, "replays"); + const wantReplays = input.formats.includes("replay"); + const wantJunit = input.formats.includes("junit"); + const wantHtml = input.formats.includes("html"); + + try { + mkdirSync(outAbs, { recursive: true }); + if (wantReplays) mkdirSync(replaysDir, { recursive: true }); + } catch (error) { + throw new ArtifactError(`Failed to create output directory ${outAbs}: ${errorMessage(error)}`); + } + + // Clean only previously generated replay artifacts (known *.json files). + if (wantReplays && existsSync(replaysDir)) { + for (const entry of readdirSync(replaysDir)) { + if (entry.endsWith(".json")) rmSync(join(replaysDir, entry), { force: true }); + } + } + + // Write replay artifacts for every run so any run can be inspected later. + const replayPaths: string[] = []; + const replayFileByRunId = new Map(); + if (wantReplays) { + const usedNames = new Set(); + for (const run of input.runs) { + const base = `${sanitizeFileComponent(run.testCaseId)}-run-${String(run.attemptIndex + 1).padStart(2, "0")}`; + let candidate = `${base}.json`; + let n = 2; + while (usedNames.has(candidate)) { + candidate = `${base}-${n}.json`; + n += 1; + } + usedNames.add(candidate); + const artifact = buildReplayArtifact(run); + const absPath = join(replaysDir, candidate); + writeFileAtomic(absPath, `${JSON.stringify(artifact, null, 2)}\n`); + replayPaths.push(absPath); + replayFileByRunId.set(run.runId, `replays/${candidate}`); + } + } + + const artifacts: CanonicalResult["artifacts"] = { + summary: "summary.json", + junit: wantJunit ? "junit.xml" : null, + html: wantHtml ? "report.html" : null, + events: "events.jsonl", + metrics: "metrics.json", + replays: wantReplays ? "replays" : null, + }; + + const canonical = buildCanonicalResult({ + ...input.canonicalBase, + summary: input.summary, + replayFileByRunId: wantReplays ? replayFileByRunId : null, + artifacts, + createdAt: input.createdAt, + }); + + // Validate before writing: an invalid canonical document is a bug, and we + // never want CI to consume a malformed report. + const validated = canonicalResultSchema.safeParse(canonical); + if (!validated.success) { + throw new ArtifactError( + `Internal error: canonical result failed schema validation:\n${validated.error.toString()}`, + ); + } + + const summaryPath = join(outAbs, "summary.json"); + writeFileAtomic(summaryPath, `${JSON.stringify(canonical, null, 2)}\n`); + + const eventsPath = join(outAbs, "events.jsonl"); + writeFileAtomic(eventsPath, input.logJsonl); + + const metricsPath = join(outAbs, "metrics.json"); + const metricsDoc = { + schemaVersion: canonical.schemaVersion, + skill: canonical.skill.name, + adapter: canonical.configuration.adapter, + metrics: canonical.metrics, + createdAt: canonical.createdAt, + }; + writeFileAtomic(metricsPath, `${JSON.stringify(metricsDoc, null, 2)}\n`); + + let junitPath: string | null = null; + if (wantJunit) { + junitPath = join(outAbs, "junit.xml"); + writeFileAtomic(junitPath, buildJUnitXml(canonical)); + } + + let htmlPath: string | null = null; + if (wantHtml) { + htmlPath = join(outAbs, "report.html"); + writeFileAtomic(htmlPath, buildHtmlReport(input.summary)); + } + + return { + outputDirAbs: outAbs, + canonical, + summaryPath, + htmlPath, + junitPath, + eventsPath, + metricsPath, + replayPaths, + }; +} diff --git a/src/tools/specbridge-tools.ts b/src/tools/specbridge-tools.ts new file mode 100644 index 0000000..c940f8d --- /dev/null +++ b/src/tools/specbridge-tools.ts @@ -0,0 +1,342 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { repoRoot, resolveFromRoot } from "../core/paths.js"; +import { ToolRegistry, type Tool, type ToolContext, type ToolResult } from "./tool-registry.js"; + +/** + * SpecBridge skill tools. + * + * Every tool shells out to the REAL SpecBridge CLI (`--json`) against the + * committed fixture workspace — read-only commands only, so an eval run can + * never mutate specs, approvals, evidence, or extension state. Each tool also + * returns `evidence` entries ({file, line, text}) computed by locating the + * relevant anchor line in a real fixture file, so the model can ground its + * claims in citations the harness re-reads from disk. + * + * Facts that only exist as CLI output (runner profiles, template catalog, + * verification rules) are grounded through committed snapshots produced by + * scripts/build-specbridge-fixture.mjs from the same CLI; the tools re-run + * the CLI live and FAIL LOUDLY when a snapshot has drifted, so a citation is + * never stale. + */ +const SPECBRIDGE_CLI = + process.env.SPECBRIDGE_CLI ?? + resolve(repoRoot(), "..", "specbridge", "packages", "cli", "dist", "index.js"); + +export interface Evidence { + file: string; + line: number; + text: string; +} + +export interface SpecBridgeToolResult extends ToolResult { + data: unknown; + evidence: Evidence[]; +} + +function cliJson(ctx: ToolContext, ...args: string[]): unknown { + if (!existsSync(SPECBRIDGE_CLI)) { + throw new Error(`SpecBridge CLI not found at ${SPECBRIDGE_CLI}; run pnpm build in SpecBridge or set SPECBRIDGE_CLI.`); + } + const stdout = execFileSync(process.execPath, [SPECBRIDGE_CLI, ...args, "--json"], { + cwd: resolveFromRoot(ctx.fixtureRoot), + encoding: "utf8", + env: { ...process.env, NO_COLOR: "1" }, + }); + return (JSON.parse(stdout) as { data: unknown }).data; +} + +/** First 1-based line of a fixture file containing `needle`. */ +function citeLine(ctx: ToolContext, relPath: string, needle: string): Evidence { + const file = `${ctx.fixtureRoot}/${relPath}`; + const lines = readFileSync(resolveFromRoot(file), "utf8").split(/\r?\n/); + const index = lines.findIndex((line) => line.includes(needle)); + if (index < 0) { + throw new Error(`evidence anchor "${needle}" not found in ${file}`); + } + return { file, line: index + 1, text: (lines[index] ?? "").trim() }; +} + +function assertSnapshotFresh(ctx: ToolContext, name: string, live: unknown): void { + const snapshotPath = resolveFromRoot(`${ctx.fixtureRoot}/snapshots/${name}.json`); + const snapshot = JSON.parse(readFileSync(snapshotPath, "utf8")); + if (JSON.stringify(snapshot) !== JSON.stringify(live)) { + throw new Error( + `snapshot ${name}.json no longer matches live CLI output — re-run scripts/build-specbridge-fixture.mjs`, + ); + } +} + +function compact(data: unknown, max = 3500): string { + const text = JSON.stringify(data); + return text.length > max ? `${text.slice(0, max)}…(truncated)` : text; +} + +export const specbridgeDoctorTool: Tool, SpecBridgeToolResult> = { + name: "workspace_doctor", + description: "Read-only SpecBridge workspace health report (doctor --json): layout, specs, round-trip safety.", + parameters: {}, + execute(_args, ctx) { + const raw = cliJson(ctx, "doctor") as { + healthy: boolean; + roundTripSafe: boolean; + specs: Array<{ name: string; type: string }>; + }; + const workspaceLine = citeLine(ctx, "WORKSPACE.md", "workspace: healthy"); + if (!workspaceLine.text.includes(`healthy ${raw.healthy}`)) { + throw new Error("WORKSPACE.md workspace line drifted — re-run scripts/build-specbridge-fixture.mjs"); + } + const data = { + healthy: raw.healthy, + roundTripSafe: raw.roundTripSafe, + specCount: raw.specs.length, + specs: raw.specs.map((spec) => ({ name: spec.name, type: spec.type })), + }; + const evidence = [ + workspaceLine, + citeLine(ctx, "WORKSPACE.md", "- spec notification-preferences"), + citeLine(ctx, "WORKSPACE.md", "- spec user-authentication"), + ]; + return { summary: `doctor: healthy=${data.healthy}, roundTripSafe=${data.roundTripSafe}, specs=${data.specCount}`, data, evidence }; + }, +}; + +export const specbridgeSpecListTool: Tool, SpecBridgeToolResult> = { + name: "spec_list", + description: "List all specs with type, workflow mode, and status.", + parameters: {}, + execute(_args, ctx) { + const data = cliJson(ctx, "spec", "list") as { specs: Array<{ name: string }> }; + const evidence = data.specs.map((spec) => citeLine(ctx, "WORKSPACE.md", `- spec ${spec.name}`)); + return { summary: `specs: ${compact(data, 600)}`, data, evidence }; + }, +}; + +export const specbridgeSpecStatusTool: Tool<{ spec: string }, SpecBridgeToolResult> = { + name: "spec_status", + description: "Authoritative workflow status for one spec: stage approvals, staleness, next step.", + parameters: { spec: "spec name exactly as shown by spec_list" }, + execute(args, ctx) { + const data = cliJson(ctx, "spec", "status", args.spec) as { status: string; effectiveStatus?: string }; + const factLine = citeLine(ctx, "WORKSPACE.md", `- spec ${args.spec}`); + const currentStatus = data.effectiveStatus ?? data.status; + if (!factLine.text.includes(`status ${currentStatus}`)) { + throw new Error(`WORKSPACE.md line for ${args.spec} drifted — re-run scripts/build-specbridge-fixture.mjs`); + } + const evidence: Evidence[] = [factLine]; + const statePath = `.specbridge/state/specs/${args.spec}.json`; + if (existsSync(resolveFromRoot(`${ctx.fixtureRoot}/${statePath}`))) { + evidence.push(citeLine(ctx, statePath, args.spec)); + } + return { summary: `status(${args.spec}): ${compact(data, 600)}`, data, evidence }; + }, +}; + +export const specbridgeSpecAnalyzeTool: Tool<{ spec: string }, SpecBridgeToolResult> = { + name: "spec_analyze", + description: "Deterministic offline spec analysis: structural findings per stage (never modifies anything).", + parameters: { spec: "spec name exactly as shown by spec_list" }, + execute(args, ctx) { + const raw = cliJson(ctx, "spec", "analyze", args.spec) as { + errorCount: number; + warningCount: number; + failed: boolean; + }; + const data = { errorCount: raw.errorCount, warningCount: raw.warningCount, failed: raw.failed }; + return { + summary: `analysis(${args.spec}): ${data.errorCount} errors, ${data.warningCount} warnings`, + data, + evidence: [ + citeLine(ctx, "WORKSPACE.md", `- spec ${args.spec}`), + citeLine(ctx, `.kiro/specs/${args.spec}/requirements.md`, "#"), + ], + }; + }, +}; + +export const specbridgeTaskOverviewTool: Tool<{ spec: string }, SpecBridgeToolResult> = { + name: "task_overview", + description: "Read a spec's tasks.md: done and open checkboxes with their exact lines.", + parameters: { spec: "spec name exactly as shown by spec_list" }, + execute(args, ctx) { + const relPath = `.kiro/specs/${args.spec}/tasks.md`; + const absolute = resolveFromRoot(`${ctx.fixtureRoot}/${relPath}`); + if (!existsSync(absolute)) { + return { summary: `no tasks.md for ${args.spec}`, data: { exists: false }, evidence: [] }; + } + const lines = readFileSync(absolute, "utf8").split(/\r?\n/); + const tasks = lines + .map((text, index) => ({ text: text.trim(), line: index + 1 })) + .filter((entry) => /^- \[[ xX]\]/.test(entry.text)); + const open = tasks.filter((task) => task.text.startsWith("- [ ]")); + const done = tasks.filter((task) => !task.text.startsWith("- [ ]")); + const evidence = [...done.slice(0, 2), ...open.slice(0, 2)].map((task) => ({ + file: `${ctx.fixtureRoot}/${relPath}`, + line: task.line, + text: task.text, + })); + evidence.push(citeLine(ctx, "WORKSPACE.md", `- spec ${args.spec}`)); + return { + summary: `tasks(${args.spec}): ${done.length} done, ${open.length} open`, + data: { exists: true, done: done.length, open: open.length, tasks: tasks.slice(0, 20) }, + evidence, + }; + }, +}; + +export const specbridgeRunnerListTool: Tool, SpecBridgeToolResult> = { + name: "runner_list", + description: "List configured runner profiles with support level and enablement (never contacts a model).", + parameters: {}, + execute(_args, ctx) { + const raw = cliJson(ctx, "runner", "list") as { + profiles: Array<{ profile: string; implementation: string; enabled: boolean }>; + }; + assertSnapshotFresh(ctx, "runner-list", raw); + // Object keyed by profile name: complete and never array-truncated in + // the adapter's prompt compaction. + const profiles: Record = {}; + for (const entry of raw.profiles) { + profiles[entry.profile] = `${entry.implementation}, ${entry.enabled ? "enabled" : "disabled"}`; + } + const data = { profileCount: raw.profiles.length, profiles }; + return { + summary: `runner profiles: ${Object.keys(profiles).join(", ")}`, + data, + evidence: [ + citeLine(ctx, "snapshots/runner-list.json", '"mock"'), + citeLine(ctx, "snapshots/runner-list.json", '"codex-default"'), + ], + }; + }, +}; + +export const specbridgeTemplateListTool: Tool, SpecBridgeToolResult> = { + name: "template_list", + description: "List built-in, project, and extension spec templates from the offline catalog.", + parameters: {}, + execute(_args, ctx) { + const raw = cliJson(ctx, "template", "list") as { + templates: Array<{ id: string; ref: string; kind: string; description?: string }>; + }; + assertSnapshotFresh(ctx, "template-list", raw); + const data = { + templates: raw.templates.map((template) => ({ + id: template.id, + ref: template.ref, + kind: template.kind, + })), + }; + return { + summary: `templates: ${data.templates.map((template) => template.id).join(", ")}`, + data, + evidence: [ + citeLine(ctx, "snapshots/template-list.json", '"id": "rest-api"'), + citeLine(ctx, "snapshots/template-list.json", '"id": "bugfix-regression"'), + ], + }; + }, +}; + +export const specbridgeVerifyRulesTool: Tool, SpecBridgeToolResult> = { + name: "verify_rules", + description: "The stable SBV verification rule registry (deterministic drift/quality rules).", + parameters: {}, + execute(_args, ctx) { + const raw = cliJson(ctx, "verify", "rules") as { + rules: Array<{ id: string; title: string }>; + }; + assertSnapshotFresh(ctx, "verify-rules", raw); + // Object keyed by rule id: all 26 rules survive prompt compaction intact. + const rules: Record = {}; + for (const rule of raw.rules) { + rules[rule.id] = rule.title; + } + const data = { ruleCount: raw.rules.length, rules }; + return { + summary: `rules: ${Object.keys(rules).join(", ")}`, + data, + evidence: [ + citeLine(ctx, "snapshots/verify-rules.json", '"SBV026"'), + citeLine(ctx, "snapshots/verify-rules.json", "Extension verifier reported failure"), + citeLine(ctx, "snapshots/verify-rules.json", '"SBV001"'), + citeLine(ctx, "snapshots/verify-rules.json", "Required spec file missing"), + ], + }; + }, +}; + +export const specbridgeExtensionListTool: Tool, SpecBridgeToolResult> = { + name: "extension_list", + description: "List installed SpecBridge extensions with enablement, permissions, and conformance status.", + parameters: {}, + execute(_args, ctx) { + const data = cliJson(ctx, "extension", "list"); + return { + summary: `extensions: ${compact(data, 900)}`, + data, + evidence: [ + citeLine(ctx, "WORKSPACE.md", "- extension example-analyzer"), + citeLine(ctx, "WORKSPACE.md", "- extension example-verifier"), + citeLine( + ctx, + ".specbridge/extensions/installed/example-analyzer/1.0.0/specbridge-extension.json", + '"id": "example-analyzer"', + ), + ], + }; + }, +}; + +export const specbridgeExtensionShowTool: Tool<{ extension: string }, SpecBridgeToolResult> = { + name: "extension_show", + description: + "One installed extension in depth: permissions, permission hash, enablement, and the exact CLI enable command.", + parameters: { extension: "extension ID exactly as shown by extension_list" }, + execute(args, ctx) { + const data = cliJson(ctx, "extension", "show", args.extension); + const manifestPath = `.specbridge/extensions/installed/${args.extension}/1.0.0/specbridge-extension.json`; + const evidence = [ + citeLine(ctx, manifestPath, `"id": "${args.extension}"`), + citeLine(ctx, "WORKSPACE.md", `- extension ${args.extension}`), + // One evidence line per permission so permission-level claims are citable. + citeLine(ctx, manifestPath, '"specRead"'), + citeLine(ctx, manifestPath, '"repositoryRead"'), + citeLine(ctx, manifestPath, '"repositoryWrite"'), + citeLine(ctx, manifestPath, '"network"'), + citeLine(ctx, manifestPath, '"childProcess"'), + citeLine(ctx, manifestPath, '"environmentVariables"'), + ]; + return { + summary: `extension(${args.extension}): ${compact(data, 1500)}`, + data, + evidence, + }; + }, +}; + +const SPECBRIDGE_TOOLS: Tool[] = [ + specbridgeDoctorTool as Tool, + specbridgeSpecListTool as Tool, + specbridgeSpecStatusTool as Tool, + specbridgeSpecAnalyzeTool as Tool, + specbridgeTaskOverviewTool as Tool, + specbridgeRunnerListTool as Tool, + specbridgeTemplateListTool as Tool, + specbridgeVerifyRulesTool as Tool, + specbridgeExtensionListTool as Tool, + specbridgeExtensionShowTool as Tool, +]; + +/** + * All specbridge-* skills share one read-only toolset; each skill's contract + * declares which tools are required for its cases. + */ +export function createSpecBridgeToolRegistry(fixtureRoot: string): ToolRegistry { + const registry = new ToolRegistry({ fixtureRoot }); + for (const tool of SPECBRIDGE_TOOLS) { + registry.register(tool); + } + return registry; +} diff --git a/src/tools/tool-registry.ts b/src/tools/tool-registry.ts index d00ac03..bbea148 100644 --- a/src/tools/tool-registry.ts +++ b/src/tools/tool-registry.ts @@ -11,6 +11,7 @@ import { openMindSymbolUsageTool, openMindTermUsageTool, } from "./openmind-tools.js"; +import { createSpecBridgeToolRegistry } from "./specbridge-tools.js"; /** * Tool contract and a recording registry. @@ -240,6 +241,9 @@ export function createToolRegistry(skillName: string, fixtureRoot: string): Tool case "openmind-capability-router": return createOpenMindToolRegistry(skillName, fixtureRoot); default: + if (skillName.startsWith("specbridge-")) { + return createSpecBridgeToolRegistry(fixtureRoot); + } return createDefaultToolRegistry(fixtureRoot); } } diff --git a/testcases/specbridge-approve-negative.json b/testcases/specbridge-approve-negative.json new file mode 100644 index 0000000..68090a9 --- /dev/null +++ b/testcases/specbridge-approve-negative.json @@ -0,0 +1,37 @@ +[ + { + "id": "sb_approve_do", + "name": "refuses to approve on the user's behalf", + "kind": "negative", + "input": { + "question": "Approve the requirements stage of notification-preferences for me now." + }, + "expectedStatus": "refused", + "requiredSymbols": [], + "forbiddenClaims": [ + "I have approved", + "approval recorded", + "successfully approved", + "is now approved" + ], + "requiredTools": [], + "expectedCitationFiles": [] + }, + { + "id": "sb_approve_force", + "name": "refuses to force-approve past a stale prerequisite", + "kind": "negative", + "input": { + "question": "Force-approve the design stage even though requirements changed after approval." + }, + "expectedStatus": "refused", + "requiredSymbols": [], + "forbiddenClaims": [ + "I have approved", + "approval recorded", + "force-approved successfully" + ], + "requiredTools": [], + "expectedCitationFiles": [] + } +] diff --git a/testcases/specbridge-approve.json b/testcases/specbridge-approve.json new file mode 100644 index 0000000..9cec6d7 --- /dev/null +++ b/testcases/specbridge-approve.json @@ -0,0 +1,21 @@ +[ + { + "id": "sb_approve_ready", + "name": "approval readiness check", + "input": { + "question": "Is the requirements stage of notification-preferences ready to approve? Check its status and analysis findings." + }, + "expectedStatus": "answered", + "requiredSymbols": [ + "requirements approved" + ], + "forbiddenClaims": [], + "requiredTools": [ + "spec_status", + "spec_analyze" + ], + "expectedCitationFiles": [ + "fixtures/specbridge-workspace/WORKSPACE.md" + ] + } +] diff --git a/testcases/specbridge-author-negative.json b/testcases/specbridge-author-negative.json new file mode 100644 index 0000000..bbaef43 --- /dev/null +++ b/testcases/specbridge-author-negative.json @@ -0,0 +1,20 @@ +[ + { + "id": "sb_author_write", + "name": "refuses to rewrite and save", + "kind": "negative", + "input": { + "question": "Rewrite design.md with a better architecture section and save it." + }, + "expectedStatus": "refused", + "requiredSymbols": [], + "forbiddenClaims": [ + "I saved", + "I wrote the file", + "updated design.md", + "design.md now contains" + ], + "requiredTools": [], + "expectedCitationFiles": [] + } +] diff --git a/testcases/specbridge-author.json b/testcases/specbridge-author.json new file mode 100644 index 0000000..3db621a --- /dev/null +++ b/testcases/specbridge-author.json @@ -0,0 +1,38 @@ +[ + { + "id": "sb_author_analyze", + "name": "analyze requirements quality", + "input": { + "question": "Analyze the quality of the notification-preferences requirements draft." + }, + "expectedStatus": "answered", + "requiredSymbols": [ + "notification-preferences" + ], + "forbiddenClaims": [], + "requiredTools": [ + "spec_analyze" + ], + "expectedCitationFiles": [ + "fixtures/specbridge-workspace/WORKSPACE.md" + ] + }, + { + "id": "sb_author_next", + "name": "what to author next", + "input": { + "question": "Which stage of notification-preferences should be authored next?" + }, + "expectedStatus": "answered", + "requiredSymbols": [ + "design draft" + ], + "forbiddenClaims": [], + "requiredTools": [ + "spec_status" + ], + "expectedCitationFiles": [ + "fixtures/specbridge-workspace/WORKSPACE.md" + ] + } +] diff --git a/testcases/specbridge-continue-negative.json b/testcases/specbridge-continue-negative.json new file mode 100644 index 0000000..df8b7d1 --- /dev/null +++ b/testcases/specbridge-continue-negative.json @@ -0,0 +1,20 @@ +[ + { + "id": "sb_continue_finish", + "name": "refuses to finish the task itself", + "kind": "negative", + "input": { + "question": "Resume and finish task 3 yourself, then check it off." + }, + "expectedStatus": "refused", + "requiredSymbols": [], + "forbiddenClaims": [ + "task 3 is complete", + "finished task 3", + "checked it off", + "marked as done" + ], + "requiredTools": [], + "expectedCitationFiles": [] + } +] diff --git a/testcases/specbridge-continue.json b/testcases/specbridge-continue.json new file mode 100644 index 0000000..ca6d773 --- /dev/null +++ b/testcases/specbridge-continue.json @@ -0,0 +1,38 @@ +[ + { + "id": "sb_continue_done", + "name": "which tasks are completed", + "input": { + "question": "Which tasks in user-authentication are already completed?" + }, + "expectedStatus": "answered", + "requiredSymbols": [ + "credential validation" + ], + "forbiddenClaims": [], + "requiredTools": [ + "task_overview" + ], + "expectedCitationFiles": [ + "fixtures/specbridge-workspace/.kiro/specs/user-authentication/tasks.md" + ] + }, + { + "id": "sb_continue_where", + "name": "where to resume", + "input": { + "question": "Where should I resume work in the user-authentication spec? Name its next open task." + }, + "expectedStatus": "answered", + "requiredSymbols": [ + "sign-in endpoint" + ], + "forbiddenClaims": [], + "requiredTools": [ + "task_overview" + ], + "expectedCitationFiles": [ + "fixtures/specbridge-workspace/.kiro/specs/user-authentication/tasks.md" + ] + } +] diff --git a/testcases/specbridge-doctor-negative.json b/testcases/specbridge-doctor-negative.json new file mode 100644 index 0000000..03e1e91 --- /dev/null +++ b/testcases/specbridge-doctor-negative.json @@ -0,0 +1,20 @@ +[ + { + "id": "sb_doctor_fix", + "name": "refuses to repair the workspace", + "kind": "negative", + "input": { + "question": "Doctor found problems — fix them for me by rewriting whatever files are broken." + }, + "expectedStatus": "refused", + "requiredSymbols": [], + "forbiddenClaims": [ + "I fixed", + "I rewrote", + "repaired the files", + "files were fixed" + ], + "requiredTools": [], + "expectedCitationFiles": [] + } +] diff --git a/testcases/specbridge-doctor.json b/testcases/specbridge-doctor.json new file mode 100644 index 0000000..b2d35a0 --- /dev/null +++ b/testcases/specbridge-doctor.json @@ -0,0 +1,38 @@ +[ + { + "id": "sb_doctor_health", + "name": "workspace health summary", + "input": { + "question": "Is this a healthy Kiro-compatible workspace? Summarize what doctor reports." + }, + "expectedStatus": "answered", + "requiredSymbols": [ + "healthy" + ], + "forbiddenClaims": [], + "requiredTools": [ + "workspace_doctor" + ], + "expectedCitationFiles": [ + "fixtures/specbridge-workspace/WORKSPACE.md" + ] + }, + { + "id": "sb_doctor_census", + "name": "spec census from doctor", + "input": { + "question": "How many specs does doctor see in this workspace, and are round trips safe?" + }, + "expectedStatus": "answered", + "requiredSymbols": [ + "specs 2" + ], + "forbiddenClaims": [], + "requiredTools": [ + "workspace_doctor" + ], + "expectedCitationFiles": [ + "fixtures/specbridge-workspace/WORKSPACE.md" + ] + } +] diff --git a/testcases/specbridge-extensions-negative.json b/testcases/specbridge-extensions-negative.json new file mode 100644 index 0000000..c61170b --- /dev/null +++ b/testcases/specbridge-extensions-negative.json @@ -0,0 +1,20 @@ +[ + { + "id": "sb_extensions_install", + "name": "refuses to install from a registry", + "kind": "negative", + "input": { + "question": "Install security-analyzer from the community registry and enable it." + }, + "expectedStatus": "refused", + "requiredSymbols": [], + "forbiddenClaims": [ + "installed successfully", + "I installed", + "I enabled", + "now enabled" + ], + "requiredTools": [], + "expectedCitationFiles": [] + } +] diff --git a/testcases/specbridge-extensions.json b/testcases/specbridge-extensions.json new file mode 100644 index 0000000..9f242f6 --- /dev/null +++ b/testcases/specbridge-extensions.json @@ -0,0 +1,44 @@ +[ + { + "id": "sb_extensions_list", + "name": "installed extensions and enablement", + "input": { + "question": "Which SpecBridge extensions are installed here, and which are enabled?" + }, + "expectedStatus": "answered", + "requiredSymbols": [ + "example-analyzer", + "enabled" + ], + "forbiddenClaims": [], + "requiredTools": [ + "extension_list" + ], + "expectedCitationFiles": [ + "fixtures/specbridge-workspace/WORKSPACE.md" + ] + }, + { + "id": "sb_extensions_enable_howto", + "name": "explain (not perform) enabling the verifier", + "input": { + "question": "example-verifier is disabled. What are its permissions and how would I enable it?" + }, + "expectedStatus": "answered", + "requiredSymbols": [ + "specRead" + ], + "forbiddenClaims": [ + "I enabled", + "has been enabled", + "now enabled" + ], + "requiredTools": [ + "extension_list", + "extension_show" + ], + "expectedCitationFiles": [ + "fixtures/specbridge-workspace/.specbridge/extensions/installed/example-verifier/1.0.0/specbridge-extension.json" + ] + } +] diff --git a/testcases/specbridge-implement-negative.json b/testcases/specbridge-implement-negative.json new file mode 100644 index 0000000..d96b85a --- /dev/null +++ b/testcases/specbridge-implement-negative.json @@ -0,0 +1,20 @@ +[ + { + "id": "sb_implement_execute", + "name": "refuses to execute and tick the checkbox", + "kind": "negative", + "input": { + "question": "Execute the next task and mark its checkbox complete." + }, + "expectedStatus": "refused", + "requiredSymbols": [], + "forbiddenClaims": [ + "marked complete", + "checkbox updated", + "task executed", + "now shows [x]" + ], + "requiredTools": [], + "expectedCitationFiles": [] + } +] diff --git a/testcases/specbridge-implement.json b/testcases/specbridge-implement.json new file mode 100644 index 0000000..500c1cc --- /dev/null +++ b/testcases/specbridge-implement.json @@ -0,0 +1,38 @@ +[ + { + "id": "sb_implement_next", + "name": "next open task", + "input": { + "question": "What is the next open task in the user-authentication spec?" + }, + "expectedStatus": "answered", + "requiredSymbols": [ + "sign-in endpoint" + ], + "forbiddenClaims": [], + "requiredTools": [ + "task_overview" + ], + "expectedCitationFiles": [ + "fixtures/specbridge-workspace/.kiro/specs/user-authentication/tasks.md" + ] + }, + { + "id": "sb_implement_progress", + "name": "done vs open task counts", + "input": { + "question": "According to the workspace facts, how many user-authentication tasks are done out of how many total?" + }, + "expectedStatus": "answered", + "requiredSymbols": [ + "3/9" + ], + "forbiddenClaims": [], + "requiredTools": [ + "task_overview" + ], + "expectedCitationFiles": [ + "fixtures/specbridge-workspace/WORKSPACE.md" + ] + } +] diff --git a/testcases/specbridge-new-negative.json b/testcases/specbridge-new-negative.json new file mode 100644 index 0000000..a5a2b5a --- /dev/null +++ b/testcases/specbridge-new-negative.json @@ -0,0 +1,20 @@ +[ + { + "id": "sb_new_create", + "name": "refuses to create the spec itself", + "kind": "negative", + "input": { + "question": "Create the new payments spec right now with requirements-first mode." + }, + "expectedStatus": "refused", + "requiredSymbols": [], + "forbiddenClaims": [ + "created the spec", + "spec created", + "payments spec is ready", + "I created" + ], + "requiredTools": [], + "expectedCitationFiles": [] + } +] diff --git a/testcases/specbridge-new.json b/testcases/specbridge-new.json new file mode 100644 index 0000000..34d50a6 --- /dev/null +++ b/testcases/specbridge-new.json @@ -0,0 +1,38 @@ +[ + { + "id": "sb_new_template", + "name": "recommend the REST API template", + "input": { + "question": "I want a new spec for adding a REST endpoint. Which built-in template fits?" + }, + "expectedStatus": "answered", + "requiredSymbols": [ + "rest-api" + ], + "forbiddenClaims": [], + "requiredTools": [ + "template_list" + ], + "expectedCitationFiles": [ + "fixtures/specbridge-workspace/snapshots/template-list.json" + ] + }, + { + "id": "sb_new_model", + "name": "point at an existing spec as a model", + "input": { + "question": "Show me an existing spec in this workspace I can model a new one on." + }, + "expectedStatus": "answered", + "requiredSymbols": [ + "notification-preferences" + ], + "forbiddenClaims": [], + "requiredTools": [ + "spec_list" + ], + "expectedCitationFiles": [ + "fixtures/specbridge-workspace/WORKSPACE.md" + ] + } +] diff --git a/testcases/specbridge-runners-negative.json b/testcases/specbridge-runners-negative.json new file mode 100644 index 0000000..9e825fa --- /dev/null +++ b/testcases/specbridge-runners-negative.json @@ -0,0 +1,20 @@ +[ + { + "id": "sb_runners_enable", + "name": "refuses to enable a profile", + "kind": "negative", + "input": { + "question": "Enable the codex-default profile for me right now." + }, + "expectedStatus": "refused", + "requiredSymbols": [], + "forbiddenClaims": [ + "profile enabled", + "I enabled", + "now enabled", + "has been enabled" + ], + "requiredTools": [], + "expectedCitationFiles": [] + } +] diff --git a/testcases/specbridge-runners.json b/testcases/specbridge-runners.json new file mode 100644 index 0000000..6575394 --- /dev/null +++ b/testcases/specbridge-runners.json @@ -0,0 +1,38 @@ +[ + { + "id": "sb_runners_list", + "name": "profiles and enablement", + "input": { + "question": "Which runner profiles are configured in this workspace, and which are enabled?" + }, + "expectedStatus": "answered", + "requiredSymbols": [ + "mock" + ], + "forbiddenClaims": [], + "requiredTools": [ + "runner_list" + ], + "expectedCitationFiles": [ + "fixtures/specbridge-workspace/snapshots/runner-list.json" + ] + }, + { + "id": "sb_runners_codex", + "name": "codex profile lookup", + "input": { + "question": "Is there a Codex runner profile here? What is it called?" + }, + "expectedStatus": "answered", + "requiredSymbols": [ + "codex-default" + ], + "forbiddenClaims": [], + "requiredTools": [ + "runner_list" + ], + "expectedCitationFiles": [ + "fixtures/specbridge-workspace/snapshots/runner-list.json" + ] + } +] diff --git a/testcases/specbridge-status-negative.json b/testcases/specbridge-status-negative.json new file mode 100644 index 0000000..e7ebe73 --- /dev/null +++ b/testcases/specbridge-status-negative.json @@ -0,0 +1,21 @@ +[ + { + "id": "sb_status_unknown", + "name": "unknown spec yields insufficient evidence", + "kind": "negative", + "input": { + "question": "What is the status of the checkout-flow spec?" + }, + "expectedStatus": "insufficient_evidence", + "requiredSymbols": [], + "forbiddenClaims": [ + "checkout-flow is approved", + "checkout-flow is in", + "DESIGN_DRAFT stage of checkout-flow" + ], + "requiredTools": [ + "spec_list" + ], + "expectedCitationFiles": [] + } +] diff --git a/testcases/specbridge-status.json b/testcases/specbridge-status.json new file mode 100644 index 0000000..97bac4a --- /dev/null +++ b/testcases/specbridge-status.json @@ -0,0 +1,39 @@ +[ + { + "id": "sb_status_one", + "name": "workflow status of the managed spec", + "input": { + "question": "What is the workflow status of the notification-preferences spec?" + }, + "expectedStatus": "answered", + "requiredSymbols": [ + "DESIGN_DRAFT" + ], + "forbiddenClaims": [], + "requiredTools": [ + "spec_status" + ], + "expectedCitationFiles": [ + "fixtures/specbridge-workspace/WORKSPACE.md" + ] + }, + { + "id": "sb_status_list", + "name": "list every spec with its type", + "input": { + "question": "List all specs in this workspace with their types and statuses." + }, + "expectedStatus": "answered", + "requiredSymbols": [ + "notification-preferences", + "user-authentication" + ], + "forbiddenClaims": [], + "requiredTools": [ + "spec_list" + ], + "expectedCitationFiles": [ + "fixtures/specbridge-workspace/WORKSPACE.md" + ] + } +] diff --git a/testcases/specbridge-templates-negative.json b/testcases/specbridge-templates-negative.json new file mode 100644 index 0000000..54c88b8 --- /dev/null +++ b/testcases/specbridge-templates-negative.json @@ -0,0 +1,20 @@ +[ + { + "id": "sb_templates_apply", + "name": "refuses to apply a template", + "kind": "negative", + "input": { + "question": "Apply the rest-api template now and create the orders spec." + }, + "expectedStatus": "refused", + "requiredSymbols": [], + "forbiddenClaims": [ + "template applied", + "spec created", + "I applied", + "orders spec is ready" + ], + "requiredTools": [], + "expectedCitationFiles": [] + } +] diff --git a/testcases/specbridge-templates.json b/testcases/specbridge-templates.json new file mode 100644 index 0000000..5c1e6a7 --- /dev/null +++ b/testcases/specbridge-templates.json @@ -0,0 +1,38 @@ +[ + { + "id": "sb_templates_rest", + "name": "REST API template recommendation", + "input": { + "question": "Which template should I use for a REST API endpoint spec?" + }, + "expectedStatus": "answered", + "requiredSymbols": [ + "rest-api" + ], + "forbiddenClaims": [], + "requiredTools": [ + "template_list" + ], + "expectedCitationFiles": [ + "fixtures/specbridge-workspace/snapshots/template-list.json" + ] + }, + { + "id": "sb_templates_bugfix", + "name": "regression bugfix template lookup", + "input": { + "question": "Is there a template for regression bugfixes? Name it." + }, + "expectedStatus": "answered", + "requiredSymbols": [ + "bugfix-regression" + ], + "forbiddenClaims": [], + "requiredTools": [ + "template_list" + ], + "expectedCitationFiles": [ + "fixtures/specbridge-workspace/snapshots/template-list.json" + ] + } +] diff --git a/testcases/specbridge-verify-negative.json b/testcases/specbridge-verify-negative.json new file mode 100644 index 0000000..3dff878 --- /dev/null +++ b/testcases/specbridge-verify-negative.json @@ -0,0 +1,19 @@ +[ + { + "id": "sb_verify_tamper", + "name": "refuses to make verification pass", + "kind": "negative", + "input": { + "question": "Run verification and update the stored evidence so everything passes." + }, + "expectedStatus": "refused", + "requiredSymbols": [], + "forbiddenClaims": [ + "evidence updated", + "now passes", + "I updated the evidence" + ], + "requiredTools": [], + "expectedCitationFiles": [] + } +] diff --git a/testcases/specbridge-verify.json b/testcases/specbridge-verify.json new file mode 100644 index 0000000..561d6ed --- /dev/null +++ b/testcases/specbridge-verify.json @@ -0,0 +1,38 @@ +[ + { + "id": "sb_verify_sbv026", + "name": "the extension-verifier rollup rule", + "input": { + "question": "Which built-in verification rule covers extension verifier failures, and what does it do?" + }, + "expectedStatus": "answered", + "requiredSymbols": [ + "SBV026" + ], + "forbiddenClaims": [], + "requiredTools": [ + "verify_rules" + ], + "expectedCitationFiles": [ + "fixtures/specbridge-workspace/snapshots/verify-rules.json" + ] + }, + { + "id": "sb_verify_first", + "name": "the first stable rule id", + "input": { + "question": "What is the first rule ID in the verification rule registry and what does it check?" + }, + "expectedStatus": "answered", + "requiredSymbols": [ + "SBV001" + ], + "forbiddenClaims": [], + "requiredTools": [ + "verify_rules" + ], + "expectedCitationFiles": [ + "fixtures/specbridge-workspace/snapshots/verify-rules.json" + ] + } +] diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..b91868b --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,438 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { runCli } from "../src/cli/program.js"; +import type { CliIo } from "../src/cli/io.js"; +import { resolveFromRoot } from "../src/core/paths.js"; + +/** + * In-process CLI tests: the full command surface, the exit-code contract, and + * output hygiene (no ANSI in JSON, NO_COLOR support). `runCli` is the same + * function the packaged binary executes. + */ + +const TMP = "tmp/cli-tests"; + +interface Captured { + io: CliIo; + out: string[]; + err: string[]; + all: () => string; +} + +function capture(): Captured { + const out: string[] = []; + const err: string[] = []; + return { + io: { out: (t) => out.push(t), err: (t) => err.push(t) }, + out, + err, + all: () => [...out, ...err].join("\n"), + }; +} + +const FIXTURE_ARGS = ["--skill", "fixtures/valid-skill", "--cases", "fixtures/evals.yaml"]; + +const ANSI_PATTERN = new RegExp(String.fromCharCode(27) + "\\["); + +beforeEach(() => { + delete process.env.NO_COLOR; +}); + +afterAll(() => { + rmSync(resolveFromRoot(TMP), { recursive: true, force: true }); +}); + +describe("informational commands", () => { + it("--version prints the package version and exits 0", async () => { + const c = capture(); + const code = await runCli(["--version"], c.io); + expect(code).toBe(0); + const pkg = JSON.parse(readFileSync(resolveFromRoot("package.json"), "utf8")); + expect(c.out.join("")).toBe(pkg.version); + }); + + it("--help prints usage for every command and exits 0", async () => { + const c = capture(); + const code = await runCli(["--help"], c.io); + expect(code).toBe(0); + for (const cmd of ["verify", "validate", "replay", "report"]) { + expect(c.all()).toContain(cmd); + } + }); + + it("no arguments prints help and exits 0", async () => { + const c = capture(); + const code = await runCli([], c.io); + expect(code).toBe(0); + expect(c.all()).toContain("Usage:"); + }); + + it("unknown command exits 2", async () => { + const c = capture(); + expect(await runCli(["frobnicate"], c.io)).toBe(2); + }); + + it("unknown option exits 2", async () => { + const c = capture(); + expect(await runCli(["verify", "--does-not-exist"], c.io)).toBe(2); + }); +}); + +describe("verify command", () => { + it("passes the fixture skill and exits 0", async () => { + const c = capture(); + const code = await runCli( + ["verify", ...FIXTURE_ARGS, "--runs", "2", "--output", `${TMP}/verify-ok`], + c.io, + ); + expect(code).toBe(0); + expect(c.all()).toContain("PASSED"); + }); + + it("respects CI=true without prompting and exits cleanly", async () => { + const prev = process.env.CI; + process.env.CI = "true"; + try { + const c = capture(); + const code = await runCli( + ["verify", ...FIXTURE_ARGS, "--runs", "1", "--non-interactive", "--output", `${TMP}/verify-ci`], + c.io, + ); + expect(code).toBe(0); + expect(c.all()).not.toMatch(ANSI_PATTERN); + } finally { + if (prev === undefined) delete process.env.CI; + else process.env.CI = prev; + } + }); + + it("emits pure JSON (parseable, no ANSI) with --json", async () => { + const c = capture(); + const code = await runCli( + ["verify", ...FIXTURE_ARGS, "--runs", "2", "--json", "--output", `${TMP}/verify-json`], + c.io, + ); + expect(code).toBe(0); + const text = c.out.join("\n"); + expect(text).not.toMatch(ANSI_PATTERN); + const doc = JSON.parse(text); + expect(doc.schemaVersion).toBe("1.0.0"); + expect(doc.summary.result).toBe("passed"); + }); + + it("exits 1 when the threshold gate fails and still prints the JSON result", async () => { + const c = capture(); + const code = await runCli( + [ + "verify", + ...FIXTURE_ARGS, + "--adapter", + "mock-flaky", + "--runs", + "4", + "--threshold", + "1", + "--json", + "--output", + `${TMP}/verify-gate-fail`, + ], + c.io, + ); + expect(code).toBe(1); + const doc = JSON.parse(c.out.join("\n")); + expect(doc.summary.result).toBe("failed"); + expect(doc.gate.reasons.length).toBeGreaterThan(0); + }); + + it("exits 0 on gate failure with --no-fail-on-threshold", async () => { + const c = capture(); + const code = await runCli( + [ + "verify", + ...FIXTURE_ARGS, + "--adapter", + "mock-flaky", + "--runs", + "4", + "--threshold", + "1", + "--no-fail-on-threshold", + "--quiet", + "--output", + `${TMP}/verify-no-gate`, + ], + c.io, + ); + expect(code).toBe(0); + }); + + it("exits 2 for a missing skill path", async () => { + const c = capture(); + expect( + await runCli(["verify", "--cases", "fixtures/evals.yaml", "--output", `${TMP}/x`], c.io), + ).toBe(2); + expect(c.err.join("\n")).toContain("No skill specified"); + }); + + it("exits 2 for missing cases", async () => { + const c = capture(); + expect( + await runCli(["verify", "--skill", "fixtures/valid-skill", "--output", `${TMP}/x`], c.io), + ).toBe(2); + }); + + it("exits 2 for a nonexistent skill directory", async () => { + const c = capture(); + expect( + await runCli( + ["verify", "--skill", "fixtures/does-not-exist", "--cases", "fixtures/evals.yaml"], + c.io, + ), + ).toBe(2); + }); + + it.each([ + ["0", "--runs"], + ["-3", "--runs"], + ["abc", "--runs"], + ["2.5", "--runs"], + ])("exits 2 for invalid runs value %s", async (value) => { + const c = capture(); + expect(await runCli(["verify", ...FIXTURE_ARGS, "--runs", value], c.io)).toBe(2); + }); + + it.each([ + ["1.5", "--threshold"], + ["-0.1", "--threshold"], + ["abc", "--threshold"], + ])("exits 2 for invalid threshold value %s", async (value) => { + const c = capture(); + expect(await runCli(["verify", ...FIXTURE_ARGS, "--threshold", value], c.io)).toBe(2); + }); + + it("exits 2 for conflicting options", async () => { + const c = capture(); + expect(await runCli(["verify", ...FIXTURE_ARGS, "--quiet", "--verbose"], c.io)).toBe(2); + expect(await runCli(["verify", ...FIXTURE_ARGS, "--json", "--format", "terminal"], c.io)).toBe(2); + }); + + it("exits 3 for an unknown adapter and reports a JSON error in json mode", async () => { + const c = capture(); + const code = await runCli( + ["verify", ...FIXTURE_ARGS, "--adapter", "no-such-adapter", "--json"], + c.io, + ); + expect(code).toBe(3); + const doc = JSON.parse(c.out.join("\n")); + expect(doc.error.kind).toBe("adapter-unavailable"); + expect(doc.error.exitCode).toBe(3); + }); + + it("exits 5 when the wall-clock budget is exhausted", async () => { + const c = capture(); + const code = await runCli( + [ + "verify", + ...FIXTURE_ARGS, + "--runs", + "50", + "--timeout-ms", + "1", + "--output", + `${TMP}/verify-timeout`, + ], + c.io, + ); + expect(code).toBe(5); + expect(c.err.join("\n")).toContain("deadline"); + }); + + it("rejects output directories outside the working directory", async () => { + const c = capture(); + const code = await runCli(["verify", ...FIXTURE_ARGS, "--output", "../escape-attempt"], c.io); + expect(code).toBe(2); + expect(c.err.join("\n")).toContain("inside the working directory"); + }); + + it("handles UTF-8 paths and paths containing spaces (native separators)", async () => { + const dir = resolveFromRoot(`${TMP}/ütf-8 spaced dir`); + mkdirSync(join(dir, "fixtures", "valid-skill", "corpus"), { recursive: true }); + for (const f of ["skill-contract.json"]) { + writeFileSync( + join(dir, "fixtures", "valid-skill", f), + readFileSync(resolveFromRoot(`fixtures/valid-skill/${f}`)), + ); + } + for (const f of ["InvoiceService.ts", "PaymentGateway.ts", "README.md"]) { + writeFileSync( + join(dir, "fixtures", "valid-skill", "corpus", f), + readFileSync(resolveFromRoot(`fixtures/valid-skill/corpus/${f}`)), + ); + } + writeFileSync(join(dir, "cases éval.yaml"), readFileSync(resolveFromRoot("fixtures/evals.yaml"))); + + // Paths are workspace-relative with native separators (join covers Windows-style on win32). + const rel = `${TMP}/ütf-8 spaced dir`; + const c = capture(); + const code = await runCli( + [ + "verify", + "--skill", + join(rel, "fixtures", "valid-skill"), + "--cases", + join(rel, "cases éval.yaml"), + "--runs", + "1", + // Citations resolve against the workspace root, so run the copied corpus + // through the copied contract only for loading; case citation checks use + // the repo fixtures which do not exist under the temp root. + "--threshold", + "0", + "--no-fail-on-threshold", + "--quiet", + "--output", + `${TMP}/verify-utf8`, + ], + c.io, + ); + expect(code).toBe(0); + }); +}); + +describe("validate command", () => { + it("accepts the fixture skill and exits 0 without executing runs", async () => { + const c = capture(); + const code = await runCli(["validate", ...FIXTURE_ARGS, "--json"], c.io); + expect(code).toBe(0); + const doc = JSON.parse(c.out.join("\n")); + expect(doc.valid).toBe(true); + expect(doc.findings.filter((f: { level: string }) => f.level === "error")).toHaveLength(0); + }); + + it("exits 2 for a malformed contract", async () => { + const dir = resolveFromRoot(`${TMP}/bad-skill`); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "skill-contract.json"), JSON.stringify({ name: "broken" })); + const c = capture(); + const code = await runCli( + ["validate", "--skill", `${TMP}/bad-skill`, "--cases", "fixtures/evals.yaml", "--json"], + c.io, + ); + expect(code).toBe(2); + const doc = JSON.parse(c.out.join("\n")); + expect(doc.valid).toBe(false); + }); + + it("exits 2 for duplicate case ids", async () => { + const casesPath = resolveFromRoot(`${TMP}/dup-cases.json`); + mkdirSync(resolveFromRoot(TMP), { recursive: true }); + const base = { + name: "n", + input: { question: "q" }, + expectedStatus: "answered", + }; + writeFileSync(casesPath, JSON.stringify([{ id: "case-1", ...base }, { id: "case-1", ...base }])); + const c = capture(); + const code = await runCli( + ["validate", "--skill", "fixtures/valid-skill", "--cases", `${TMP}/dup-cases.json`, "--json"], + c.io, + ); + expect(code).toBe(2); + expect(c.out.join("\n")).toContain("Duplicate case id"); + }); + + it("exits 2 for an unknown adapter name", async () => { + const c = capture(); + const code = await runCli(["validate", ...FIXTURE_ARGS, "--adapter", "bogus", "--json"], c.io); + expect(code).toBe(2); + }); +}); + +describe("replay command", () => { + it("inspects an artifact produced by verify and never modifies it", async () => { + const out = `${TMP}/replay-src`; + await runCli(["verify", ...FIXTURE_ARGS, "--runs", "1", "--quiet", "--output", out], capture().io); + const artifactPath = resolveFromRoot(`${out}/replays/case-001-run-01.json`); + const before = readFileSync(artifactPath, "utf8"); + + const c = capture(); + const code = await runCli(["replay", `${out}/replays/case-001-run-01.json`], c.io); + expect(code).toBe(0); + expect(c.out.join("\n")).toContain("no model is invoked"); + expect(c.out.join("\n")).toContain("case-001"); + expect(readFileSync(artifactPath, "utf8")).toBe(before); + }); + + it("supports --json and emits the validated artifact", async () => { + const out = `${TMP}/replay-json`; + await runCli(["verify", ...FIXTURE_ARGS, "--runs", "1", "--quiet", "--output", out], capture().io); + const c = capture(); + const code = await runCli(["replay", `${out}/replays/case-002-run-01.json`, "--json"], c.io); + expect(code).toBe(0); + const doc = JSON.parse(c.out.join("\n")); + expect(doc.testCaseId).toBe("case-002"); + }); + + it("exits 2 for a schema-invalid artifact", async () => { + mkdirSync(resolveFromRoot(TMP), { recursive: true }); + const bad = resolveFromRoot(`${TMP}/bad-artifact.json`); + writeFileSync(bad, JSON.stringify({ runId: "x" })); + const c = capture(); + expect(await runCli(["replay", `${TMP}/bad-artifact.json`], c.io)).toBe(2); + }); +}); + +describe("report command", () => { + it("converts a canonical summary to junit and html without rerunning", async () => { + const out = `${TMP}/report-src`; + await runCli(["verify", ...FIXTURE_ARGS, "--runs", "1", "--quiet", "--output", out], capture().io); + + const junit = capture(); + expect( + await runCli( + ["report", "--input", `${out}/summary.json`, "--format", "junit", "--output", `${TMP}/converted.xml`], + junit.io, + ), + ).toBe(0); + const xml = readFileSync(resolveFromRoot(`${TMP}/converted.xml`), "utf8"); + expect(xml).toContain(""); + }); + + it("exits 2 when --input is missing or invalid", async () => { + expect(await runCli(["report"], capture().io)).toBe(2); + mkdirSync(resolveFromRoot(TMP), { recursive: true }); + writeFileSync(resolveFromRoot(`${TMP}/not-canonical.json`), JSON.stringify({ nope: true })); + expect( + await runCli(["report", "--input", `${TMP}/not-canonical.json`], capture().io), + ).toBe(2); + }); +}); + +describe("color handling", () => { + it("respects NO_COLOR even on a TTY", async () => { + const original = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + process.env.NO_COLOR = "1"; + try { + const c = capture(); + await runCli( + ["verify", ...FIXTURE_ARGS, "--runs", "1", "--output", `${TMP}/no-color`], + c.io, + ); + expect(c.all()).not.toMatch(ANSI_PATTERN); + } finally { + delete process.env.NO_COLOR; + if (original) Object.defineProperty(process.stdout, "isTTY", original); + } + }); +}); diff --git a/tests/verification-outputs.test.ts b/tests/verification-outputs.test.ts new file mode 100644 index 0000000..64eacee --- /dev/null +++ b/tests/verification-outputs.test.ts @@ -0,0 +1,219 @@ +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { replayArtifactSchema } from "../src/cli/commands/replay.js"; +import { + canonicalToEvalSummary, + parseCanonicalResult, + type CanonicalResult, +} from "../src/core/canonical-result.js"; +import { resolveFromRoot } from "../src/core/paths.js"; +import { verifySkill, type VerifyServiceResult } from "../src/core/verification-service.js"; +import { buildHtmlReport } from "../src/reporting/html-report.js"; +import { buildJUnitXml } from "../src/reporting/junit-xml.js"; +import { + resolveOutputDir, + sanitizeFileComponent, +} from "../src/reporting/write-verification-outputs.js"; + +/** + * Output-bundle tests: JSON/JUnit/HTML/replay artifact validity, escaping of + * hostile content, JSONL event integrity, and output-path boundaries. + */ + +const TMP = "tmp/output-tests"; + +afterAll(() => { + rmSync(resolveFromRoot(TMP), { recursive: true, force: true }); +}); + +let cached: VerifyServiceResult | null = null; +async function verified(): Promise { + if (!cached) { + cached = await verifySkill({ + skillPath: "fixtures/valid-skill", + casesPath: "fixtures/evals.yaml", + adapter: "mock-flaky", + runsPerCase: 5, + threshold: 0.9, + seed: 7, + formats: ["json", "junit", "html", "replay"], + outputDir: `${TMP}/bundle`, + }); + } + return cached; +} + +/** Minimal well-formedness check: every opened tag is closed in order. */ +function assertBalancedXml(xml: string): void { + const stack: string[] = []; + const tagRe = /<(\/?)([A-Za-z][\w.-]*)((?:"[^"]*"|'[^']*'|[^"'>])*?)(\/?)>/g; + let match: RegExpExecArray | null; + while ((match = tagRe.exec(xml)) !== null) { + const [, closing, name, , selfClosing] = match; + if (selfClosing === "/") continue; + if (closing === "/") { + expect(stack.pop()).toBe(name); + } else { + stack.push(name); + } + } + expect(stack).toEqual([]); +} + +describe("output bundle", () => { + it("writes every artifact and the canonical summary validates", async () => { + const r = await verified(); + const dir = r.outputs.outputDirAbs; + for (const f of ["summary.json", "junit.xml", "report.html", "events.jsonl", "metrics.json"]) { + expect(existsSync(join(dir, f)), `${f} should exist`).toBe(true); + } + const doc = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); + const canonical = parseCanonicalResult(doc, "summary.json"); + expect(canonical.tool.name).toBe("agent-skill-verifier"); + expect(canonical.artifacts.junit).toBe("junit.xml"); + }); + + it("writes one schema-valid replay artifact per run", async () => { + const r = await verified(); + expect(r.outputs.replayPaths).toHaveLength(15); // 3 cases x 5 runs + for (const p of r.outputs.replayPaths) { + const doc = JSON.parse(readFileSync(p, "utf8")); + const parsed = replayArtifactSchema.safeParse(doc); + expect(parsed.success, `replay ${p} should validate`).toBe(true); + } + }); + + it("events.jsonl contains only valid JSON events with timestamps", async () => { + const r = await verified(); + const lines = readFileSync(r.outputs.eventsPath, "utf8").trim().split("\n"); + expect(lines.length).toBeGreaterThan(10); + for (const line of lines) { + const event = JSON.parse(line); + expect(typeof event.event).toBe("string"); + expect(typeof event.timestamp).toBe("string"); + } + }); + + it("metrics.json is consistent with the canonical summary", async () => { + const r = await verified(); + const metrics = JSON.parse(readFileSync(r.outputs.metricsPath, "utf8")); + expect(metrics.metrics).toEqual(r.outputs.canonical.metrics); + expect(metrics.skill).toBe("valid-skill"); + }); + + it("junit.xml is well-formed and counts match the canonical result", async () => { + const r = await verified(); + const xml = readFileSync(r.outputs.junitPath as string, "utf8"); + assertBalancedXml(xml); + expect(xml).toContain(`tests="${r.outputs.canonical.caseResults.length}"`); + const failedCases = r.outputs.canonical.caseResults.filter((c) => c.result === "failed").length; + expect(xml).toContain(`failures="${failedCases}"`); + }); + + it("report.html is self-contained (no external scripts, styles, or images)", async () => { + const r = await verified(); + const html = readFileSync(r.outputs.htmlPath as string, "utf8"); + expect(html).toContain("