diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12828bfc..b52f62dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,7 +75,7 @@ jobs: python-version: "3.10" - name: Install Python dependencies - run: pip install pyyaml + run: pip install -r tools/requirements.txt - name: Set up Node uses: actions/setup-node@v4 @@ -103,13 +103,15 @@ jobs: - name: Audit npm dependencies run: npm audit --audit-level=high - continue-on-error: true - name: Run tests env: ENABLE_NETWORK_TESTS: "1" run: npm run test + - name: Run web app coverage + run: npm run app:test:coverage + - name: Run docs security checks run: npm run security:docs @@ -126,7 +128,7 @@ jobs: python-version: "3.10" - name: Install Python dependencies - run: pip install pyyaml + run: pip install -r tools/requirements.txt - name: Set up Node uses: actions/setup-node@v4 @@ -176,12 +178,17 @@ jobs: main-validation-and-sync: if: github.event_name != 'pull_request' runs-on: ubuntu-latest + concurrency: + group: canonical-main-sync + cancel-in-progress: false permissions: contents: write env: GH_TOKEN: ${{ github.token }} steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 @@ -189,7 +196,7 @@ jobs: python-version: "3.10" - name: Install Python dependencies - run: pip install pyyaml + run: pip install -r tools/requirements.txt - name: Set up Node uses: actions/setup-node@v4 @@ -216,37 +223,55 @@ jobs: - name: Audit npm dependencies run: npm audit --audit-level=high - continue-on-error: true - name: Run tests env: ENABLE_NETWORK_TESTS: "1" run: npm run test + - name: Run web app coverage + run: npm run app:test:coverage + - name: Run docs security checks run: npm run security:docs - name: Set up GitHub credentials if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: | + set -euo pipefail git config user.name 'github-actions[bot]' git config user.email 'github-actions[bot]@users.noreply.github.com' git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git + git fetch origin main - name: Auto-commit canonical artifacts if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: | + set -euo pipefail mapfile -t managed_files < <(node tools/scripts/generated_files.js --include-mixed) if [ "${#managed_files[@]}" -eq 0 ]; then echo "No managed files resolved from generated_files contract." exit 1 fi - git diff --quiet && exit 0 + if git diff --quiet && [ -z "$(git ls-files --others --exclude-standard)" ]; then + echo "No canonical repo-state drift detected." + exit 0 + fi git add -- "${managed_files[@]}" || true - git diff --cached --quiet && exit 0 + if git diff --cached --quiet; then + echo "Repo-state sync produced unmanaged drift only." + git status --short + exit 1 + fi + + if [ -n "$(git diff --name-only)" ] || [ -n "$(git ls-files --others --exclude-standard)" ]; then + echo "Repo-state sync produced unmanaged drift alongside canonical changes." + git status --short + exit 1 + fi git commit -m "chore: sync repo state [ci skip]" git pull origin main --rebase @@ -255,13 +280,14 @@ jobs: - name: Check for uncommitted drift if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: | - if ! git diff --quiet; then - echo "❌ Detected uncommitted changes produced by registry/readme/catalog scripts." + if ! git diff --quiet || [ -n "$(git ls-files --others --exclude-standard)" ]; then + echo "❌ Detected leftover drift after the canonical bot sync." echo - echo "Main must be self-healing after the auto-sync step." + echo "The bot may only commit managed canonical files and must leave a clean tree." echo "To fix locally, run the canonical maintainer flow:" echo " npm run release:preflight" echo " npm run sync:repo-state" echo " git status" + git status --short exit 1 fi diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 1cc813f4..8c94307d 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -25,7 +25,7 @@ jobs: python-version: "3.10" - name: Install Python dependencies - run: pip install pyyaml + run: pip install -r tools/requirements.txt - name: Setup Node uses: actions/setup-node@v4 @@ -36,6 +36,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Audit npm dependencies + run: npm audit --audit-level=high + - name: Validate references run: npm run validate:references @@ -45,6 +48,9 @@ jobs: - name: Run tests run: npm run test + - name: Run web app coverage + run: npm run app:test:coverage + - name: Run docs security checks run: npm run security:docs diff --git a/.github/workflows/repo-hygiene.yml b/.github/workflows/repo-hygiene.yml index 68e1c99c..aa6ff64e 100644 --- a/.github/workflows/repo-hygiene.yml +++ b/.github/workflows/repo-hygiene.yml @@ -11,10 +11,15 @@ permissions: jobs: sync-repo-state: runs-on: ubuntu-latest + concurrency: + group: canonical-main-sync + cancel-in-progress: false env: GH_TOKEN: ${{ github.token }} steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 @@ -22,7 +27,7 @@ jobs: python-version: "3.10" - name: Install Python dependencies - run: pip install pyyaml + run: pip install -r tools/requirements.txt - name: Set up Node uses: actions/setup-node@v4 @@ -33,6 +38,9 @@ jobs: - name: Install npm dependencies run: npm ci + - name: Audit npm dependencies + run: npm audit --audit-level=high + - name: Run repo-state sync run: npm run sync:repo-state @@ -46,17 +54,24 @@ jobs: exit 1 fi - if git diff --quiet; then + if git diff --quiet && [ -z "$(git ls-files --others --exclude-standard)" ]; then echo "No repo-state drift detected." exit 0 fi git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" + git fetch origin main git add -- "${managed_files[@]}" || true if git diff --cached --quiet; then - echo "Repo hygiene produced unmanaged drift." + echo "Repo hygiene produced unmanaged drift only." + git status --short + exit 1 + fi + + if [ -n "$(git diff --name-only)" ] || [ -n "$(git ls-files --others --exclude-standard)" ]; then + echo "Repo hygiene produced unmanaged drift alongside canonical changes." git status --short exit 1 fi diff --git a/apps/web-app/README.md b/apps/web-app/README.md index 18bc70eb..7a9da678 100644 --- a/apps/web-app/README.md +++ b/apps/web-app/README.md @@ -1,16 +1,93 @@ -# React + Vite +# Antigravity Web App -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +This app is the static catalog and skill browser for `antigravity-awesome-skills`. It ships the generated registry, renders searchable skill detail pages, and publishes the public site to GitHub Pages. -Currently, two official plugins are available: +## What This App Does -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +- Loads the generated skill catalog and related metadata from tracked assets in `public/`. +- Renders home, category, bundle, and skill detail routes for the published library. +- Adds SEO metadata, sitemap-backed URLs, and static asset resolution for GitHub Pages. +- Supports a local-only "refresh skills" developer flow through the Vite dev server plugin. -## React Compiler +## Architecture -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +- `src/pages/` contains top-level route screens such as `Home.tsx` and `SkillDetail.tsx`. +- `src/context/` holds catalog loading and shared app state. +- `src/hooks/` contains feature-specific client hooks such as star state and filters. +- `src/utils/` contains URL, SEO, and content helpers. +- `public/` contains generated catalog artifacts copied from the repo root as part of maintainer sync flows. -## Expanding the ESLint configuration +The app intentionally assumes a static hosting model in production. Anything that depends on `/api/*` is development-only unless it is backed by a real serverless or backend implementation. -If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. +## Development + +From the repo root: + +```bash +npm run app:install +npm run app:dev +``` + +Or directly from this directory: + +```bash +npm ci +npm run dev +``` + +Useful root-level commands: + +```bash +npm run app:build +npm run sync:web-assets +``` + +## Environment Variables + +The app reads configuration from `.env` files in `apps/web-app/`. + +- `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY`: optional read access for read-only community save counts. +- `VITE_ENABLE_SKILLS_SYNC=true`: explicitly exposes the local maintainer-only sync button during development. +- `VITE_SYNC_SKILLS_TOKEN`: local development token accepted by the Vite refresh plugin. +- `VITE_SITE_URL`: optional override for canonical URL generation when testing non-default hosts. + +Saving a skill is intentionally browser-local for now. The UI should not imply a shared write path until the project has a real backend contract for persistence, abuse controls, and deployment. + +## Deploy Model + +Production deploys use GitHub Pages and publish the built `dist/` output from this app. That means: + +- production is static +- Vite `configureServer` hooks are not available in production +- any refresh or sync endpoint exposed by a dev plugin must be hidden or replaced by a real backend before being treated as a public feature + +Maintainers should treat `public/skills.json.backup`, `public/sitemap.xml`, and other generated assets as derived artifacts synced from the repo root during release and hygiene workflows. + +## Catalog Data Flow + +The high-level maintainer flow is: + +1. update skill sources under `skills/` +2. regenerate canonical registry artifacts from the repo root +3. sync tracked web assets into `apps/web-app/public/` +4. build this app for Pages + +`npm run sync:repo-state` and `npm run sync:release-state` are the safest entrypoints because they keep the root catalog and the web assets aligned. + +## Testing + +From the repo root: + +```bash +cd apps/web-app && npm run test +cd apps/web-app && npm run test:coverage +npm run test +``` + +The repo-level test suite also contains workflow and documentation guardrails outside `src/`, so changes to this app can fail tests in `tools/scripts/tests/` even when the React code itself is untouched. + +## Troubleshooting + +- If the app shows stale catalog data, run `npm run sync:web-assets` from the repo root and rebuild. +- If a feature works in `npm run app:dev` but not on GitHub Pages, check whether it depends on a dev-only Vite plugin or non-static runtime behavior. +- If canonical URLs or asset links look wrong, inspect the shared path/base URL helpers before patching individual pages. diff --git a/apps/web-app/package-lock.json b/apps/web-app/package-lock.json index fb5fd7fc..ab3cc26c 100644 --- a/apps/web-app/package-lock.json +++ b/apps/web-app/package-lock.json @@ -32,6 +32,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", + "@vitest/coverage-v8": "^3.2.4", "autoprefixer": "^10.4.24", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", @@ -66,6 +67,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -379,6 +394,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -1145,6 +1170,34 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1233,6 +1286,17 @@ "node": ">= 8" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -2554,6 +2618,40 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -2725,7 +2823,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -2773,6 +2870,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3312,6 +3428,13 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", @@ -3319,6 +3442,13 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.19.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", @@ -3830,6 +3960,23 @@ "dev": true, "license": "ISC" }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -3974,6 +4121,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3987,6 +4156,32 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", @@ -4187,6 +4382,13 @@ "node": ">=18" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -4344,6 +4546,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -4403,6 +4615,76 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -4901,6 +5183,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -5849,6 +6172,16 @@ "node": "*" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/motion-dom": { "version": "12.34.2", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.2.tgz", @@ -5960,6 +6293,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6031,6 +6371,30 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -6592,6 +6956,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6626,6 +7003,60 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -6640,6 +7071,49 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -6755,6 +7229,60 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/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/test-exclude/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -7420,6 +7948,91 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", diff --git a/apps/web-app/package.json b/apps/web-app/package.json index d03c62fc..4fa9b503 100644 --- a/apps/web-app/package.json +++ b/apps/web-app/package.json @@ -40,6 +40,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", + "@vitest/coverage-v8": "^3.2.4", "autoprefixer": "^10.4.24", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/apps/web-app/public/sitemap.xml b/apps/web-app/public/sitemap.xml index 3b5e3ba7..d32791de 100644 --- a/apps/web-app/public/sitemap.xml +++ b/apps/web-app/public/sitemap.xml @@ -2,247 +2,247 @@ http://localhost/ - 2026-03-28 + 2026-03-29 daily 1.0 http://localhost/skill/phase-gated-debugging - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/saas-multi-tenant - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/akf-trust-metadata - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/clarvia-aeo-check - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/adhx - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/app-store-changelog - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/github - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/ios-debugger-agent - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/macos-menubar-tuist-app - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/macos-spm-app-packaging - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/orchestrate-batch-refactor - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/project-skill-audit - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/react-component-performance - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/simplify-code - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/swift-concurrency-expert - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/swiftui-liquid-glass - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/swiftui-performance-audit - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/swiftui-ui-patterns - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/swiftui-view-refactor - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/aegisops-ai - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/snowflake-development - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/jobgpt - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/moyu - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/xvary-stock-research - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/gdb-cli - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/ad-creative - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/ai-seo - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/churn-prevention - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/claude-api - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/cold-email - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/content-strategy - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/defuddle - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/internal-comms - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/json-canvas - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/lead-magnets - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/obsidian-bases - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/obsidian-cli - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/obsidian-markdown - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/product-marketing-context - 2026-03-28 + 2026-03-29 weekly 0.7 http://localhost/skill/revops - 2026-03-28 + 2026-03-29 weekly 0.7 diff --git a/apps/web-app/src/components/SkillCard.tsx b/apps/web-app/src/components/SkillCard.tsx index fe7ea3cd..dfa81db9 100644 --- a/apps/web-app/src/components/SkillCard.tsx +++ b/apps/web-app/src/components/SkillCard.tsx @@ -35,7 +35,7 @@ export const SkillCard = React.memo(({ skill, starCount }: SkillCardProps) => { diff --git a/apps/web-app/src/components/SkillStarButton.tsx b/apps/web-app/src/components/SkillStarButton.tsx index e7c34003..ab66b26d 100644 --- a/apps/web-app/src/components/SkillStarButton.tsx +++ b/apps/web-app/src/components/SkillStarButton.tsx @@ -4,60 +4,74 @@ import { useSkillStars } from '../hooks/useSkillStars'; interface SkillStarButtonProps { skillId: string; - initialCount?: number; - onStarClick?: () => void; + communityCount?: number; + onSaveClick?: () => void; variant?: 'default' | 'compact'; } /** - * Star button component for skills - * Uses useSkillStars hook for state management + * Local-save button for skills with an optional read-only community count. */ export function SkillStarButton({ skillId, - initialCount = 0, - onStarClick, + communityCount = 0, + onSaveClick, variant = 'default' }: SkillStarButtonProps): React.ReactElement { - const { starCount, hasStarred, handleStarClick, isLoading } = useSkillStars(skillId); - - // Use optimistic count from hook, fall back to initial - const displayCount = starCount || initialCount; + const { hasSaved, handleSaveClick, isSaving } = useSkillStars(skillId); + const actionLabel = hasSaved ? 'Saved locally' : 'Save locally'; + const communityLabel = communityCount > 0 + ? `${communityCount.toLocaleString('en-US')} community saves` + : null; const handleClick = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - if (hasStarred || isLoading) return; + if (hasSaved || isSaving) return; - await handleStarClick(); - onStarClick?.(); + await handleSaveClick(); + onSaveClick?.(); }; if (variant === 'compact') { return ( - +
+ {communityLabel && ( + + {communityLabel} + + )} + +
); } return ( - +
+ {communityLabel && ( + + {communityLabel} + + )} + +
); } diff --git a/apps/web-app/src/context/SkillContext.tsx b/apps/web-app/src/context/SkillContext.tsx index 5dd7bb35..a63fd179 100644 --- a/apps/web-app/src/context/SkillContext.tsx +++ b/apps/web-app/src/context/SkillContext.tsx @@ -1,6 +1,7 @@ import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react'; import type { Skill, StarMap } from '../types'; import { supabase } from '../lib/supabase'; +import { getSkillsIndexCandidateUrls } from '../utils/publicAssetUrls'; interface SkillContextType { skills: Skill[]; @@ -10,67 +11,8 @@ interface SkillContextType { refreshSkills: () => Promise; } -interface SkillsIndexUrlInput { - baseUrl: string; - origin: string; - pathname: string; - documentBaseUrl?: string; -} - const SkillContext = createContext(undefined); -function normalizeBasePath(baseUrl: string): string { - const normalizedSegments = baseUrl - .trim() - .split('/') - .filter((segment) => segment.length > 0 && segment !== '.'); - - const normalizedPath = normalizedSegments.length > 0 - ? `/${normalizedSegments.join('/')}` - : '/'; - - return normalizedPath.endsWith('/') ? normalizedPath : `${normalizedPath}/`; -} - -function appendBackupCandidates(urls: string[]): string[] { - const candidates = new Set(); - - urls.forEach((url) => { - candidates.add(url); - - if (url.endsWith('skills.json')) { - candidates.add(`${url}.backup`); - } - }); - - return Array.from(candidates); -} - -export function getSkillsIndexCandidateUrls({ - baseUrl, - origin, - pathname, - documentBaseUrl, -}: SkillsIndexUrlInput): string[] { - const normalizedPathname = pathname.startsWith('/') ? pathname : `/${pathname}`; - const baseCandidate = new URL( - 'skills.json', - documentBaseUrl || new URL(normalizeBasePath(baseUrl), origin), - ).href; - const pathSegments = normalizedPathname.split('/').filter(Boolean); - const pathCandidates = pathSegments.map((_, index) => { - const prefix = `/${pathSegments.slice(0, index + 1).join('/')}/`; - return `${origin}${prefix}skills.json`; - }); - - return appendBackupCandidates([ - baseCandidate, - new URL('skills.json', new URL(normalizeBasePath(baseUrl), origin)).href, - `${origin}/skills.json`, - ...pathCandidates, - ]); -} - export function SkillProvider({ children }: { children: React.ReactNode }) { const [skills, setSkills] = useState([]); const [stars, setStars] = useState({}); diff --git a/apps/web-app/src/context/__tests__/SkillContext.test.tsx b/apps/web-app/src/context/__tests__/SkillContext.test.tsx index 689fa497..30b2b177 100644 --- a/apps/web-app/src/context/__tests__/SkillContext.test.tsx +++ b/apps/web-app/src/context/__tests__/SkillContext.test.tsx @@ -1,7 +1,7 @@ import { act, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; -import { getSkillsIndexCandidateUrls, SkillProvider, useSkills } from '../SkillContext'; +import { SkillProvider, useSkills } from '../SkillContext'; // Keep tests deterministic by skipping real Supabase requests. vi.mock('../../lib/supabase', () => ({ @@ -23,44 +23,6 @@ function SkillsProbe() { ); } -describe('getSkillsIndexCandidateUrls', () => { - it('keeps stable candidates for gh-pages base path', () => { - expect( - getSkillsIndexCandidateUrls({ - baseUrl: '/antigravity-awesome-skills/', - origin: 'https://sickn33.github.io', - pathname: '/antigravity-awesome-skills/skill/some-id', - documentBaseUrl: 'https://sickn33.github.io/antigravity-awesome-skills/', - }), - ).toEqual([ - 'https://sickn33.github.io/antigravity-awesome-skills/skills.json', - 'https://sickn33.github.io/antigravity-awesome-skills/skills.json.backup', - 'https://sickn33.github.io/skills.json', - 'https://sickn33.github.io/skills.json.backup', - 'https://sickn33.github.io/antigravity-awesome-skills/skill/skills.json', - 'https://sickn33.github.io/antigravity-awesome-skills/skill/skills.json.backup', - 'https://sickn33.github.io/antigravity-awesome-skills/skill/some-id/skills.json', - 'https://sickn33.github.io/antigravity-awesome-skills/skill/some-id/skills.json.backup', - ]); - }); - - it('normalizes dot-relative BASE_URL values', () => { - expect( - getSkillsIndexCandidateUrls({ - baseUrl: './', - origin: 'https://sickn33.github.io', - pathname: '/antigravity-awesome-skills/', - documentBaseUrl: 'https://sickn33.github.io/antigravity-awesome-skills/', - }), - ).toEqual([ - 'https://sickn33.github.io/antigravity-awesome-skills/skills.json', - 'https://sickn33.github.io/antigravity-awesome-skills/skills.json.backup', - 'https://sickn33.github.io/skills.json', - 'https://sickn33.github.io/skills.json.backup', - ]); - }); -}); - describe('SkillProvider', () => { beforeEach(() => { (global.fetch as Mock).mockReset(); diff --git a/apps/web-app/src/factories/skill.ts b/apps/web-app/src/factories/skill.ts index d7b54e8f..a220a5d4 100644 --- a/apps/web-app/src/factories/skill.ts +++ b/apps/web-app/src/factories/skill.ts @@ -9,7 +9,7 @@ export function createMockSkill(overrides?: Partial): Skill { name: 'Test Skill', description: 'A test skill for testing purposes', category: 'testing', - risk: 'low', + risk: 'safe', source: 'test', date_added: '2024-01-01', path: 'skills/test/SKILL.md', diff --git a/apps/web-app/src/hooks/__tests__/useSkillStars.test.ts b/apps/web-app/src/hooks/__tests__/useSkillStars.test.ts index fa08f013..5cbf621d 100644 --- a/apps/web-app/src/hooks/__tests__/useSkillStars.test.ts +++ b/apps/web-app/src/hooks/__tests__/useSkillStars.test.ts @@ -2,81 +2,58 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; import { useSkillStars } from '../useSkillStars'; -const STORAGE_KEY = 'user_stars'; - -const supabaseMocks = vi.hoisted(() => { - const maybeSingle = vi.fn(); - const upsert = vi.fn(); - const select = vi.fn(() => ({ eq: vi.fn(() => ({ maybeSingle })) })); - const from = vi.fn(() => ({ select, upsert })); - - return { maybeSingle, upsert, select, from }; -}); - -vi.mock('../../lib/supabase', () => ({ - supabase: { - from: supabaseMocks.from, - }, -})); +const STORAGE_KEY = 'saved_skills'; +const LEGACY_STORAGE_KEY = 'user_stars'; describe('useSkillStars', () => { beforeEach(() => { - // Clear localStorage mock before each test localStorage.clear(); vi.clearAllMocks(); - supabaseMocks.from.mockReturnValue({ select: supabaseMocks.select, upsert: supabaseMocks.upsert }); - supabaseMocks.select.mockReturnValue({ eq: vi.fn(() => ({ maybeSingle: supabaseMocks.maybeSingle })) }); - supabaseMocks.maybeSingle.mockResolvedValue({ data: null, error: null }); - supabaseMocks.upsert.mockResolvedValue({ error: null }); }); describe('Initialization', () => { - it('should initialize with zero stars when no skillId provided', () => { + it('should initialize as unsaved when no skillId is provided', () => { const { result } = renderHook(() => useSkillStars(undefined)); - expect(result.current.starCount).toBe(0); - expect(result.current.hasStarred).toBe(false); - expect(result.current.isLoading).toBe(false); + expect(result.current.hasSaved).toBe(false); + expect(result.current.isSaving).toBe(false); }); - it('should initialize with zero stars for new skill', () => { + it('should initialize as unsaved for a new skill', () => { const { result } = renderHook(() => useSkillStars('new-skill')); - expect(result.current.starCount).toBe(0); - expect(result.current.hasStarred).toBe(false); + expect(result.current.hasSaved).toBe(false); }); - it('should overlay a local star on top of the shared count', async () => { + it('should read saved state from the new storage key', async () => { localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'test-skill': true })); - supabaseMocks.maybeSingle.mockResolvedValue({ data: { star_count: 7 }, error: null }); const { result } = renderHook(() => useSkillStars('test-skill')); await waitFor(() => { - expect(result.current.starCount).toBe(8); + expect(result.current.hasSaved).toBe(true); }); - - expect(result.current.hasStarred).toBe(true); }); - it('should read starred status from localStorage on init', () => { - localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'test-skill': true })); + it('should read saved state from the legacy storage key', async () => { + localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify({ 'test-skill': true })); const { result } = renderHook(() => useSkillStars('test-skill')); - expect(result.current.hasStarred).toBe(true); + await waitFor(() => { + expect(result.current.hasSaved).toBe(true); + }); }); it('should handle corrupted localStorage gracefully', () => { - // Mock getItem to return invalid JSON const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); localStorage.setItem(STORAGE_KEY, 'invalid-json'); const { result } = renderHook(() => useSkillStars('test-skill')); - expect(result.current.hasStarred).toBe(false); + expect(result.current.hasSaved).toBe(false); expect(consoleSpy).toHaveBeenCalledWith( - 'Failed to parse user_stars from localStorage:', + `Failed to parse ${STORAGE_KEY} from localStorage:`, expect.any(Error) ); @@ -84,80 +61,69 @@ describe('useSkillStars', () => { }); }); - describe('handleStarClick', () => { - it('should not allow starring without skillId', async () => { + describe('handleSaveClick', () => { + it('should not allow saving without skillId', async () => { const { result } = renderHook(() => useSkillStars(undefined)); await act(async () => { - await result.current.handleStarClick(); + await result.current.handleSaveClick(); }); - expect(result.current.starCount).toBe(0); + expect(result.current.hasSaved).toBe(false); expect(localStorage.setItem).not.toHaveBeenCalled(); }); - it('should not allow double-starring same skill', async () => { + it('should not allow double-saving the same skill', async () => { localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'skill-1': true })); const { result } = renderHook(() => useSkillStars('skill-1')); await act(async () => { - await result.current.handleStarClick(); + await result.current.handleSaveClick(); }); - // Star count should remain unchanged (already starred) - expect(result.current.starCount).toBe(0); - expect(result.current.hasStarred).toBe(true); + expect(result.current.hasSaved).toBe(true); }); - it('should optimistically update star count', async () => { + it('should optimistically mark the skill as saved', async () => { const { result } = renderHook(() => useSkillStars('optimistic-skill')); - // Initial state - expect(result.current.starCount).toBe(0); + expect(result.current.hasSaved).toBe(false); - // Click star await act(async () => { - await result.current.handleStarClick(); + await result.current.handleSaveClick(); }); - // Should be optimistically updated await waitFor(() => { - expect(result.current.starCount).toBe(1); - expect(result.current.hasStarred).toBe(true); + expect(result.current.hasSaved).toBe(true); }); - expect(supabaseMocks.upsert).not.toHaveBeenCalled(); }); - it('should persist starred status to localStorage', async () => { + it('should persist saved status to localStorage', async () => { const { result } = renderHook(() => useSkillStars('persist-skill')); await act(async () => { - await result.current.handleStarClick(); + await result.current.handleSaveClick(); }); expect(localStorage.setItem).toHaveBeenCalledWith( STORAGE_KEY, JSON.stringify({ 'persist-skill': true }) ); - expect(supabaseMocks.upsert).not.toHaveBeenCalled(); }); - it('should set loading state during operation', async () => { + it('should expose a settled saving state after completion', async () => { const { result } = renderHook(() => useSkillStars('loading-skill')); - // Wait for initial render await waitFor(() => { - expect(result.current.isLoading).toBe(false); + expect(result.current.isSaving).toBe(false); }); - // Click star - the loading state may change very quickly due to the async nature await act(async () => { - await result.current.handleStarClick(); + await result.current.handleSaveClick(); }); - // After completion, loading should be false - expect(result.current.isLoading).toBe(false); + expect(result.current.isSaving).toBe(false); }); }); @@ -165,21 +131,19 @@ describe('useSkillStars', () => { it('should handle setItem errors gracefully', async () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - // Mock setItem to throw localStorage.setItem = vi.fn(() => { throw new Error('Storage quota exceeded'); }); const { result } = renderHook(() => useSkillStars('error-skill')); - // Should still optimistically update UI await act(async () => { - await result.current.handleStarClick(); + await result.current.handleSaveClick(); }); - expect(result.current.starCount).toBe(1); + expect(result.current.hasSaved).toBe(true); expect(consoleSpy).toHaveBeenCalledWith( - 'Failed to save user_stars to localStorage:', + `Failed to save ${STORAGE_KEY} to localStorage:`, expect.any(Error) ); @@ -191,16 +155,15 @@ describe('useSkillStars', () => { it('should return all expected properties', () => { const { result } = renderHook(() => useSkillStars('test')); - expect(result.current).toHaveProperty('starCount'); - expect(result.current).toHaveProperty('hasStarred'); - expect(result.current).toHaveProperty('handleStarClick'); - expect(result.current).toHaveProperty('isLoading'); + expect(result.current).toHaveProperty('hasSaved'); + expect(result.current).toHaveProperty('handleSaveClick'); + expect(result.current).toHaveProperty('isSaving'); }); - it('should expose handleStarClick as function', () => { + it('should expose handleSaveClick as function', () => { const { result } = renderHook(() => useSkillStars('test')); - expect(typeof result.current.handleStarClick).toBe('function'); + expect(typeof result.current.handleSaveClick).toBe('function'); }); }); }); diff --git a/apps/web-app/src/hooks/__tests__/useSkillStarsSecurity.test.ts b/apps/web-app/src/hooks/__tests__/useSkillStarsSecurity.test.ts index c3b1e31a..b99aa307 100644 --- a/apps/web-app/src/hooks/__tests__/useSkillStarsSecurity.test.ts +++ b/apps/web-app/src/hooks/__tests__/useSkillStarsSecurity.test.ts @@ -1,41 +1,28 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; -const maybeSingle = vi.fn().mockResolvedValue({ data: null, error: null }); -const upsert = vi.fn().mockResolvedValue({ error: null }); -const select = vi.fn(() => ({ eq: vi.fn(() => ({ maybeSingle })) })); -const from = vi.fn(() => ({ select, upsert })); - -vi.mock('../../lib/supabase', () => ({ - supabase: { - from, - }, -})); - -describe('useSkillStars shared writes', () => { +describe('useSkillStars local-only persistence', () => { beforeEach(() => { localStorage.clear(); vi.clearAllMocks(); - from.mockReturnValue({ select, upsert }); - select.mockReturnValue({ eq: vi.fn(() => ({ maybeSingle })) }); - maybeSingle.mockResolvedValue({ data: null, error: null }); - upsert.mockResolvedValue({ error: null }); }); - it('does not upsert shared star counts when frontend writes are disabled', async () => { + it('only records a local save in browser storage', async () => { const { useSkillStars } = await import('../useSkillStars'); - const { result } = renderHook(() => useSkillStars('shared-stars-disabled')); + const { result } = renderHook(() => useSkillStars('saved-locally')); await waitFor(() => { - expect(result.current.isLoading).toBe(false); + expect(result.current.isSaving).toBe(false); }); await act(async () => { - await result.current.handleStarClick(); + await result.current.handleSaveClick(); }); - expect(upsert).not.toHaveBeenCalled(); - expect(result.current.hasStarred).toBe(true); - expect(result.current.starCount).toBe(1); + expect(result.current.hasSaved).toBe(true); + expect(localStorage.setItem).toHaveBeenCalledWith( + 'saved_skills', + JSON.stringify({ 'saved-locally': true }), + ); }); }); diff --git a/apps/web-app/src/hooks/useSkillStars.ts b/apps/web-app/src/hooks/useSkillStars.ts index 21a46f8b..2923a042 100644 --- a/apps/web-app/src/hooks/useSkillStars.ts +++ b/apps/web-app/src/hooks/useSkillStars.ts @@ -1,34 +1,40 @@ import { useState, useEffect, useCallback } from 'react'; -import { supabase } from '../lib/supabase'; -const STORAGE_KEY = 'user_stars'; +const STORAGE_KEY = 'saved_skills'; +const LEGACY_STORAGE_KEY = 'user_stars'; interface UserStars { [skillId: string]: boolean; } interface UseSkillStarsReturn { - starCount: number; - hasStarred: boolean; - handleStarClick: () => Promise; - isLoading: boolean; + hasSaved: boolean; + handleSaveClick: () => Promise; + isSaving: boolean; } /** * Safely parse localStorage data with error handling */ -function getUserStarsFromStorage(): UserStars { +function parseStoredStars(storageKey: string): UserStars { try { - const stored = localStorage.getItem(STORAGE_KEY); + const stored = localStorage.getItem(storageKey); if (!stored) return {}; const parsed = JSON.parse(stored); return typeof parsed === 'object' && parsed !== null ? parsed : {}; } catch (error) { - console.warn('Failed to parse user_stars from localStorage:', error); + console.warn(`Failed to parse ${storageKey} from localStorage:`, error); return {}; } } +function getUserStarsFromStorage(): UserStars { + return { + ...parseStoredStars(LEGACY_STORAGE_KEY), + ...parseStoredStars(STORAGE_KEY), + }; +} + /** * Safely save to localStorage with error handling */ @@ -36,82 +42,53 @@ function saveUserStarsToStorage(stars: UserStars): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(stars)); } catch (error) { - console.warn('Failed to save user_stars to localStorage:', error); + console.warn(`Failed to save ${STORAGE_KEY} to localStorage:`, error); } } /** - * Hook to manage skill starring functionality - * Handles localStorage persistence, optimistic UI updates, and Supabase sync + * Hook to manage local skill saves in the browser. */ export function useSkillStars(skillId: string | undefined): UseSkillStarsReturn { - const [starCount, setStarCount] = useState(0); - const [hasStarred, setHasStarred] = useState(false); - const [isLoading, setIsLoading] = useState(false); + const [hasSaved, setHasSaved] = useState(false); + const [isSaving, setIsSaving] = useState(false); - // Initialize star count from Supabase and check if user has starred useEffect(() => { - if (!skillId) return; + if (!skillId) { + setHasSaved(false); + return; + } - const initializeStars = async () => { - // Check localStorage for user's starred status - const userStars = getUserStarsFromStorage(); - setHasStarred(!!userStars[skillId]); - - // Fetch star count from Supabase if available - if (supabase) { - try { - const { data, error } = await supabase - .from('skill_stars') - .select('star_count') - .eq('skill_id', skillId) - .maybeSingle(); - - if (!error && data) { - setStarCount(data.star_count + (userStars[skillId] ? 1 : 0)); - } - } catch (err) { - console.warn('Failed to fetch star count:', err); - } - } - }; - - initializeStars(); + const userStars = getUserStarsFromStorage(); + setHasSaved(!!userStars[skillId]); }, [skillId]); /** - * Handle star button click - * Prevents double-starring, updates optimistically, persists local state + * Save a skill locally in this browser without pretending to update shared metrics. */ - const handleStarClick = useCallback(async () => { - if (!skillId || isLoading) return; + const handleSaveClick = useCallback(async () => { + if (!skillId || isSaving) return; - // Check if user has already starred (prevent spam) const userStars = getUserStarsFromStorage(); if (userStars[skillId]) return; - setIsLoading(true); + setIsSaving(true); + setHasSaved(true); try { - // Optimistically update UI - setStarCount(prev => prev + 1); - setHasStarred(true); - - // Persist to localStorage const updatedStars = { ...userStars, [skillId]: true }; saveUserStarsToStorage(updatedStars); } catch (error) { - console.error('Failed to star skill:', error); + console.error('Failed to save skill locally:', error); } finally { - setIsLoading(false); + setIsSaving(false); } - }, [skillId, isLoading]); + }, [skillId, isSaving]); return { - starCount, - hasStarred, - handleStarClick, - isLoading + hasSaved, + handleSaveClick, + isSaving }; } diff --git a/apps/web-app/src/lib/supabase.ts b/apps/web-app/src/lib/supabase.ts index c4a39b2a..a45092b8 100644 --- a/apps/web-app/src/lib/supabase.ts +++ b/apps/web-app/src/lib/supabase.ts @@ -1,8 +1,7 @@ import { createClient, SupabaseClient } from '@supabase/supabase-js' -// Public Supabase credentials for the shared community stars database. -// The anon key is a public key by design — security is enforced via RLS policies. -// .env values override these defaults if provided. +// Public Supabase credentials for the read-only community save counts feed. +// The browser UI does not perform shared writes; .env values can override these defaults. const supabaseUrl = (import.meta as ImportMeta & { env: Record }).env.VITE_SUPABASE_URL || 'https://gczhgcbtjbvfrgfmpbmv.supabase.co' diff --git a/apps/web-app/src/pages/Home.tsx b/apps/web-app/src/pages/Home.tsx index 7ac86a93..e7d81873 100644 --- a/apps/web-app/src/pages/Home.tsx +++ b/apps/web-app/src/pages/Home.tsx @@ -5,7 +5,7 @@ import { useSkills } from '../context/SkillContext'; import { SkillCard } from '../components/SkillCard'; import type { SyncMessage, CategoryStats } from '../types'; import { usePageMeta } from '../hooks/usePageMeta'; -import { APP_HOME_CATALOG_COUNT, buildHomeMeta, getHomeFaqItems } from '../utils/seo'; +import { buildHomeMeta, getHomeFaqItems } from '../utils/seo'; const conceptCards = [ { @@ -49,6 +49,11 @@ const integrationGuides = [ }, ] as const; +const syncFeatureEnabled = ( + (import.meta as ImportMeta & { env: Record }).env.VITE_ENABLE_SKILLS_SYNC + === 'true' +); + export function Home(): React.ReactElement { const { skills, stars, loading, error, refreshSkills } = useSkills(); const [search, setSearch] = useState(''); @@ -62,6 +67,7 @@ export function Home(): React.ReactElement { const docsLink = 'https://github.com/sickn33/antigravity-awesome-skills/blob/main/docs/users/usage.md'; const installLink = 'https://www.npmjs.com/package/antigravity-awesome-skills'; const faqItems = getHomeFaqItems(); + const catalogCountLabel = skills.length > 0 ? skills.length.toLocaleString('en-US') : 'installable'; usePageMeta(buildHomeMeta(skills.length)); @@ -197,7 +203,7 @@ export function Home(): React.ReactElement {

Explore Skills

- Discover {Math.max(skills.length, APP_HOME_CATALOG_COUNT)}+ agentic capabilities for your AI assistant. + Discover {catalogCountLabel} agentic capabilities for your AI assistant.

@@ -211,16 +217,27 @@ export function Home(): React.ReactElement { {syncMsg.text} )} - + {syncFeatureEnabled ? ( + + ) : ( + + Public catalog mode + + )}
+ {!syncFeatureEnabled && ( +

+ Catalog sync is a maintainer-only workflow in local builds, so the public Pages site always shows the last published catalog. +

+ )}
@@ -259,7 +276,7 @@ export function Home(): React.ReactElement { onChange={(e) => setSortBy(e.target.value)} > - + diff --git a/apps/web-app/src/pages/SkillDetail.tsx b/apps/web-app/src/pages/SkillDetail.tsx index 039e1e62..4d3b5ac1 100644 --- a/apps/web-app/src/pages/SkillDetail.tsx +++ b/apps/web-app/src/pages/SkillDetail.tsx @@ -5,59 +5,13 @@ import { SkillStarButton } from '../components/SkillStarButton'; import { useSkills } from '../context/SkillContext'; import { usePageMeta } from '../hooks/usePageMeta'; import { buildSkillFallbackMeta, buildSkillMeta, selectTopSkills } from '../utils/seo'; +import { getSkillMarkdownCandidateUrls } from '../utils/publicAssetUrls'; import remarkGfm from 'remark-gfm'; import rehypeHighlight from 'rehype-highlight'; // Lazy load heavy markdown component const Markdown = lazy(() => import('react-markdown')); -interface SkillMarkdownUrlInput { - baseUrl: string; - origin: string; - pathname: string; - documentBaseUrl?: string; - skillPath: string; -} - -function normalizeBasePath(baseUrl: string): string { - const normalizedSegments = baseUrl - .trim() - .split('/') - .filter((segment) => segment.length > 0 && segment !== '.'); - - const normalizedPath = normalizedSegments.length > 0 - ? `/${normalizedSegments.join('/')}` - : '/'; - - return normalizedPath.endsWith('/') ? normalizedPath : `${normalizedPath}/`; -} - -export function getSkillMarkdownCandidateUrls({ - baseUrl, - origin, - pathname, - documentBaseUrl, - skillPath, -}: SkillMarkdownUrlInput): string[] { - const normalizedSkillPath = skillPath - .replace(/^\/+/, '') - .replace(/\/SKILL\.md$/i, ''); - const assetPath = `${normalizedSkillPath}/SKILL.md`; - const normalizedPathname = pathname.startsWith('/') ? pathname : `/${pathname}`; - const pathSegments = normalizedPathname.split('/').filter(Boolean); - const pathCandidates = pathSegments.map((_, index) => { - const prefix = `/${pathSegments.slice(0, index + 1).join('/')}/`; - return `${origin}${prefix}${assetPath}`; - }); - - return Array.from(new Set([ - new URL(assetPath, documentBaseUrl || new URL(normalizeBasePath(baseUrl), origin)).href, - new URL(assetPath, new URL(normalizeBasePath(baseUrl), origin)).href, - `${origin}/${assetPath}`, - ...pathCandidates, - ])); -} - function looksLikeHtmlDocument(text: string): boolean { const trimmed = text.trim().toLowerCase(); return trimmed.startsWith(' (id ? stars[id] || 0 : 0), [stars, id]); + const communityCount = useMemo(() => (id ? stars[id] || 0 : 0), [stars, id]); const { frontmatter, body: markdownBody } = useMemo(() => splitFrontmatter(content), [content]); const frontmatterRows = useMemo(() => parseFrontmatterRows(frontmatter), [frontmatter]); @@ -300,7 +254,7 @@ export function SkillDetail(): React.ReactElement {

@{skill.name}

- +

{skill.description} diff --git a/apps/web-app/src/pages/__tests__/Home.test.tsx b/apps/web-app/src/pages/__tests__/Home.test.tsx index 05b198a4..246fdaca 100644 --- a/apps/web-app/src/pages/__tests__/Home.test.tsx +++ b/apps/web-app/src/pages/__tests__/Home.test.tsx @@ -178,31 +178,23 @@ describe('Home', () => { }); describe('User Settings and Sync', () => { - it('should sync local stars when sync button is clicked', async () => { + it('hides sync actions on the public catalog and explains why', async () => { const mockSkills = [createMockSkill({ id: 'skill-1' })]; - const refreshSkills = vi.fn().mockResolvedValue(undefined); (useSkills as Mock).mockReturnValue({ skills: mockSkills, stars: { 'skill-1': 5 }, loading: false, error: null, - refreshSkills, + refreshSkills: vi.fn().mockResolvedValue(undefined), }); renderWithRouter(, { useProvider: false }); - const syncButton = screen.getByRole('button', { name: /Sync/i }); - - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ success: true, count: 1 }) - }); - - fireEvent.click(syncButton); - await waitFor(() => { - expect(refreshSkills).toHaveBeenCalled(); + expect(screen.queryByRole('button', { name: /Sync Skills/i })).not.toBeInTheDocument(); + expect(screen.getByText(/Public catalog mode/i)).toBeInTheDocument(); + expect(screen.getByText(/maintainer-only workflow/i)).toBeInTheDocument(); }); }); }); diff --git a/apps/web-app/src/pages/__tests__/SkillDetail.security.test.tsx b/apps/web-app/src/pages/__tests__/SkillDetail.security.test.tsx index 9f75dfad..980e4016 100644 --- a/apps/web-app/src/pages/__tests__/SkillDetail.security.test.tsx +++ b/apps/web-app/src/pages/__tests__/SkillDetail.security.test.tsx @@ -8,7 +8,7 @@ import { useSkills } from '../../context/SkillContext'; let capturedRehypePlugins: unknown[] | undefined; vi.mock('../../components/SkillStarButton', () => ({ - SkillStarButton: () => , + SkillStarButton: () => , })); vi.mock('../../context/SkillContext', async (importOriginal) => { diff --git a/apps/web-app/src/pages/__tests__/SkillDetail.test.tsx b/apps/web-app/src/pages/__tests__/SkillDetail.test.tsx index 712499c2..6271f8d4 100644 --- a/apps/web-app/src/pages/__tests__/SkillDetail.test.tsx +++ b/apps/web-app/src/pages/__tests__/SkillDetail.test.tsx @@ -4,13 +4,12 @@ import { SkillDetail } from '../SkillDetail'; import { renderWithRouter } from '../../utils/testUtils'; import { createMockSkill } from '../../factories/skill'; import { useSkills } from '../../context/SkillContext'; -import { getSkillMarkdownCandidateUrls } from '../SkillDetail'; // Mock the SkillStarButton component vi.mock('../../components/SkillStarButton', () => ({ - SkillStarButton: ({ skillId, initialCount }: { skillId: string; initialCount?: number }) => ( - ), })); @@ -36,25 +35,6 @@ describe('SkillDetail', () => { window.history.pushState({}, '', '/'); }); - describe('Markdown URL resolution', () => { - it('builds stable markdown candidates for gh-pages routes', () => { - expect( - getSkillMarkdownCandidateUrls({ - baseUrl: '/antigravity-awesome-skills/', - origin: 'https://sickn33.github.io', - pathname: '/antigravity-awesome-skills/skill/react-patterns', - documentBaseUrl: 'https://sickn33.github.io/antigravity-awesome-skills/', - skillPath: 'skills/react-patterns', - }), - ).toEqual([ - 'https://sickn33.github.io/antigravity-awesome-skills/skills/react-patterns/SKILL.md', - 'https://sickn33.github.io/skills/react-patterns/SKILL.md', - 'https://sickn33.github.io/antigravity-awesome-skills/skill/skills/react-patterns/SKILL.md', - 'https://sickn33.github.io/antigravity-awesome-skills/skill/react-patterns/skills/react-patterns/SKILL.md', - ]); - }); - }); - describe('Loading state', () => { it('should show loading spinner when context is loading', async () => { (useSkills as Mock).mockReturnValue({ @@ -131,7 +111,7 @@ describe('SkillDetail', () => { '@react-patterns | Antigravity Awesome Skills', ); expect(global.fetch).toHaveBeenCalled(); - }); + }, { timeout: 3000 }); }); it('falls back to the next markdown candidate when the first response is html', async () => { @@ -345,7 +325,7 @@ describe('SkillDetail', () => { await waitFor(() => { const starBtn = screen.getByTestId('star-button'); expect(starBtn).toBeInTheDocument(); - expect(starBtn).toHaveAttribute('data-count', '10'); + expect(starBtn).toHaveAttribute('data-community-count', '10'); }); }); }); diff --git a/apps/web-app/src/types/index.ts b/apps/web-app/src/types/index.ts index 35e57f6b..a380ed5d 100644 --- a/apps/web-app/src/types/index.ts +++ b/apps/web-app/src/types/index.ts @@ -1,3 +1,5 @@ +export type RiskLevel = 'none' | 'safe' | 'critical' | 'offensive' | 'unknown'; + /** * Skill data type from skills.json */ @@ -6,7 +8,7 @@ export interface Skill { name: string; description: string; category: string; - risk?: 'low' | 'medium' | 'high' | 'critical' | 'unknown'; + risk?: RiskLevel; source?: string; date_added?: string; path: string; diff --git a/apps/web-app/src/utils/__tests__/publicAssetUrls.test.ts b/apps/web-app/src/utils/__tests__/publicAssetUrls.test.ts new file mode 100644 index 00000000..c857dd2b --- /dev/null +++ b/apps/web-app/src/utils/__tests__/publicAssetUrls.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { + getAbsolutePublicAssetUrl, + getSkillMarkdownCandidateUrls, + getSkillsIndexCandidateUrls, + normalizeBasePath, +} from '../publicAssetUrls'; + +describe('public asset URL helpers', () => { + it('normalizes dot-relative BASE_URL values', () => { + expect(normalizeBasePath('./')).toBe('/'); + expect(normalizeBasePath('/antigravity-awesome-skills/')).toBe('/antigravity-awesome-skills/'); + }); + + it('builds stable skills index candidates for gh-pages routes', () => { + expect( + getSkillsIndexCandidateUrls({ + baseUrl: '/antigravity-awesome-skills/', + origin: 'https://sickn33.github.io', + pathname: '/antigravity-awesome-skills/skill/some-id', + documentBaseUrl: 'https://sickn33.github.io/antigravity-awesome-skills/', + }), + ).toEqual([ + 'https://sickn33.github.io/antigravity-awesome-skills/skills.json', + 'https://sickn33.github.io/antigravity-awesome-skills/skills.json.backup', + 'https://sickn33.github.io/skills.json', + 'https://sickn33.github.io/skills.json.backup', + 'https://sickn33.github.io/antigravity-awesome-skills/skill/skills.json', + 'https://sickn33.github.io/antigravity-awesome-skills/skill/skills.json.backup', + 'https://sickn33.github.io/antigravity-awesome-skills/skill/some-id/skills.json', + 'https://sickn33.github.io/antigravity-awesome-skills/skill/some-id/skills.json.backup', + ]); + }); + + it('builds stable markdown candidates for gh-pages routes', () => { + expect( + getSkillMarkdownCandidateUrls({ + baseUrl: '/antigravity-awesome-skills/', + origin: 'https://sickn33.github.io', + pathname: '/antigravity-awesome-skills/skill/react-patterns', + documentBaseUrl: 'https://sickn33.github.io/antigravity-awesome-skills/', + skillPath: 'skills/react-patterns', + }), + ).toEqual([ + 'https://sickn33.github.io/antigravity-awesome-skills/skills/react-patterns/SKILL.md', + 'https://sickn33.github.io/skills/react-patterns/SKILL.md', + 'https://sickn33.github.io/antigravity-awesome-skills/skill/skills/react-patterns/SKILL.md', + 'https://sickn33.github.io/antigravity-awesome-skills/skill/react-patterns/skills/react-patterns/SKILL.md', + ]); + }); + + it('resolves absolute public asset URLs from the shared base path logic', () => { + expect( + getAbsolutePublicAssetUrl('/skill/react-patterns', { + baseUrl: '/antigravity-awesome-skills/', + origin: 'https://sickn33.github.io', + }), + ).toBe('https://sickn33.github.io/antigravity-awesome-skills/skill/react-patterns'); + }); +}); diff --git a/apps/web-app/src/utils/__tests__/seo.test.ts b/apps/web-app/src/utils/__tests__/seo.test.ts index 42f0f4d2..9872c31b 100644 --- a/apps/web-app/src/utils/__tests__/seo.test.ts +++ b/apps/web-app/src/utils/__tests__/seo.test.ts @@ -28,8 +28,8 @@ describe('SEO helpers', () => { it('builds homepage metadata with the canonical catalog message', () => { const meta = buildHomeMeta(10); - expect(meta.title).toContain('1,326+'); - expect(meta.description).toContain('1,326+ installable agentic skills'); + expect(meta.title).toContain('10 installable AI skills'); + expect(meta.description).toContain('10 installable agentic skills'); expect(meta.canonicalPath).toBe('/'); expect(meta.ogTitle).toBe(meta.title); expect(meta.ogImage).toBe(DEFAULT_SOCIAL_IMAGE); diff --git a/apps/web-app/src/utils/publicAssetUrls.ts b/apps/web-app/src/utils/publicAssetUrls.ts new file mode 100644 index 00000000..0771b0a3 --- /dev/null +++ b/apps/web-app/src/utils/publicAssetUrls.ts @@ -0,0 +1,119 @@ +export interface PublicAssetUrlInput { + baseUrl: string; + origin: string; + pathname: string; + documentBaseUrl?: string; +} + +export type SkillsIndexUrlInput = PublicAssetUrlInput; + +export interface SkillMarkdownUrlInput extends PublicAssetUrlInput { + skillPath: string; +} + +function stripLeadingSlashes(path: string): string { + return path.replace(/^\/+/, ''); +} + +function normalizePathname(pathname: string): string { + return pathname.startsWith('/') ? pathname : `/${pathname}`; +} + +function getResolvedDocumentBaseUrl({ + baseUrl, + origin, + documentBaseUrl, +}: Pick): URL { + if (documentBaseUrl) { + return new URL(documentBaseUrl); + } + + return new URL(normalizeBasePath(baseUrl), origin); +} + +function getPathCandidateUrls(pathname: string, assetPath: string, origin: string): string[] { + const pathSegments = normalizePathname(pathname).split('/').filter(Boolean); + + return pathSegments.map((_, index) => { + const prefix = `/${pathSegments.slice(0, index + 1).join('/')}/`; + return `${origin}${prefix}${assetPath}`; + }); +} + +function uniqueUrls(urls: string[]): string[] { + return Array.from(new Set(urls)); +} + +function appendBackupCandidates(urls: string[]): string[] { + const candidates = new Set(); + + urls.forEach((url) => { + candidates.add(url); + + if (url.endsWith('skills.json')) { + candidates.add(`${url}.backup`); + } + }); + + return Array.from(candidates); +} + +export function normalizeBasePath(baseUrl: string): string { + const normalizedSegments = baseUrl + .trim() + .split('/') + .filter((segment) => segment.length > 0 && segment !== '.'); + + const normalizedPath = normalizedSegments.length > 0 + ? `/${normalizedSegments.join('/')}` + : '/'; + + return normalizedPath.endsWith('/') ? normalizedPath : `${normalizedPath}/`; +} + +export function getAbsolutePublicAssetUrl( + assetPath: string, + { + baseUrl, + origin, + }: Pick, +): string { + const resolvedAssetPath = stripLeadingSlashes(assetPath.trim()); + return new URL(resolvedAssetPath || '.', new URL(normalizeBasePath(baseUrl), origin)).href; +} + +export function getSkillsIndexCandidateUrls({ + baseUrl, + origin, + pathname, + documentBaseUrl, +}: SkillsIndexUrlInput): string[] { + const assetPath = 'skills.json'; + + return appendBackupCandidates(uniqueUrls([ + new URL(assetPath, getResolvedDocumentBaseUrl({ baseUrl, origin, documentBaseUrl })).href, + new URL(assetPath, new URL(normalizeBasePath(baseUrl), origin)).href, + `${origin}/${assetPath}`, + ...getPathCandidateUrls(pathname, assetPath, origin), + ])); +} + +export function getSkillMarkdownCandidateUrls({ + baseUrl, + origin, + pathname, + documentBaseUrl, + skillPath, +}: SkillMarkdownUrlInput): string[] { + const normalizedSkillPath = skillPath + .replace(/^\/+/, '') + .replace(/\/SKILL\.md$/i, ''); + const assetPath = `${normalizedSkillPath}/SKILL.md`; + + return uniqueUrls([ + new URL(assetPath, getResolvedDocumentBaseUrl({ baseUrl, origin, documentBaseUrl })).href, + new URL(assetPath, new URL(normalizeBasePath(baseUrl), origin)).href, + `${origin}/${assetPath}`, + ...getPathCandidateUrls(pathname, assetPath, origin), + ]); +} diff --git a/apps/web-app/src/utils/seo.ts b/apps/web-app/src/utils/seo.ts index fd55d164..2d9bcfb6 100644 --- a/apps/web-app/src/utils/seo.ts +++ b/apps/web-app/src/utils/seo.ts @@ -1,6 +1,6 @@ import type { SeoJsonLdValue, SeoMeta, TwitterCard, Skill } from '../types'; +import { getAbsolutePublicAssetUrl } from './publicAssetUrls'; -export const APP_HOME_CATALOG_COUNT = 1326; export const DEFAULT_TOP_SKILL_COUNT = 40; export const DEFAULT_SOCIAL_IMAGE = 'social-card.svg'; const SITE_NAME = 'Antigravity Awesome Skills'; @@ -46,15 +46,17 @@ export function getCanonicalUrl(canonicalPath: string, siteBaseUrl?: string): st } export function getAssetCanonicalUrl(canonicalPath: string): string { - const baseUrl = import.meta.env.BASE_URL || '/'; - const normalizedBase = toCanonicalPath(baseUrl); - const appBase = normalizedBase === '/' ? '' : normalizedBase; - return `${window.location.origin}${appBase}${toCanonicalPath(canonicalPath)}`; + return getAbsolutePublicAssetUrl(toCanonicalPath(canonicalPath), { + baseUrl: import.meta.env.BASE_URL || '/', + origin: window.location.origin, + }); } export function getAbsoluteAssetUrl(assetPath: string): string { - const normalizedAsset = toCanonicalPath(assetPath); - return getAssetCanonicalUrl(normalizedAsset); + return getAbsolutePublicAssetUrl(toCanonicalPath(assetPath), { + baseUrl: import.meta.env.BASE_URL || '/', + origin: window.location.origin, + }); } function getCatalogBaseUrl(canonicalUrl: string): string { @@ -93,12 +95,15 @@ function buildWebSiteSchema(canonicalUrl: string): Record { } function buildSoftwareSourceCodeSchema(canonicalUrl: string, visibleCount: number): Record { - const visibleCountLabel = `${visibleCount.toLocaleString('en-US')}+`; + const visibleCountLabel = visibleCount > 0 + ? `${visibleCount.toLocaleString('en-US')} agentic skills` + : 'agentic skills'; + return { '@context': 'https://schema.org', '@type': 'SoftwareSourceCode', name: SITE_NAME, - description: `Installable GitHub library of ${visibleCountLabel} agentic skills for AI coding assistants.`, + description: `Installable GitHub library of ${visibleCountLabel} for AI coding assistants.`, url: canonicalUrl, codeRepository: 'https://github.com/sickn33/antigravity-awesome-skills', programmingLanguage: { @@ -275,10 +280,14 @@ export function isTopSkill(skillId: string, skills: ReadonlyArray, limit } export function buildHomeMeta(skillCount: number): SeoMeta { - const visibleCount = Math.max(skillCount, APP_HOME_CATALOG_COUNT); - const visibleCountLabel = `${visibleCount.toLocaleString('en-US')}+`; - const title = `Antigravity Awesome Skills | ${visibleCountLabel} installable AI skills catalog`; - const description = `Explore ${visibleCountLabel} installable agentic skills for Claude Code, Cursor, Codex CLI, Gemini CLI, and Antigravity. Browse bundles, workflows, FAQs, and integration guides in one place.`; + const visibleCount = Math.max(skillCount, 0); + const visibleCountLabel = visibleCount > 0 + ? `${visibleCount.toLocaleString('en-US')} installable AI skills` + : 'installable AI skills'; + const title = `Antigravity Awesome Skills | ${visibleCountLabel} catalog`; + const description = visibleCount > 0 + ? `Explore ${visibleCount.toLocaleString('en-US')} installable agentic skills for Claude Code, Cursor, Codex CLI, Gemini CLI, and Antigravity. Browse bundles, workflows, FAQs, and integration guides in one place.` + : 'Explore installable agentic skills for Claude Code, Cursor, Codex CLI, Gemini CLI, and Antigravity. Browse bundles, workflows, FAQs, and integration guides in one place.'; return { title, description, diff --git a/apps/web-app/vitest.config.ts b/apps/web-app/vitest.config.ts index 13f3c76f..2866fcb3 100644 --- a/apps/web-app/vitest.config.ts +++ b/apps/web-app/vitest.config.ts @@ -9,6 +9,28 @@ export default mergeConfig( environment: 'jsdom', setupFiles: './src/test/setup.ts', css: true, + coverage: { + provider: 'v8', + reporter: ['text', 'html'], + include: [ + 'src/components/**/*.{ts,tsx}', + 'src/context/**/*.{ts,tsx}', + 'src/hooks/**/*.{ts,tsx}', + 'src/pages/**/*.{ts,tsx}', + 'src/utils/**/*.{ts,tsx}', + ], + exclude: [ + 'src/**/*.test.{ts,tsx}', + 'src/**/__tests__/**', + 'src/test/**', + ], + thresholds: { + statements: 75, + lines: 75, + functions: 70, + branches: 60, + }, + }, }, }) ); diff --git a/docs/maintainers/audit.md b/docs/maintainers/audit.md index b1ea9965..59f0b617 100644 --- a/docs/maintainers/audit.md +++ b/docs/maintainers/audit.md @@ -37,6 +37,7 @@ This document summarizes the repository coherence audit performed after the `app - missing examples and missing limitations sections, - overly long `SKILL.md` files that should probably be split into `references/`, - plus the existing structural/safety checks (frontmatter, risk, `When to Use`, offensive disclaimer, dangling links). +- The report also includes a non-blocking `suggested_risk` for skills that are still marked `unknown` or appear to be misclassified, so maintainers can resolve risk classification during PR review without changing the contributor gate. - Use `npm run audit:skills` for the maintainer view and `npm run audit:skills -- --json-out ... --markdown-out ...` when you want artifacts for triage or cleanup tracking. ### 3. Riferimenti incrociati diff --git a/docs/maintainers/release-process.md b/docs/maintainers/release-process.md index e7ad9913..a5e10362 100644 --- a/docs/maintainers/release-process.md +++ b/docs/maintainers/release-process.md @@ -75,6 +75,16 @@ npm publish Normally this still happens via the existing GitHub release workflow after the GitHub release is published. That workflow now reruns `sync:release-state`, refreshes tracked web assets, fails on canonical drift via `git diff --exit-code`, executes tests and docs security checks, builds the web app, and dry-runs the npm package before `npm publish`. +## Canonical Sync Bot + +`main` still uses the repository's auto-sync model for canonical generated artifacts, but with a narrow contract: + +- PRs stay source-only. +- After merge, the `main` workflow may commit generated canonical files directly to `main` with `[ci skip]`. +- The bot commit is only allowed to stage files resolved from `tools/scripts/generated_files.js --include-mixed`. +- If repo-state sync leaves any unmanaged tracked or untracked drift, the workflow fails instead of pushing a partial fix. +- The scheduled hygiene workflow follows the same contract and shares the same concurrency group so only one canonical sync writer runs at a time. + ## Rollback Notes - If the release tag is wrong, delete the tag locally and remotely before republishing. diff --git a/package.json b/package.json index 59835b5c..0c4ead83 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ "update:skills": "node tools/scripts/run-python.js tools/scripts/generate_index.py && node tools/scripts/copy-file.js skills_index.json apps/web-app/public/skills.json && node tools/scripts/copy-file.js skills_index.json apps/web-app/public/skills.json.backup", "app:setup": "node tools/scripts/setup_web.js", "app:install": "cd apps/web-app && npm ci", + "app:test": "cd apps/web-app && npm run test -- --run", + "app:test:coverage": "cd apps/web-app && npm run test:coverage -- --run", "app:dev": "npm run app:setup && cd apps/web-app && npm run dev", "app:build": "npm run app:setup && cd apps/web-app && npm run build", "app:preview": "cd apps/web-app && npm run preview" diff --git a/skills/docx-official/ooxml/scripts/unpack.py b/skills/docx-official/ooxml/scripts/unpack.py index 96cb94ba..47e55ce4 100755 --- a/skills/docx-official/ooxml/scripts/unpack.py +++ b/skills/docx-official/ooxml/scripts/unpack.py @@ -2,22 +2,47 @@ """Unpack and format XML contents of Office files (.docx, .pptx, .xlsx)""" import random +import shutil +import stat import sys import zipfile from pathlib import Path +def _is_zip_symlink(member: zipfile.ZipInfo) -> bool: + return stat.S_ISLNK(member.external_attr >> 16) + + +def _is_safe_destination(output_root: Path, member_name: str) -> bool: + destination = output_root / member_name + return destination.resolve().is_relative_to(output_root.resolve()) + + +def _extract_member(archive: zipfile.ZipFile, member: zipfile.ZipInfo, output_root: Path): + destination = output_root / member.filename + if member.is_dir(): + destination.mkdir(parents=True, exist_ok=True) + return + + destination.parent.mkdir(parents=True, exist_ok=True) + with archive.open(member, "r") as source, open(destination, "wb") as target: + shutil.copyfileobj(source, target) + + def extract_archive_safely(input_file: str | Path, output_dir: str | Path): output_path = Path(output_dir) output_path.mkdir(parents=True, exist_ok=True) + output_root = output_path.resolve() with zipfile.ZipFile(input_file) as archive: for member in archive.infolist(): - destination = output_path / member.filename - if not destination.resolve().is_relative_to(output_path.resolve()): + if _is_zip_symlink(member): + raise ValueError(f"Unsafe archive entry: {member.filename}") + if not _is_safe_destination(output_root, member.filename): raise ValueError(f"Unsafe archive entry: {member.filename}") - archive.extractall(output_path) + for member in archive.infolist(): + _extract_member(archive, member, output_path) def pretty_print_xml(output_path: Path): diff --git a/skills/pptx-official/ooxml/scripts/unpack.py b/skills/pptx-official/ooxml/scripts/unpack.py index 96cb94ba..47e55ce4 100755 --- a/skills/pptx-official/ooxml/scripts/unpack.py +++ b/skills/pptx-official/ooxml/scripts/unpack.py @@ -2,22 +2,47 @@ """Unpack and format XML contents of Office files (.docx, .pptx, .xlsx)""" import random +import shutil +import stat import sys import zipfile from pathlib import Path +def _is_zip_symlink(member: zipfile.ZipInfo) -> bool: + return stat.S_ISLNK(member.external_attr >> 16) + + +def _is_safe_destination(output_root: Path, member_name: str) -> bool: + destination = output_root / member_name + return destination.resolve().is_relative_to(output_root.resolve()) + + +def _extract_member(archive: zipfile.ZipFile, member: zipfile.ZipInfo, output_root: Path): + destination = output_root / member.filename + if member.is_dir(): + destination.mkdir(parents=True, exist_ok=True) + return + + destination.parent.mkdir(parents=True, exist_ok=True) + with archive.open(member, "r") as source, open(destination, "wb") as target: + shutil.copyfileobj(source, target) + + def extract_archive_safely(input_file: str | Path, output_dir: str | Path): output_path = Path(output_dir) output_path.mkdir(parents=True, exist_ok=True) + output_root = output_path.resolve() with zipfile.ZipFile(input_file) as archive: for member in archive.infolist(): - destination = output_path / member.filename - if not destination.resolve().is_relative_to(output_path.resolve()): + if _is_zip_symlink(member): + raise ValueError(f"Unsafe archive entry: {member.filename}") + if not _is_safe_destination(output_root, member.filename): raise ValueError(f"Unsafe archive entry: {member.filename}") - archive.extractall(output_path) + for member in archive.infolist(): + _extract_member(archive, member, output_path) def pretty_print_xml(output_path: Path): diff --git a/tools/bin/install.js b/tools/bin/install.js index 68bf7773..721d7107 100755 --- a/tools/bin/install.js +++ b/tools/bin/install.js @@ -121,7 +121,7 @@ antigravity-awesome-skills — installer npx antigravity-awesome-skills [install] [options] - Clones the skills repo into your agent's skills directory. + Shallow-clones the skills repo into your agent's skills directory. Options: --cursor Install to ~/.cursor/skills (Cursor) @@ -131,8 +131,8 @@ Options: --kiro Install to ~/.kiro/skills (Kiro CLI) --antigravity Install to ~/.gemini/antigravity/skills (Antigravity) --path

Install to (default: ~/.gemini/antigravity/skills) - --version After clone, checkout tag v (e.g. 4.6.0 -> v4.6.0) - --tag After clone, checkout this tag (e.g. v4.6.0) + --version Clone tag v (e.g. 4.6.0 -> v4.6.0) + --tag Clone this tag or branch (e.g. v4.6.0) Examples: npx antigravity-awesome-skills @@ -286,6 +286,15 @@ function run(cmd, args, opts = {}) { if (r.status !== 0) process.exit(r.status == null ? 1 : r.status); } +function buildCloneArgs(repo, tempDir, ref = null) { + const args = ["clone", "--depth", "1"]; + if (ref) { + args.push("--branch", ref); + } + args.push(repo, tempDir); + return args; +} + function installForTarget(tempDir, target) { if (fs.existsSync(target.path)) { ensureTargetIsDirectory(target.path); @@ -353,6 +362,13 @@ function getPostInstallMessages(targets) { function main() { const opts = parseArgs(); const { tagArg, versionArg } = opts; + const ref = + tagArg || + (versionArg + ? versionArg.startsWith("v") + ? versionArg + : `v${versionArg}` + : null); if (opts.help) { printHelp(); @@ -372,21 +388,10 @@ function main() { try { console.log("Cloning repository…"); - run("git", ["clone", REPO, tempDir]); - - const ref = - tagArg || - (versionArg - ? versionArg.startsWith("v") - ? versionArg - : `v${versionArg}` - : null); if (ref) { - console.log(`Checking out ${ref}…`); - process.chdir(tempDir); - run("git", ["checkout", ref]); - process.chdir(originalCwd); + console.log(`Cloning repository at ${ref}…`); } + run("git", buildCloneArgs(REPO, tempDir, ref)); console.log(`\nInstalling for ${targets.length} target(s):`); for (const target of targets) { @@ -419,6 +424,7 @@ if (require.main === module) { module.exports = { copyRecursiveSync, getPostInstallMessages, + buildCloneArgs, getInstallEntries, installSkillsIntoTarget, installForTarget, diff --git a/tools/scripts/audit_skills.py b/tools/scripts/audit_skills.py index 3f2ad423..23b8033d 100644 --- a/tools/scripts/audit_skills.py +++ b/tools/scripts/audit_skills.py @@ -12,6 +12,7 @@ from datetime import datetime, timezone from pathlib import Path from _project_paths import find_repo_root +from risk_classifier import suggest_risk from validate_skills import configure_utf8_output, has_when_to_use_section, parse_frontmatter @@ -34,6 +35,7 @@ SECURITY_DISCLAIMER_PATTERN = re.compile(r"AUTHORIZED USE ONLY", re.IGNORECASE) VALID_RISK_LEVELS = {"none", "safe", "critical", "offensive", "unknown"} DEFAULT_MARKDOWN_TOP_FINDINGS = 15 DEFAULT_MARKDOWN_TOP_SKILLS = 20 +DEFAULT_MARKDOWN_TOP_RISK_SUGGESTIONS = 20 @dataclass(frozen=True) @@ -48,8 +50,6 @@ class Finding: "code": self.code, "message": self.message, } - - def has_examples(content: str) -> bool: return bool(FENCED_CODE_BLOCK_PATTERN.search(content)) or any( pattern.search(content) for pattern in EXAMPLES_HEADING_PATTERNS @@ -110,6 +110,7 @@ def build_skill_report(skill_root: Path, skills_dir: Path) -> dict[str, object]: risk = metadata.get("risk") source = metadata.get("source") date_added = metadata.get("date_added") + risk_suggestion = suggest_risk(content, metadata) if name != skill_root.name: findings.append( @@ -162,6 +163,17 @@ def build_skill_report(skill_root: Path, skills_dir: Path) -> dict[str, object]: ) ) + if risk_suggestion.risk not in ("unknown", "none"): + risk_needs_review = risk is None or risk == "unknown" or risk != risk_suggestion.risk + if risk_needs_review: + findings.append( + Finding( + "info" if risk in (None, "unknown") else "warning", + "risk_suggestion", + f"Suggested risk is {risk_suggestion.risk} based on: {', '.join(risk_suggestion.reasons[:3])}.", + ) + ) + if source is None: findings.append(Finding("warning", "missing_source", "Missing source attribution.")) @@ -211,10 +223,25 @@ def build_skill_report(skill_root: Path, skills_dir: Path) -> dict[str, object]: ) ) - return finalize_skill_report(rel_dir, rel_file, findings) + return finalize_skill_report( + rel_dir, + rel_file, + findings, + risk=risk, + suggested_risk=risk_suggestion.risk, + suggested_risk_reasons=list(risk_suggestion.reasons), + ) -def finalize_skill_report(skill_id: str, rel_file: str, findings: list[Finding]) -> dict[str, object]: +def finalize_skill_report( + skill_id: str, + rel_file: str, + findings: list[Finding], + *, + risk: str | None = None, + suggested_risk: str = "unknown", + suggested_risk_reasons: list[str] | None = None, +) -> dict[str, object]: severity_counts = Counter(finding.severity for finding in findings) if severity_counts["error"] > 0: status = "error" @@ -230,6 +257,9 @@ def finalize_skill_report(skill_id: str, rel_file: str, findings: list[Finding]) "error_count": severity_counts["error"], "warning_count": severity_counts["warning"], "info_count": severity_counts["info"], + "risk": risk, + "suggested_risk": suggested_risk, + "suggested_risk_reasons": suggested_risk_reasons or [], "findings": [finding.to_dict() for finding in findings], } @@ -250,19 +280,30 @@ def audit_skills(skills_dir: str | Path) -> dict[str, object]: code_counts = Counter() severity_counts = Counter() + risk_suggestion_counts = Counter() for report in reports: for finding in report["findings"]: code_counts[finding["code"]] += 1 severity_counts[finding["severity"]] += 1 + if report["suggested_risk"] not in (None, "unknown", "none"): + risk_suggestion_counts[report["suggested_risk"]] += 1 summary = { "skills_scanned": len(reports), "skills_ok": sum(report["status"] == "ok" for report in reports), "skills_with_errors": sum(report["status"] == "error" for report in reports), "skills_with_warnings_only": sum(report["status"] == "warning" for report in reports), + "skills_with_suggested_risk": sum( + report["suggested_risk"] not in ("unknown", "none") + for report in reports + ), "errors": severity_counts["error"], "warnings": severity_counts["warning"], "infos": severity_counts["info"], + "risk_suggestions": [ + {"risk": risk, "count": count} + for risk, count in risk_suggestion_counts.most_common() + ], "top_finding_codes": [ {"code": code, "count": count} for code, count in code_counts.most_common() @@ -284,6 +325,15 @@ def write_markdown_report(report: dict[str, object], destination: str | Path) -> top_skills = [ skill for skill in skills if skill["status"] != "ok" ][:DEFAULT_MARKDOWN_TOP_SKILLS] + risk_suggestions = [ + skill + for skill in skills + if skill.get("suggested_risk") not in (None, "unknown", "none") + and ( + skill.get("risk") in (None, "unknown") + or skill.get("risk") != skill.get("suggested_risk") + ) + ][:DEFAULT_MARKDOWN_TOP_RISK_SUGGESTIONS] lines = [ "# Skills Audit Report", @@ -296,15 +346,28 @@ def write_markdown_report(report: dict[str, object], destination: str | Path) -> f"- Skills ready: **{summary['skills_ok']}**", f"- Skills with errors: **{summary['skills_with_errors']}**", f"- Skills with warnings only: **{summary['skills_with_warnings_only']}**", + f"- Skills with suggested risk: **{summary['skills_with_suggested_risk']}**", f"- Total errors: **{summary['errors']}**", f"- Total warnings: **{summary['warnings']}**", - "", - "## Top Finding Codes", - "", - "| Code | Count |", - "| --- | ---: |", + f"- Total info findings: **{summary['infos']}**", ] + if summary.get("risk_suggestions"): + summary_text = ", ".join( + f"{item['risk']}: {item['count']}" for item in summary["risk_suggestions"] + ) + lines.append(f"- Suggested risks: **{summary_text}**") + + lines.extend( + [ + "", + "## Top Finding Codes", + "", + "| Code | Count |", + "| --- | ---: |", + ] + ) + if top_findings: lines.extend(f"| `{item['code']}` | {item['count']} |" for item in top_findings) else: @@ -328,6 +391,24 @@ def write_markdown_report(report: dict[str, object], destination: str | Path) -> else: lines.append("| _none_ | ok | 0 | 0 |") + lines.extend( + [ + "", + "## Risk Suggestions", + "", + "| Skill | Current | Suggested | Why |", + "| --- | --- | --- | --- |", + ] + ) + + if risk_suggestions: + lines.extend( + f"| `{skill['id']}` | {skill.get('risk') or 'unknown'} | {skill.get('suggested_risk') or 'unknown'} | {', '.join(skill.get('suggested_risk_reasons', [])[:3]) or '_n/a_'} |" + for skill in risk_suggestions + ) + else: + lines.append("| _none_ | _none_ | _none_ | _n/a_ |") + Path(destination).write_text("\n".join(lines) + "\n", encoding="utf-8") @@ -338,8 +419,16 @@ def print_summary(report: dict[str, object]) -> None: print(f" Ready: {summary['skills_ok']}") print(f" Warning only: {summary['skills_with_warnings_only']}") print(f" With errors: {summary['skills_with_errors']}") + print(f" With suggested risk: {summary['skills_with_suggested_risk']}") print(f" Total warnings: {summary['warnings']}") print(f" Total errors: {summary['errors']}") + print(f" Total info findings: {summary['infos']}") + if summary.get("risk_suggestions"): + risk_summary = ", ".join( + f"{item['risk']}: {item['count']}" + for item in summary["risk_suggestions"] + ) + print(f" Suggested risks: {risk_summary}") top_findings = summary["top_finding_codes"][:10] if top_findings: diff --git a/tools/scripts/generate_index.py b/tools/scripts/generate_index.py index 4cd9e793..eea0e5e2 100644 --- a/tools/scripts/generate_index.py +++ b/tools/scripts/generate_index.py @@ -815,7 +815,7 @@ def parse_frontmatter(content): Parses YAML frontmatter, sanitizing unquoted values containing @. Handles single values and comma-separated lists by quoting the entire line. """ - fm_match = re.search(r'^---\s*\n(.*?)\n---', content, re.DOTALL) + fm_match = re.search(r'^---\s*\n(.*?)\n?---(?:\s*\n|$)', content, re.DOTALL) if not fm_match: return {} diff --git a/tools/scripts/generate_skills_report.py b/tools/scripts/generate_skills_report.py index 9166c9de..82463135 100644 --- a/tools/scripts/generate_skills_report.py +++ b/tools/scripts/generate_skills_report.py @@ -15,6 +15,7 @@ from datetime import datetime from pathlib import Path import yaml from _project_paths import find_repo_root +from risk_classifier import suggest_risk def get_project_root(): """Get the project root directory.""" @@ -32,9 +33,10 @@ def parse_frontmatter(content): except yaml.YAMLError: return None -def generate_skills_report(output_file=None, sort_by='date'): +def generate_skills_report(output_file=None, sort_by='date', project_root=None): """Generate a report of all skills with their metadata.""" - skills_dir = os.path.join(get_project_root(), 'skills') + root = str(project_root or get_project_root()) + skills_dir = os.path.join(root, 'skills') skills_data = [] for root, dirs, files in os.walk(skills_dir): @@ -52,14 +54,22 @@ def generate_skills_report(output_file=None, sort_by='date'): metadata = parse_frontmatter(content) if metadata is None: continue + + suggested_risk = suggest_risk(content, metadata) + date_added = metadata.get('date_added', None) + if date_added is not None: + date_added = date_added.isoformat() if hasattr(date_added, 'isoformat') else str(date_added) + skill_info = { 'id': metadata.get('id', skill_name), 'name': metadata.get('name', skill_name), 'description': metadata.get('description', ''), - 'date_added': metadata.get('date_added', None), + 'date_added': date_added, 'source': metadata.get('source', 'unknown'), 'risk': metadata.get('risk', 'unknown'), + 'suggested_risk': suggested_risk.risk, + 'suggested_risk_reasons': list(suggested_risk.reasons), 'category': metadata.get('category', metadata.get('id', '').split('-')[0] if '-' in metadata.get('id', '') else 'other'), } @@ -80,6 +90,11 @@ def generate_skills_report(output_file=None, sort_by='date'): 'total_skills': len(skills_data), 'skills_with_dates': sum(1 for s in skills_data if s['date_added']), 'skills_without_dates': sum(1 for s in skills_data if not s['date_added']), + 'skills_with_suggested_risk': sum(1 for s in skills_data if s['suggested_risk'] != 'unknown'), + 'suggested_risk_counts': { + risk: sum(1 for skill in skills_data if skill['suggested_risk'] == risk) + for risk in sorted({skill['suggested_risk'] for skill in skills_data if skill['suggested_risk'] != 'unknown'}) + }, 'coverage_percentage': round( sum(1 for s in skills_data if s['date_added']) / len(skills_data) * 100 if skills_data else 0, 1 diff --git a/tools/scripts/risk_classifier.py b/tools/scripts/risk_classifier.py new file mode 100644 index 00000000..ca8896db --- /dev/null +++ b/tools/scripts/risk_classifier.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import re +from dataclasses import dataclass +from collections.abc import Mapping + + +@dataclass(frozen=True) +class RiskSuggestion: + risk: str + reasons: tuple[str, ...] + + +OFFENSIVE_HINTS = [ + (re.compile(r"AUTHORIZED USE ONLY", re.IGNORECASE), "explicit offensive disclaimer"), + ( + re.compile( + r"\b(?:pentest(?:ing)?|penetration testing|red team(?:ing)?|exploit(?:ation)?|malware|phishing|sql injection|xss|csrf|jailbreak|sandbox escape|credential theft|exfiltrat\w*|prompt injection)\b", + re.IGNORECASE, + ), + "offensive security language", + ), +] + +CRITICAL_HINTS = [ + (re.compile(r"\bcurl\b[^\n]*\|\s*(?:bash|sh)\b", re.IGNORECASE), "curl pipes into a shell"), + (re.compile(r"\bwget\b[^\n]*\|\s*(?:bash|sh)\b", re.IGNORECASE), "wget pipes into a shell"), + (re.compile(r"\birm\b[^\n]*\|\s*iex\b", re.IGNORECASE), "PowerShell invoke-expression"), + (re.compile(r"\brm\s+-rf\b", re.IGNORECASE), "destructive filesystem delete"), + (re.compile(r"\bgit\s+(?:commit|push|merge|reset)\b", re.IGNORECASE), "git mutation"), + (re.compile(r"\b(?:npm|pnpm|yarn|bun)\s+publish\b", re.IGNORECASE), "package publication"), + (re.compile(r"\b(?:kubectl\s+apply|terraform\s+apply|ansible-playbook|docker\s+push)\b", re.IGNORECASE), "deployment or infrastructure mutation"), + ( + re.compile(r"\b(?:POST|PUT|PATCH|DELETE)\b", re.IGNORECASE), + "mutating HTTP verb", + ), + ( + re.compile(r"\b(?:insert|update|upsert|delete|drop|truncate|alter)\b", re.IGNORECASE), + "state-changing data operation", + ), + ( + re.compile(r"\b(?:api key|api[_ -]?key|token|secret|password|bearer token|oauth token)\b", re.IGNORECASE), + "secret or token handling", + ), + ( + re.compile( + r"\b(?:write|overwrite|append|create|modify|remove|rename|move)\b[^\n]{0,60}\b(?:file|files|directory|repo|repository|config|skill|document|artifact|database|table|record|row|branch|release|production|server|endpoint|resource)\b", + re.IGNORECASE, + ), + "state-changing instruction", + ), +] + +SAFE_HINTS = [ + ( + re.compile( + r"\b(?:echo|cat|ls|rg|grep|find|sed\s+-n|git\s+status|git\s+diff|pytest|npm\s+test|ruff|eslint|tsc)\b", + re.IGNORECASE, + ), + "non-mutating command example", + ), + (re.compile(r"^```", re.MULTILINE), "contains fenced examples"), + ( + re.compile(r"\b(?:read|inspect|analyze|audit|validate|test|search|summarize|monitor|review|list|fetch|get|query|lint)\b", re.IGNORECASE), + "read-only or diagnostic language", + ), + ( + re.compile(r"\b(?:api|http|graphql|webhook|endpoint|cli|sdk|docs?|database|log|logs)\b", re.IGNORECASE), + "technical or integration language", + ), +] + + +def _collect_reasons(text: str, patterns: list[tuple[re.Pattern[str], str]]) -> list[str]: + return [reason for pattern, reason in patterns if pattern.search(text)] + + +def suggest_risk(content: str, metadata: Mapping[str, object] | None = None) -> RiskSuggestion: + text = content if isinstance(content, str) else str(content or "") + if metadata: + if isinstance(metadata.get("description"), str): + text = f"{metadata['description']}\n{text}" + if isinstance(metadata.get("name"), str): + text = f"{metadata['name']}\n{text}" + + offensive_reasons = _collect_reasons(text, OFFENSIVE_HINTS) + if offensive_reasons: + return RiskSuggestion("offensive", tuple(offensive_reasons)) + + critical_reasons = _collect_reasons(text, CRITICAL_HINTS) + if critical_reasons: + return RiskSuggestion("critical", tuple(critical_reasons)) + + safe_reasons = _collect_reasons(text, SAFE_HINTS) + if safe_reasons: + return RiskSuggestion("safe", tuple(safe_reasons)) + + return RiskSuggestion("none", ()) diff --git a/tools/scripts/sync_microsoft_skills.py b/tools/scripts/sync_microsoft_skills.py index 4185eb30..26afb4c1 100644 --- a/tools/scripts/sync_microsoft_skills.py +++ b/tools/scripts/sync_microsoft_skills.py @@ -207,6 +207,9 @@ def find_plugin_skills(source_dir: Path, already_synced_names: set): skill_dir = skill_file.parent skill_name = skill_dir.name + if not is_safe_regular_file(skill_file, source_dir): + continue + if skill_name not in already_synced_names: results.append({ "relative_path": Path("plugins") / skill_name, diff --git a/tools/scripts/tests/automation_workflows.test.js b/tools/scripts/tests/automation_workflows.test.js index 62abd3e2..12a7688c 100644 --- a/tools/scripts/tests/automation_workflows.test.js +++ b/tools/scripts/tests/automation_workflows.test.js @@ -98,6 +98,31 @@ assert.match( /GH_TOKEN: \$\{\{ github\.token \}\}/, "main CI should provide GH_TOKEN for contributor synchronization", ); +assert.match( + ciWorkflow, + /main-validation-and-sync:[\s\S]*?concurrency:[\s\S]*?group: canonical-main-sync[\s\S]*?cancel-in-progress: false/, + "main validation should serialize canonical sync writers", +); +assert.match( + ciWorkflow, + /pip install -r tools\/requirements\.txt/g, + "CI workflows should install Python dependencies from tools/requirements.txt", +); +assert.match( + ciWorkflow, + /- name: Audit npm dependencies[\s\S]*?run: npm audit --audit-level=high/, + "CI should run npm audit at high severity", +); +assert.match( + ciWorkflow, + /main-validation-and-sync:[\s\S]*?- name: Audit npm dependencies[\s\S]*?run: npm audit --audit-level=high/, + "main validation should enforce npm audit before syncing canonical state", +); +assert.doesNotMatch( + ciWorkflow, + /main-validation-and-sync:[\s\S]*?continue-on-error: true/, + "main validation should not treat high-severity npm audit findings as non-blocking", +); assert.doesNotMatch( ciWorkflow, /^ - name: Generate index$/m, @@ -113,16 +138,46 @@ assert.doesNotMatch( /^ - name: Build catalog$/m, "main CI should not keep the old standalone Build catalog step", ); +assert.match( + ciWorkflow, + /git commit -m "chore: sync repo state \[ci skip\]"/, + "main CI should keep bot-generated canonical sync commits out of the normal CI loop", +); +assert.match( + ciWorkflow, + /git ls-files --others --exclude-standard/, + "main CI should fail if canonical sync leaves unmanaged untracked drift", +); +assert.match( + ciWorkflow, + /git diff --name-only/, + "main CI should fail if canonical sync leaves unmanaged tracked drift", +); assert.ok(fs.existsSync(hygieneWorkflowPath), "repo hygiene workflow should exist"); const hygieneWorkflow = readText(".github/workflows/repo-hygiene.yml"); assert.match(hygieneWorkflow, /^on:\n workflow_dispatch:\n schedule:/m, "repo hygiene workflow should support schedule and manual runs"); +assert.match( + hygieneWorkflow, + /concurrency:\n\s+group: canonical-main-sync\n\s+cancel-in-progress: false/, + "repo hygiene workflow should serialize canonical sync writers with main CI", +); assert.match( hygieneWorkflow, /GH_TOKEN: \$\{\{ github\.token \}\}/, "repo hygiene workflow should provide GH_TOKEN for gh-based contributor sync", ); +assert.match( + hygieneWorkflow, + /pip install -r tools\/requirements\.txt/, + "repo hygiene workflow should install Python dependencies from tools/requirements.txt", +); +assert.match( + hygieneWorkflow, + /run: npm audit --audit-level=high/, + "repo hygiene workflow should block on high-severity npm audit findings before syncing", +); assert.match( hygieneWorkflow, /run: npm run sync:repo-state/, @@ -133,8 +188,33 @@ assert.match( /generated_files\.js --include-mixed/, "repo hygiene workflow should resolve and stage the mixed generated files contract", ); +assert.match( + hygieneWorkflow, + /git commit -m "chore: scheduled repo hygiene sync \[ci skip\]"/, + "repo hygiene workflow should keep bot-generated sync commits out of the normal CI loop", +); +assert.match( + hygieneWorkflow, + /git ls-files --others --exclude-standard/, + "repo hygiene workflow should fail if canonical sync leaves unmanaged untracked drift", +); +assert.match( + hygieneWorkflow, + /git diff --name-only/, + "repo hygiene workflow should fail if canonical sync leaves unmanaged tracked drift", +); assert.match(publishWorkflow, /run: npm ci/, "npm publish workflow should install dependencies"); +assert.match( + publishWorkflow, + /pip install -r tools\/requirements\.txt/, + "npm publish workflow should install Python dependencies from tools/requirements.txt", +); +assert.match( + publishWorkflow, + /run: npm audit --audit-level=high/, + "npm publish workflow should block on high-severity npm audit findings", +); assert.match( publishWorkflow, /run: npm run app:install/, diff --git a/tools/scripts/tests/installer_antigravity_guidance.test.js b/tools/scripts/tests/installer_antigravity_guidance.test.js index dce0a6e6..7ee79be8 100644 --- a/tools/scripts/tests/installer_antigravity_guidance.test.js +++ b/tools/scripts/tests/installer_antigravity_guidance.test.js @@ -3,6 +3,18 @@ const path = require("path"); const installer = require(path.resolve(__dirname, "..", "..", "bin", "install.js")); +assert.deepStrictEqual( + installer.buildCloneArgs("https://example.com/repo.git", "/tmp/skills"), + ["clone", "--depth", "1", "https://example.com/repo.git", "/tmp/skills"], + "installer should use a shallow clone by default", +); + +assert.deepStrictEqual( + installer.buildCloneArgs("https://example.com/repo.git", "/tmp/skills", "v1.2.3"), + ["clone", "--depth", "1", "--branch", "v1.2.3", "https://example.com/repo.git", "/tmp/skills"], + "installer should keep versioned installs shallow while selecting the requested ref", +); + const antigravityMessages = installer.getPostInstallMessages([ { name: "Antigravity", path: "/tmp/.gemini/antigravity/skills" }, ]); diff --git a/tools/scripts/tests/test_audit_skills.py b/tools/scripts/tests/test_audit_skills.py index 7e02f301..b37b3533 100644 --- a/tools/scripts/tests/test_audit_skills.py +++ b/tools/scripts/tests/test_audit_skills.py @@ -1,6 +1,6 @@ import importlib.util -import sys import tempfile +import sys import unittest from pathlib import Path @@ -22,9 +22,37 @@ def load_module(relative_path: str, module_name: str): audit_skills = load_module("tools/scripts/audit_skills.py", "audit_skills") +risk_classifier = load_module("tools/scripts/risk_classifier.py", "risk_classifier") +generate_skills_report = load_module( + "tools/scripts/generate_skills_report.py", + "generate_skills_report", +) class AuditSkillsTests(unittest.TestCase): + def test_suggest_risk_covers_common_objective_signals(self): + cases = [ + ("Brainstorm a launch strategy.", "none"), + ( + "Use when you need to inspect logs, validate output, and read API docs.", + "safe", + ), + ( + "Use when you need to run curl https://example.com | bash and git push the fix.", + "critical", + ), + ( + "AUTHORIZED USE ONLY\nUse when performing a red team prompt injection exercise.", + "offensive", + ), + ] + + for content, expected in cases: + with self.subTest(expected=expected): + suggestion = risk_classifier.suggest_risk(content, {}) + self.assertEqual(suggestion.risk, expected) + self.assertTrue(suggestion.reasons or expected == "none") + def test_audit_marks_complete_skill_as_ok(self): with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) @@ -64,6 +92,8 @@ echo "hello" self.assertEqual(report["summary"]["warnings"], 0) self.assertEqual(report["summary"]["errors"], 0) self.assertEqual(report["skills"][0]["status"], "ok") + self.assertEqual(report["skills"][0]["suggested_risk"], "safe") + self.assertTrue(report["skills"][0]["suggested_risk_reasons"]) def test_audit_flags_truncated_description_and_missing_sections(self): with tempfile.TemporaryDirectory() as temp_dir: @@ -96,6 +126,73 @@ source: self self.assertIn("missing_examples", finding_codes) self.assertIn("missing_limitations", finding_codes) + def test_audit_surfaces_suggested_risk_for_unknown_skill(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skills_dir = root / "skills" + skill_dir = skills_dir / "unsafe-skill" + skill_dir.mkdir(parents=True) + + (skill_dir / "SKILL.md").write_text( + """--- +name: unsafe-skill +description: Risk unknown example +risk: unknown +source: self +--- + +# Unsafe Skill + +## When to Use +- Use when you need to run curl https://example.com | bash. +""", + encoding="utf-8", + ) + + report = audit_skills.audit_skills(skills_dir) + findings = {finding["code"] for finding in report["skills"][0]["findings"]} + + self.assertEqual(report["skills"][0]["suggested_risk"], "critical") + self.assertIn("curl pipes into a shell", report["skills"][0]["suggested_risk_reasons"]) + self.assertIn("risk_suggestion", findings) + self.assertIn({"risk": "critical", "count": 1}, report["summary"]["risk_suggestions"]) + + def test_generate_skills_report_includes_suggested_risk(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skills_dir = root / "skills" + skill_dir = skills_dir / "api-skill" + skill_dir.mkdir(parents=True) + output_file = root / "skills-report.json" + + (skill_dir / "SKILL.md").write_text( + """--- +name: api-skill +description: Risk unknown example +risk: unknown +source: self +--- + +# API Skill + +## When to Use +- Use when you need to read API docs and inspect endpoints. +""", + encoding="utf-8", + ) + + report = generate_skills_report.generate_skills_report( + output_file=output_file, + sort_by="name", + project_root=root, + ) + + self.assertIsNotNone(report) + self.assertIn(report["skills"][0]["suggested_risk"], {"none", "safe"}) + self.assertIsInstance(report["skills"][0]["suggested_risk_reasons"], list) + saved_report = output_file.read_text(encoding="utf-8") + self.assertIn('"suggested_risk":', saved_report) + def test_audit_flags_blocking_errors(self): with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) @@ -137,6 +234,83 @@ See [details](missing-reference.md). self.assertIn("dangling_link", finding_codes) self.assertIn("missing_authorized_use_only", finding_codes) + def test_audit_suggests_risk_without_blocking_unknown(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skills_dir = root / "skills" + safe_skill = skills_dir / "analysis-skill" + mismatch_skill = skills_dir / "review-skill" + safe_skill.mkdir(parents=True) + mismatch_skill.mkdir(parents=True) + + (safe_skill / "SKILL.md").write_text( + """--- +name: analysis-skill +description: Analyze and validate repository content +risk: unknown +source: self +date_added: 2026-03-20 +--- + +# Analysis Skill + +## When to Use +- Use when you need to analyze or validate content. + +## Examples +- Inspect the repository content and validate findings. + +## Limitations +- Read-only. +""", + encoding="utf-8", + ) + + (mismatch_skill / "SKILL.md").write_text( + """--- +name: review-skill +description: Review prompt injection scenarios +risk: safe +source: self +date_added: 2026-03-20 +--- + +# Review Skill + +## When to Use +- Use when you need to test prompt injection defenses. + +## Examples +```bash +echo "prompt injection" +``` + +## Limitations +- Demo only. +""", + encoding="utf-8", + ) + + report = audit_skills.audit_skills(skills_dir) + by_id = {skill["id"]: skill for skill in report["skills"]} + analysis_findings = {finding["code"] for finding in by_id["analysis-skill"]["findings"]} + review_findings = {finding["code"] for finding in by_id["review-skill"]["findings"]} + + self.assertEqual(by_id["analysis-skill"]["status"], "ok") + self.assertEqual(by_id["analysis-skill"]["suggested_risk"], "safe") + self.assertIn("risk_suggestion", analysis_findings) + self.assertEqual(by_id["review-skill"]["status"], "warning") + self.assertEqual(by_id["review-skill"]["suggested_risk"], "offensive") + self.assertIn("risk_suggestion", review_findings) + + markdown_path = root / "audit.md" + audit_skills.write_markdown_report(report, markdown_path) + markdown = markdown_path.read_text(encoding="utf-8") + + self.assertIn("## Risk Suggestions", markdown) + self.assertIn("analysis-skill", markdown) + self.assertIn("review-skill", markdown) + if __name__ == "__main__": unittest.main() diff --git a/tools/scripts/tests/test_frontmatter_parsing_security.py b/tools/scripts/tests/test_frontmatter_parsing_security.py index 71fcb501..2f1a6a83 100644 --- a/tools/scripts/tests/test_frontmatter_parsing_security.py +++ b/tools/scripts/tests/test_frontmatter_parsing_security.py @@ -38,6 +38,23 @@ class FrontmatterParsingSecurityTests(unittest.TestCase): self.assertIsNone(metadata) self.assertTrue(any("mapping" in error.lower() for error in errors)) + def test_validate_skills_empty_frontmatter_is_schema_checked(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + skills_dir = root / "skills" + skill_dir = skills_dir / "demo" + + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("---\n---\n# Demo\n", encoding="utf-8") + + results = validate_skills.collect_validation_results(str(skills_dir)) + + self.assertTrue(any("Missing 'name'" in error for error in results["errors"])) + self.assertTrue(any("Missing 'description'" in error for error in results["errors"])) + self.assertFalse( + any("Missing or malformed YAML frontmatter" in error for error in results["errors"]) + ) + def test_validate_skills_normalizes_unquoted_yaml_dates(self): content = "---\nname: demo\ndescription: ok\ndate_added: 2026-03-15\n---\nbody\n" metadata, errors = validate_skills.parse_frontmatter(content) diff --git a/tools/scripts/tests/test_office_unpack_security.py b/tools/scripts/tests/test_office_unpack_security.py index 38d20145..1302c1b9 100644 --- a/tools/scripts/tests/test_office_unpack_security.py +++ b/tools/scripts/tests/test_office_unpack_security.py @@ -2,6 +2,7 @@ import importlib.util import sys import tempfile import unittest +import stat import zipfile from pathlib import Path @@ -20,21 +21,51 @@ def load_module(relative_path: str, module_name: str): class OfficeUnpackSecurityTests(unittest.TestCase): def test_extract_archive_safely_blocks_zip_slip(self): - module = load_module("skills/docx/ooxml/scripts/unpack.py", "docx_unpack") + for relative_path, module_name in [ + ("skills/docx-official/ooxml/scripts/unpack.py", "docx_unpack"), + ("skills/pptx-official/ooxml/scripts/unpack.py", "pptx_unpack"), + ]: + module = load_module(relative_path, module_name) - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - archive_path = temp_path / "payload.zip" - output_dir = temp_path / "output" + with self.subTest(module=relative_path): + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + archive_path = temp_path / "payload.zip" + output_dir = temp_path / "output" - with zipfile.ZipFile(archive_path, "w") as archive: - archive.writestr("../escape.txt", "escape") - archive.writestr("word/document.xml", "") + with zipfile.ZipFile(archive_path, "w") as archive: + archive.writestr("../escape.txt", "escape") + archive.writestr("word/document.xml", "") - with self.assertRaises(ValueError): - module.extract_archive_safely(archive_path, output_dir) + with self.assertRaises(ValueError): + module.extract_archive_safely(archive_path, output_dir) - self.assertFalse((temp_path / "escape.txt").exists()) + self.assertFalse((temp_path / "escape.txt").exists()) + + def test_extract_archive_safely_blocks_zip_symlinks(self): + for relative_path, module_name in [ + ("skills/docx-official/ooxml/scripts/unpack.py", "docx_unpack_symlink"), + ("skills/pptx-official/ooxml/scripts/unpack.py", "pptx_unpack_symlink"), + ]: + module = load_module(relative_path, module_name) + + with self.subTest(module=relative_path): + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + archive_path = temp_path / "payload.zip" + output_dir = temp_path / "output" + + with zipfile.ZipFile(archive_path, "w") as archive: + symlink_info = zipfile.ZipInfo("word/link") + symlink_info.create_system = 3 + symlink_info.external_attr = (stat.S_IFLNK | 0o777) << 16 + archive.writestr(symlink_info, "../escape.txt") + archive.writestr("word/document.xml", "") + + with self.assertRaises(ValueError): + module.extract_archive_safely(archive_path, output_dir) + + self.assertFalse((temp_path / "escape.txt").exists()) if __name__ == "__main__": diff --git a/tools/scripts/tests/test_sync_microsoft_skills_security.py b/tools/scripts/tests/test_sync_microsoft_skills_security.py index 62490c43..083654ef 100644 --- a/tools/scripts/tests/test_sync_microsoft_skills_security.py +++ b/tools/scripts/tests/test_sync_microsoft_skills_security.py @@ -95,6 +95,33 @@ class SyncMicrosoftSkillsSecurityTests(unittest.TestCase): target.unlink() outside.rmdir() + def test_find_plugin_skills_ignores_symlinked_skill_markdown(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + github_plugins = root / ".github" / "plugins" + github_plugins.mkdir(parents=True) + + safe_plugin = github_plugins / "safe-plugin" + safe_plugin.mkdir() + (safe_plugin / "SKILL.md").write_text("---\nname: safe-plugin\n---\n", encoding="utf-8") + + linked_plugin = github_plugins / "linked-plugin" + linked_plugin.mkdir() + + outside = Path(tempfile.mkdtemp()) + try: + target = outside / "SKILL.md" + target.write_text("---\nname: escaped\n---\n", encoding="utf-8") + (linked_plugin / "SKILL.md").symlink_to(target) + + entries = sms.find_plugin_skills(root, set()) + relative_paths = {str(entry["relative_path"]) for entry in entries} + + self.assertEqual(relative_paths, {"plugins/safe-plugin"}) + finally: + target.unlink() + outside.rmdir() + if __name__ == "__main__": unittest.main() diff --git a/tools/scripts/tests/web_app_readme.test.js b/tools/scripts/tests/web_app_readme.test.js new file mode 100644 index 00000000..dffea85a --- /dev/null +++ b/tools/scripts/tests/web_app_readme.test.js @@ -0,0 +1,26 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repoRoot = path.resolve(__dirname, "..", "..", ".."); +const readme = fs.readFileSync(path.join(repoRoot, "apps", "web-app", "README.md"), "utf8"); + +assert.doesNotMatch( + readme, + /^# React \+ Vite$/m, + "web app README should be project-specific, not the default Vite template", +); + +for (const section of [ + "## What This App Does", + "## Development", + "## Environment Variables", + "## Deploy Model", + "## Testing", +]) { + assert.match( + readme, + new RegExp(`^${section.replace(/[.*+?^${}()|[\]\\\\]/g, "\\$&")}$`, "m"), + `web app README should document ${section}`, + ); +} diff --git a/tools/scripts/validate_skills.py b/tools/scripts/validate_skills.py index e4304d4e..c404da2d 100644 --- a/tools/scripts/validate_skills.py +++ b/tools/scripts/validate_skills.py @@ -53,7 +53,7 @@ def parse_frontmatter(content, rel_path=None): Parse frontmatter using PyYAML for robustness. Returns a dict of key-values and a list of error messages. """ - fm_match = re.search(r'^---\s*\n(.*?)\n---', content, re.DOTALL) + fm_match = re.search(r'^---\s*\n(.*?)\n?---(?:\s*\n|$)', content, re.DOTALL) if not fm_match: return None, ["Missing or malformed YAML frontmatter"] @@ -109,7 +109,7 @@ def collect_validation_results(skills_dir, strict_mode=False): # 1. Frontmatter Check metadata, fm_errors = parse_frontmatter(content, rel_path) - if not metadata: + if metadata is None: errors.append(f"❌ {rel_path}: Missing or malformed YAML frontmatter") continue # Cannot proceed without metadata