diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5417cc3..e3b3d45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,35 +6,62 @@ on: pull_request: branches: [main] +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: + # ── Lightweight structural validation (no Node required) ────────────── validate: - name: Validate Framework + name: Validate Structure runs-on: ubuntu-latest + timeout-minutes: 2 steps: - uses: actions/checkout@v4 - - name: Check shell scripts syntax + - name: Check shell script syntax run: | errors=0 for f in scripts/*.sh; do if [ -f "$f" ]; then - echo "Checking $f..." - bash -n "$f" || errors=$((errors + 1)) + bash -n "$f" || { echo "FAIL: $f"; errors=$((errors + 1)); } fi done + echo "Checked $(ls scripts/*.sh 2>/dev/null | wc -l) scripts, $errors failed" exit $errors - name: Validate JSON configs run: | errors=0 - for f in .claude/pipeline.config.json package.json templates/**/*.json; do + for f in .claude/pipeline.config.json package.json; do if [ -f "$f" ]; then - echo "Checking $f..." - python3 -m json.tool "$f" > /dev/null || errors=$((errors + 1)) + python3 -m json.tool "$f" > /dev/null || { echo "FAIL: $f"; errors=$((errors + 1)); } fi done + for f in templates/**/*.json; do + if [ -f "$f" ]; then + python3 -m json.tool "$f" > /dev/null || { echo "FAIL: $f"; errors=$((errors + 1)); } + fi + done + echo "$errors JSON validation failures" exit $errors + - name: Validate pipeline.config.json structure + run: | + # Verify required top-level keys exist + required_keys='["visualDiff","iterationLoop","tdd","e2e","qualityGate","appTypes","orchestration","caching"]' + python3 -c " + import json, sys + with open('.claude/pipeline.config.json') as f: + config = json.load(f) + required = json.loads('$required_keys') + missing = [k for k in required if k not in config] + if missing: + print(f'Missing required keys: {missing}') + sys.exit(1) + print(f'All {len(required)} required keys present') + " + - name: Check required files exist run: | exit_code=0 @@ -46,6 +73,8 @@ jobs: "scripts/run-tests.sh" "scripts/check-types.sh" "scripts/visual-diff.js" + "scripts/verify-tokens.sh" + "scripts/check-security.sh" ) for f in "${required_files[@]}"; do if [ -f "$f" ]; then @@ -57,21 +86,152 @@ jobs: done exit $exit_code - - name: Check scripts are executable + - name: Validate agent frontmatter run: | - for f in scripts/*.sh; do - if [ -f "$f" ]; then - if [ ! -x "$f" ]; then - echo "WARNING: $f is not executable" - else - echo " $f: OK" + errors=0 + count=0 + for f in .claude/agents/*.md; do + [ -f "$f" ] || continue + count=$((count + 1)) + + # Extract YAML frontmatter between --- delimiters + frontmatter=$(sed -n '/^---$/,/^---$/p' "$f" | sed '1d;$d') + + if [ -z "$frontmatter" ]; then + echo "FAIL: $f — no YAML frontmatter found" + errors=$((errors + 1)) + continue + fi + + # Check required fields (tools is optional — omitted means "all tools") + for field in name description; do + if ! echo "$frontmatter" | grep -qE "^${field}:"; then + echo "FAIL: $f — missing required field: $field" + errors=$((errors + 1)) fi + done + done + echo "Checked $count agents, $errors failures" + exit $errors + + - name: Validate skill structure + run: | + errors=0 + count=0 + for f in .claude/skills/*.md; do + [ -f "$f" ] || continue + [ "$(basename "$f")" = "README.md" ] && continue + count=$((count + 1)) + + frontmatter=$(sed -n '/^---$/,/^---$/p' "$f" | sed '1d;$d') + + if [ -z "$frontmatter" ]; then + echo "FAIL: $f — no frontmatter found" + errors=$((errors + 1)) + continue + fi + + for field in name description; do + if ! echo "$frontmatter" | grep -qE "^${field}:"; then + echo "FAIL: $f — missing required field: $field" + errors=$((errors + 1)) + fi + done + done + echo "Checked $count skills, $errors failures" + [ $count -eq 0 ] && echo "Note: no skill .md files found in .claude/skills/" + exit $errors + + - name: Validate templates + run: | + errors=0 + + # Check template JSON files parse correctly + for f in templates/**/*.json; do + if [ -f "$f" ]; then + python3 -m json.tool "$f" > /dev/null 2>&1 || { + echo "FAIL: $f — invalid JSON" + errors=$((errors + 1)) + } fi done + # Check key template directories exist + for dir in templates/shared templates/nextjs templates/vite; do + if [ -d "$dir" ]; then + echo " $dir: OK" + else + echo " $dir: MISSING" + errors=$((errors + 1)) + fi + done + + # Check shared configs exist + for f in templates/shared/eslint.config.js templates/shared/prettier.config.js templates/shared/tsconfig.json templates/shared/tailwind.config.ts; do + if [ -f "$f" ]; then + echo " $f: OK" + else + echo " $f: MISSING" + errors=$((errors + 1)) + fi + done + + echo "$errors template validation failures" + exit $errors + + # ── Script test suite (needs Node + dependencies) ───────────────────── + script-tests: + name: Script Tests + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run script tests + run: pnpm vitest run scripts/__tests__/ --reporter=verbose + + # ── Lint & format check (needs Node + project with eslint/prettier) ─── + lint: + name: Lint & Format + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run lint and format check + run: bash scripts/lint-and-format.sh --check + + # ── Token verification ──────────────────────────────────────────────── token-verification: name: Verify Design Tokens runs-on: ubuntu-latest + timeout-minutes: 2 steps: - uses: actions/checkout@v4 @@ -83,16 +243,18 @@ jobs: echo "No app source found — skipping token check" fi + # ── Security scanning ───────────────────────────────────────────────── security-scan: - name: Security Scanning + name: Security Scan runs-on: ubuntu-latest + timeout-minutes: 3 steps: - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: "20" - name: Install pnpm uses: pnpm/action-setup@v4 @@ -129,9 +291,11 @@ jobs: if-no-files-found: ignore retention-days: 30 + # ── Visual regression (PR only) ─────────────────────────────────────── visual-regression: - name: Visual Regression Test + name: Visual Regression runs-on: ubuntu-latest + timeout-minutes: 5 if: github.event_name == 'pull_request' steps: - uses: actions/checkout@v4 @@ -141,7 +305,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: "20" - name: Install pnpm uses: pnpm/action-setup@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51bfbde..0d4a74c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,9 +4,9 @@ on: workflow_dispatch: inputs: release_type: - description: 'Release type' + description: "Release type" required: true - default: 'auto' + default: "auto" type: choice options: - auto @@ -30,7 +30,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: "20" - name: Install pnpm uses: pnpm/action-setup@v4 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..09fad60 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +build/ +pnpm-lock.yaml +CHANGELOG.md +.claude/ +templates/ +docs/ +app/ +*.md diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..6120787 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "all", + "printWidth": 100 +} diff --git a/commitlint.config.js b/commitlint.config.js index ec9b0d2..9bfa72b 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -1,24 +1,24 @@ export default { - extends: ['@commitlint/config-conventional'], + extends: ["@commitlint/config-conventional"], rules: { - 'type-enum': [ + "type-enum": [ 2, - 'always', + "always", [ - 'feat', - 'fix', - 'docs', - 'style', - 'refactor', - 'perf', - 'test', - 'build', - 'ci', - 'chore', - 'revert', + "feat", + "fix", + "docs", + "style", + "refactor", + "perf", + "test", + "build", + "ci", + "chore", + "revert", ], ], - 'subject-case': [2, 'never', ['start-case', 'pascal-case', 'upper-case']], - 'header-max-length': [2, 'always', 100], + "subject-case": [2, "never", ["start-case", "pascal-case", "upper-case"]], + "header-max-length": [2, "always", 100], }, }; diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..f8e8f93 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,32 @@ +import js from "@eslint/js"; +import eslintConfigPrettier from "eslint-config-prettier"; + +export default [ + js.configs.recommended, + eslintConfigPrettier, + { + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: { + // Node.js globals + console: "readonly", + process: "readonly", + Buffer: "readonly", + __dirname: "readonly", + __filename: "readonly", + setTimeout: "readonly", + clearTimeout: "readonly", + setInterval: "readonly", + clearInterval: "readonly", + URL: "readonly", + }, + }, + rules: { + "no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], + }, + }, + { + ignores: ["node_modules/", "dist/", "build/", ".claude/", "templates/", "docs/", "app/"], + }, +]; diff --git a/package.json b/package.json index 636a385..4b8ece4 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,14 @@ "devDependencies": { "@commitlint/cli": "^20.5.0", "@commitlint/config-conventional": "^20.5.0", + "@eslint/js": "^10.0.1", "commit-and-tag-version": "^12.7.1", + "eslint": "^10.1.0", + "eslint-config-prettier": "^10.1.8", "husky": "^9.1.7", "pixelmatch": "^7.1.0", "pngjs": "^7.0.0", + "prettier": "^3.8.1", "vitest": "^4.1.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da556a5..69ff07c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,18 @@ importers: '@commitlint/config-conventional': specifier: ^20.5.0 version: 20.5.0 + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.1.0(jiti@2.6.1)) commit-and-tag-version: specifier: ^12.7.1 version: 12.7.1 + eslint: + specifier: ^10.1.0 + version: 10.1.0(jiti@2.6.1) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@10.1.0(jiti@2.6.1)) husky: specifier: ^9.1.7 version: 9.1.7 @@ -26,6 +35,9 @@ importers: pngjs: specifier: ^7.0.0 version: 7.0.0 + prettier: + specifier: ^3.8.1 + version: 3.8.1 vitest: specifier: ^4.1.2 version: 4.1.2(@types/node@25.5.0)(vite@8.0.0(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.3)) @@ -130,6 +142,61 @@ packages: '@emnapi/wasi-threads@1.2.0': resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.3': + resolution: {integrity: sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.5.3': + resolution: {integrity: sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.1.1': + resolution: {integrity: sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.3': + resolution: {integrity: sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.6.1': + resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@hutson/parse-repository-url@3.0.2': resolution: {integrity: sha512-H9XAx3hc0BQHY6l+IFSWHDySypcXsvsuLhgYLUGywmJ5pswRVQJUHpOsobnLYp2ZUaUlKiKDrgWWhosOwAEM8Q==} engines: {node: '>=6.9.0'} @@ -262,9 +329,15 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/minimist@1.2.5': resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} @@ -307,9 +380,22 @@ packages: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + add-stream@1.0.0: resolution: {integrity: sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==} + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -342,9 +428,17 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + brace-expansion@1.1.13: resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -509,6 +603,10 @@ packages: typescript: optional: true + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + dargs@7.0.0: resolution: {integrity: sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==} engines: {node: '>=8'} @@ -516,6 +614,15 @@ packages: dateformat@3.0.3: resolution: {integrity: sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decamelize-keys@1.1.1: resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==} engines: {node: '>=0.10.0'} @@ -524,6 +631,9 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -565,9 +675,61 @@ packages: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.1.0: + resolution: {integrity: sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -575,6 +737,12 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -598,6 +766,10 @@ packages: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + find-up@2.1.0: resolution: {integrity: sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==} engines: {node: '>=4'} @@ -614,6 +786,13 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -655,6 +834,10 @@ packages: gitconfiglocal@1.0.0: resolution: {integrity: sha512-spLUXeTAVHxDtKsJc8FkFVgFtMdEN9qPGpL23VfSHx4fP4+Ds097IXLvymbnDH8FnmxX5Nr9bPw3A+AQ6mWEaQ==} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} engines: {node: '>=18'} @@ -691,6 +874,10 @@ packages: engines: {node: '>=18'} hasBin: true + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -698,6 +885,10 @@ packages: import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} @@ -719,10 +910,18 @@ packages: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + is-obj@2.0.0: resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} engines: {node: '>=8'} @@ -742,6 +941,9 @@ packages: isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -753,15 +955,24 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-parse-better-errors@1.0.2: resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} @@ -769,10 +980,17 @@ packages: resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} engines: {'0': node >= 0.2.0} + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kind-of@6.0.3: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -914,6 +1132,10 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} @@ -928,11 +1150,17 @@ packages: resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==} engines: {node: '>=0.10.0'} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} @@ -946,6 +1174,10 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + p-limit@1.3.0: resolution: {integrity: sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==} engines: {node: '>=4'} @@ -1006,6 +1238,10 @@ packages: resolution: {integrity: sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==} engines: {node: '>=14.0.0'} + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -1043,9 +1279,22 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + quick-lru@4.0.1: resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==} engines: {node: '>=8'} @@ -1118,6 +1367,14 @@ packages: engines: {node: '>=10'} hasBin: true + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1218,6 +1475,10 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + type-fest@0.18.1: resolution: {integrity: sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==} engines: {node: '>=10'} @@ -1246,6 +1507,9 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -1330,11 +1594,20 @@ packages: jsdom: optional: true + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} @@ -1526,6 +1799,51 @@ snapshots: tslib: 2.8.1 optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.1.0(jiti@2.6.1))': + dependencies: + eslint: 10.1.0(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.3': + dependencies: + '@eslint/object-schema': 3.0.3 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.5.3': + dependencies: + '@eslint/core': 1.1.1 + + '@eslint/core@1.1.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.1.0(jiti@2.6.1))': + optionalDependencies: + eslint: 10.1.0(jiti@2.6.1) + + '@eslint/object-schema@3.0.3': {} + + '@eslint/plugin-kit@0.6.1': + dependencies: + '@eslint/core': 1.1.1 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@hutson/parse-repository-url@3.0.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -1613,8 +1931,12 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} + '@types/estree@1.0.8': {} + '@types/json-schema@7.0.15': {} + '@types/minimist@1.2.5': {} '@types/node@25.5.0': @@ -1669,8 +1991,21 @@ snapshots: jsonparse: 1.3.1 through: 2.3.8 + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + add-stream@1.0.0: {} + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 @@ -1698,11 +2033,17 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + brace-expansion@1.1.13: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + buffer-from@1.1.2: {} callsites@3.1.0: {} @@ -1900,10 +2241,20 @@ snapshots: optionalDependencies: typescript: 6.0.2 + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + dargs@7.0.0: {} dateformat@3.0.3: {} + debug@4.4.3: + dependencies: + ms: 2.1.3 + decamelize-keys@1.1.1: dependencies: decamelize: 1.2.0 @@ -1911,6 +2262,8 @@ snapshots: decamelize@1.2.0: {} + deep-is@0.1.4: {} + detect-indent@6.1.0: {} detect-libc@2.1.2: {} @@ -1940,14 +2293,90 @@ snapshots: escape-string-regexp@1.0.5: {} + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@10.1.0(jiti@2.6.1)): + dependencies: + eslint: 10.1.0(jiti@2.6.1) + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.1.0(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.3 + '@eslint/config-helpers': 0.5.3 + '@eslint/core': 1.1.1 + '@eslint/plugin-kit': 0.6.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 + esutils@2.0.3: {} + expect-type@1.3.0: {} fast-deep-equal@3.1.3: {} + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + fast-uri@3.1.0: {} fast-xml-builder@1.1.4: @@ -1968,6 +2397,10 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + find-up@2.1.0: dependencies: locate-path: 2.0.0 @@ -1986,6 +2419,13 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + fsevents@2.3.3: optional: true @@ -2028,6 +2468,10 @@ snapshots: dependencies: ini: 1.3.8 + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + global-directory@4.0.1: dependencies: ini: 4.1.1 @@ -2059,6 +2503,8 @@ snapshots: husky@9.1.7: {} + ignore@5.3.2: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -2066,6 +2512,8 @@ snapshots: import-meta-resolve@4.2.0: {} + imurmurhash@0.1.4: {} + indent-string@4.0.0: {} inherits@2.0.4: {} @@ -2080,8 +2528,14 @@ snapshots: dependencies: hasown: 2.0.2 + is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + is-obj@2.0.0: {} is-plain-obj@1.1.0: {} @@ -2094,6 +2548,8 @@ snapshots: isarray@1.0.0: {} + isexe@2.0.0: {} + jiti@2.6.1: {} js-tokens@4.0.0: {} @@ -2102,18 +2558,33 @@ snapshots: dependencies: argparse: 2.0.1 + json-buffer@3.0.1: {} + json-parse-better-errors@1.0.2: {} json-parse-even-better-errors@2.3.1: {} + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-safe@5.0.1: {} jsonparse@1.3.1: {} + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + kind-of@6.0.3: {} + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + lightningcss-android-arm64@1.32.0: optional: true @@ -2234,6 +2705,10 @@ snapshots: min-indent@1.0.1: {} + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + minimatch@3.1.5: dependencies: brace-expansion: 1.1.13 @@ -2248,8 +2723,12 @@ snapshots: modify-values@1.0.1: {} + ms@2.1.3: {} + nanoid@3.3.11: {} + natural-compare@1.4.0: {} + neo-async@2.6.2: {} normalize-package-data@2.5.0: @@ -2268,6 +2747,15 @@ snapshots: obug@2.1.1: {} + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + p-limit@1.3.0: dependencies: p-try: 1.0.0 @@ -2322,6 +2810,8 @@ snapshots: path-expression-matcher@1.2.0: {} + path-key@3.1.1: {} + path-parse@1.0.7: {} path-type@3.0.0: @@ -2350,8 +2840,14 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prelude-ls@1.2.1: {} + + prettier@3.8.1: {} + process-nextick-args@2.0.1: {} + punycode@2.3.1: {} + quick-lru@4.0.1: {} read-pkg-up@3.0.0: @@ -2445,6 +2941,12 @@ snapshots: semver@7.7.4: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -2534,6 +3036,10 @@ snapshots: tslib@2.8.1: optional: true + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + type-fest@0.18.1: {} type-fest@0.6.0: {} @@ -2549,6 +3055,10 @@ snapshots: undici-types@7.18.2: {} + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + util-deprecate@1.0.2: {} validate-npm-package-license@3.0.4: @@ -2600,11 +3110,17 @@ snapshots: transitivePeerDependencies: - msw + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + word-wrap@1.2.5: {} + wordwrap@1.0.0: {} wrap-ansi@7.0.0: diff --git a/scripts/__tests__/generate-fixtures.js b/scripts/__tests__/generate-fixtures.js index ba40970..97d4f9f 100644 --- a/scripts/__tests__/generate-fixtures.js +++ b/scripts/__tests__/generate-fixtures.js @@ -37,8 +37,8 @@ export function solid(r, g, b, a = 255) { export function textBands(weight = 400) { const darkness = Math.round(255 - (weight / 900) * 200); return (x, y) => { - const inBand = (y % 20) < 12; - const inChar = (x % 10) < 6; + const inBand = y % 20 < 12; + const inChar = x % 10 < 6; if (inBand && inChar) return [darkness, darkness, darkness, 255]; return [255, 255, 255, 255]; }; @@ -73,8 +73,8 @@ export function withShift(baseFn, dx, dy) { // Simulate font fallback: different character widths export function textBandsFallback() { return (x, y) => { - const inBand = (y % 20) < 12; - const inChar = (x % 10) < 8; + const inBand = y % 20 < 12; + const inChar = x % 10 < 8; if (inBand && inChar) return [50, 50, 50, 255]; return [255, 255, 255, 255]; }; diff --git a/scripts/__tests__/visual-diff.test.js b/scripts/__tests__/visual-diff.test.js index 5e1b59d..bdfc9ec 100644 --- a/scripts/__tests__/visual-diff.test.js +++ b/scripts/__tests__/visual-diff.test.js @@ -60,10 +60,7 @@ describe("visual-diff.js — sub-pixel detection", () => { it("classifies scattered single-pixel diffs as sub-pixel artifacts", () => { const base = solid(200, 200, 200); const a = savePNG("subpixel-a.png", createPNG(200, 200, base)); - const b = savePNG( - "subpixel-b.png", - createPNG(200, 200, withSubPixelNoise(base, 0.005)) - ); + const b = savePNG("subpixel-b.png", createPNG(200, 200, withSubPixelNoise(base, 0.005))); const result = runDiff([a, b, "--json"]); expect(result.subPixelAnalysis).toBeDefined(); expect(result.subPixelAnalysis.subPixelPct).toBeGreaterThan(0.5); @@ -85,14 +82,8 @@ describe("visual-diff.js — sub-pixel detection", () => { describe("visual-diff.js — font weight detection", () => { it("detects font weight mismatch between 400 and 700", () => { - const a = savePNG( - "weight-400.png", - createPNG(200, 200, textBands(400)) - ); - const b = savePNG( - "weight-700.png", - createPNG(200, 200, textBands(700)) - ); + const a = savePNG("weight-400.png", createPNG(200, 200, textBands(400))); + const b = savePNG("weight-700.png", createPNG(200, 200, textBands(700))); const result = runDiff([a, b, "--json"]); expect(result.typographyAnalysis).toBeDefined(); expect(result.typographyAnalysis.fontWeightMismatch).toBe(true); @@ -110,14 +101,8 @@ describe("visual-diff.js — font weight detection", () => { describe("visual-diff.js — font fallback detection", () => { it("detects font fallback from character width differences", () => { - const a = savePNG( - "font-primary.png", - createPNG(200, 200, textBands(400)) - ); - const b = savePNG( - "font-fallback.png", - createPNG(200, 200, textBandsFallback()) - ); + const a = savePNG("font-primary.png", createPNG(200, 200, textBands(400))); + const b = savePNG("font-fallback.png", createPNG(200, 200, textBandsFallback())); const result = runDiff([a, b, "--json"]); expect(result.typographyAnalysis).toBeDefined(); expect(result.typographyAnalysis.fontFallbackDetected).toBe(true); @@ -128,10 +113,7 @@ describe("visual-diff.js — responsive layout comparison", () => { it("detects layout shift when content moves", () => { const base = textBands(400); const a = savePNG("layout-a.png", createPNG(200, 200, base)); - const b = savePNG( - "layout-b.png", - createPNG(200, 200, withShift(base, 10, 5)) - ); + const b = savePNG("layout-b.png", createPNG(200, 200, withShift(base, 10, 5))); const result = runDiff([a, b, "--json"]); expect(result.layoutAnalysis).toBeDefined(); expect(result.layoutAnalysis.layoutShiftDetected).toBe(true); @@ -154,10 +136,7 @@ describe("visual-diff.js — responsive layout comparison", () => { savePNG("responsive-actual/desktop-1440px.png", createPNG(1440, 200, base)); savePNG("responsive-expected/desktop-1440px.png", createPNG(1440, 200, base)); savePNG("responsive-actual/mobile-375px.png", createPNG(375, 200, base)); - savePNG( - "responsive-expected/mobile-375px.png", - createPNG(375, 200, withShift(base, 5, 0)) - ); + savePNG("responsive-expected/mobile-375px.png", createPNG(375, 200, withShift(base, 5, 0))); const result = runDiff([ "--batch", diff --git a/scripts/create-app.js b/scripts/create-app.js index 40834d0..b40acfb 100644 --- a/scripts/create-app.js +++ b/scripts/create-app.js @@ -12,30 +12,30 @@ * 5. Clears CHANGELOG.md */ -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const rootDir = path.resolve(__dirname, '..'); +const rootDir = path.resolve(__dirname, ".."); const appName = process.argv[2]; if (!appName) { - console.error('Usage: pnpm new-app '); - console.error('Example: pnpm new-app my-chrome-extension'); + console.error("Usage: pnpm new-app "); + console.error("Example: pnpm new-app my-chrome-extension"); process.exit(1); } // Validate app name const validName = /^[a-z0-9-]+$/; if (!validName.test(appName)) { - console.error('Error: App name must be lowercase letters, numbers, and hyphens only'); + console.error("Error: App name must be lowercase letters, numbers, and hyphens only"); process.exit(1); } -const sourceDir = path.join(rootDir, 'app'); +const sourceDir = path.join(rootDir, "app"); const targetDir = path.join(rootDir, appName); // Check if target already exists @@ -54,7 +54,7 @@ function copyDir(src, dest) { const destPath = path.join(dest, entry.name); // Skip node_modules and dist - if (entry.name === 'node_modules' || entry.name === 'dist') { + if (entry.name === "node_modules" || entry.name === "dist") { continue; } @@ -72,59 +72,63 @@ console.log(`Copying template from app/ to ${appName}/...`); copyDir(sourceDir, targetDir); // Update package.json -const packageJsonPath = path.join(targetDir, 'package.json'); -const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); +const packageJsonPath = path.join(targetDir, "package.json"); +const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")); packageJson.name = appName; -packageJson.version = '0.1.0'; -fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n'); -console.log('Updated package.json'); +packageJson.version = "0.1.0"; +fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n"); +console.log("Updated package.json"); // Update manifest.json -const manifestPath = path.join(targetDir, 'manifest.json'); -const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); -manifest.name = appName.split('-').map(word => - word.charAt(0).toUpperCase() + word.slice(1) -).join(' '); -manifest.version = '0.1.0'; +const manifestPath = path.join(targetDir, "manifest.json"); +const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); +manifest.name = appName + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +manifest.version = "0.1.0"; manifest.description = `${manifest.name} - Built with Claude Code React Framework`; -fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); -console.log('Updated manifest.json'); +fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n"); +console.log("Updated manifest.json"); // Clear CHANGELOG.md if it exists -const changelogPath = path.join(targetDir, 'CHANGELOG.md'); +const changelogPath = path.join(targetDir, "CHANGELOG.md"); if (fs.existsSync(changelogPath)) { - fs.writeFileSync(changelogPath, `# Changelog + fs.writeFileSync( + changelogPath, + `# Changelog All notable changes to ${manifest.name} will be documented in this file. -## 0.1.0 (${new Date().toISOString().split('T')[0]}) +## 0.1.0 (${new Date().toISOString().split("T")[0]}) ### Features * Initial release -`); - console.log('Reset CHANGELOG.md'); +`, + ); + console.log("Reset CHANGELOG.md"); } // Update .versionrc.json tagPrefix -const versionrcPath = path.join(targetDir, '.versionrc.json'); +const versionrcPath = path.join(targetDir, ".versionrc.json"); if (fs.existsSync(versionrcPath)) { - const versionrc = JSON.parse(fs.readFileSync(versionrcPath, 'utf-8')); + const versionrc = JSON.parse(fs.readFileSync(versionrcPath, "utf-8")); versionrc.tagPrefix = `${appName}-v`; versionrc.header = `# Changelog\n\nAll notable changes to ${manifest.name} will be documented in this file.\n`; - fs.writeFileSync(versionrcPath, JSON.stringify(versionrc, null, 2) + '\n'); - console.log('Updated .versionrc.json'); + fs.writeFileSync(versionrcPath, JSON.stringify(versionrc, null, 2) + "\n"); + console.log("Updated .versionrc.json"); } -console.log(''); -console.log('✅ App created successfully!'); -console.log(''); -console.log('Next steps:'); +console.log(""); +console.log("✅ App created successfully!"); +console.log(""); +console.log("Next steps:"); console.log(` cd ${appName}`); -console.log(' pnpm install'); -console.log(' pnpm dev'); -console.log(''); -console.log('To release a new version:'); -console.log(' pnpm release # Auto-detect version bump'); -console.log(' pnpm release:patch # Patch bump (0.1.0 → 0.1.1)'); -console.log(' pnpm release:minor # Minor bump (0.1.0 → 0.2.0)'); +console.log(" pnpm install"); +console.log(" pnpm dev"); +console.log(""); +console.log("To release a new version:"); +console.log(" pnpm release # Auto-detect version bump"); +console.log(" pnpm release:patch # Patch bump (0.1.0 → 0.1.1)"); +console.log(" pnpm release:minor # Minor bump (0.1.0 → 0.2.0)"); diff --git a/scripts/extract-release-notes.js b/scripts/extract-release-notes.js index fa29a73..4cb29a3 100644 --- a/scripts/extract-release-notes.js +++ b/scripts/extract-release-notes.js @@ -5,20 +5,20 @@ * Writes the result to RELEASE_NOTES.md for use in GitHub Releases. */ -import { readFileSync, writeFileSync } from 'fs'; +import { readFileSync, writeFileSync } from "fs"; -const changelog = readFileSync('CHANGELOG.md', 'utf8'); +const changelog = readFileSync("CHANGELOG.md", "utf8"); const sections = changelog.split(/^## /m).slice(1); if (sections.length === 0) { - console.error('No version sections found in CHANGELOG.md'); + console.error("No version sections found in CHANGELOG.md"); process.exit(1); } // First section after split is the latest version const latestSection = sections[0]; // Remove the version header line, keep the rest -const notes = latestSection.replace(/^.*\n/, '').trim(); +const notes = latestSection.replace(/^.*\n/, "").trim(); -writeFileSync('RELEASE_NOTES.md', notes); -console.log('Release notes extracted to RELEASE_NOTES.md'); +writeFileSync("RELEASE_NOTES.md", notes); +console.log("Release notes extracted to RELEASE_NOTES.md"); diff --git a/scripts/lib/sanitize.js b/scripts/lib/sanitize.js index 7980854..be9006f 100644 --- a/scripts/lib/sanitize.js +++ b/scripts/lib/sanitize.js @@ -5,21 +5,23 @@ * potentially dangerous data before processing in the build pipeline. */ +import path from "path"; + /** * Sanitize a URL to prevent SSRF and injection attacks * @param {string} url - The URL to sanitize * @returns {{ valid: boolean, url: string | null, error?: string }} */ export function sanitizeUrl(url) { - if (typeof url !== 'string' || !url.trim()) { - return { valid: false, url: null, error: 'URL must be a non-empty string' }; + if (typeof url !== "string" || !url.trim()) { + return { valid: false, url: null, error: "URL must be a non-empty string" }; } try { const parsed = new URL(url.trim()); // Only allow http and https protocols - if (!['http:', 'https:'].includes(parsed.protocol)) { + if (!["http:", "https:"].includes(parsed.protocol)) { return { valid: false, url: null, @@ -43,12 +45,12 @@ export function sanitizeUrl(url) { const isBlocked = blockedPatterns.some((pattern) => pattern.test(hostname)); // Allow localhost in development mode - const isDev = process.env.NODE_ENV === 'development'; + const isDev = process.env.NODE_ENV === "development"; if (isBlocked && !isDev) { return { valid: false, url: null, - error: 'Private/local addresses are not allowed', + error: "Private/local addresses are not allowed", }; } @@ -65,16 +67,15 @@ export function sanitizeUrl(url) { * @returns {{ valid: boolean, path: string | null, error?: string }} */ export function sanitizePath(filePath, baseDir) { - if (typeof filePath !== 'string' || !filePath.trim()) { - return { valid: false, path: null, error: 'Path must be a non-empty string' }; + if (typeof filePath !== "string" || !filePath.trim()) { + return { valid: false, path: null, error: "Path must be a non-empty string" }; } - if (typeof baseDir !== 'string' || !baseDir.trim()) { - return { valid: false, path: null, error: 'Base directory must be specified' }; + if (typeof baseDir !== "string" || !baseDir.trim()) { + return { valid: false, path: null, error: "Base directory must be specified" }; } // Normalize paths for cross-platform compatibility - const path = require('path'); const normalizedBase = path.resolve(baseDir); const normalizedPath = path.resolve(baseDir, filePath); @@ -83,7 +84,7 @@ export function sanitizePath(filePath, baseDir) { return { valid: false, path: null, - error: 'Path traversal detected: path escapes base directory', + error: "Path traversal detected: path escapes base directory", }; } @@ -108,7 +109,7 @@ export function sanitizePath(filePath, baseDir) { return { valid: false, path: null, - error: 'Access to sensitive files is not allowed', + error: "Access to sensitive files is not allowed", }; } @@ -121,16 +122,16 @@ export function sanitizePath(filePath, baseDir) { * @returns {string} - Sanitized HTML with dangerous elements removed */ export function sanitizeHtml(html) { - if (typeof html !== 'string') { - return ''; + if (typeof html !== "string") { + return ""; } // Remove script tags and their content - let sanitized = html.replace(/)<[^<]*)*<\/script>/gi, ''); + let sanitized = html.replace(/)<[^<]*)*<\/script>/gi, ""); // Remove event handlers - sanitized = sanitized.replace(/\s*on\w+\s*=\s*["'][^"']*["']/gi, ''); - sanitized = sanitized.replace(/\s*on\w+\s*=\s*[^\s>]+/gi, ''); + sanitized = sanitized.replace(/\s*on\w+\s*=\s*["'][^"']*["']/gi, ""); + sanitized = sanitized.replace(/\s*on\w+\s*=\s*[^\s>]+/gi, ""); // Remove javascript: URLs sanitized = sanitized.replace(/href\s*=\s*["']?\s*javascript:[^"'>\s]*/gi, 'href="#"'); @@ -140,11 +141,11 @@ export function sanitizeHtml(html) { sanitized = sanitized.replace(/src\s*=\s*["']?\s*data:[^"'>\s]*/gi, 'src=""'); // Remove style expressions (IE-specific XSS) - sanitized = sanitized.replace(/expression\s*\([^)]*\)/gi, ''); + sanitized = sanitized.replace(/expression\s*\([^)]*\)/gi, ""); // Remove iframe, object, embed, and form tags - sanitized = sanitized.replace(/<(iframe|object|embed|form)\b[^>]*>/gi, ''); - sanitized = sanitized.replace(/<\/(iframe|object|embed|form)>/gi, ''); + sanitized = sanitized.replace(/<(iframe|object|embed|form)\b[^>]*>/gi, ""); + sanitized = sanitized.replace(/<\/(iframe|object|embed|form)>/gi, ""); return sanitized; } @@ -155,24 +156,19 @@ export function sanitizeHtml(html) { * @returns {{ valid: boolean, arg: string | null, error?: string }} */ export function sanitizeShellArg(arg) { - if (typeof arg !== 'string') { - return { valid: false, arg: null, error: 'Argument must be a string' }; + if (typeof arg !== "string") { + return { valid: false, arg: null, error: "Argument must be a string" }; } // Block dangerous shell metacharacters - const dangerousPatterns = [ - /[;&|`$(){}[\]<>]/, - /\n/, - /\r/, - /\0/, - ]; + const dangerousPatterns = [/[;&|`$(){}[\]<>]/, /\n/, /\r/, /\0/]; const hasDangerous = dangerousPatterns.some((pattern) => pattern.test(arg)); if (hasDangerous) { return { valid: false, arg: null, - error: 'Argument contains dangerous shell metacharacters', + error: "Argument contains dangerous shell metacharacters", }; } @@ -197,25 +193,25 @@ export function sanitizeDesignUrl(url) { const hostname = parsed.hostname.toLowerCase(); // Figma URLs - if (hostname === 'www.figma.com' || hostname === 'figma.com') { + if (hostname === "www.figma.com" || hostname === "figma.com") { const figmaPattern = /^\/(?:file|design|proto)\/([a-zA-Z0-9]+)/; if (figmaPattern.test(parsed.pathname)) { - return { valid: true, url: urlResult.url, type: 'figma' }; + return { valid: true, url: urlResult.url, type: "figma" }; } - return { valid: false, url: null, type: null, error: 'Invalid Figma URL format' }; + return { valid: false, url: null, type: null, error: "Invalid Figma URL format" }; } // Canva URLs - if (hostname === 'www.canva.com' || hostname === 'canva.com') { + if (hostname === "www.canva.com" || hostname === "canva.com") { const canvaPattern = /^\/design\/([a-zA-Z0-9_-]+)/; if (canvaPattern.test(parsed.pathname)) { - return { valid: true, url: urlResult.url, type: 'canva' }; + return { valid: true, url: urlResult.url, type: "canva" }; } - return { valid: false, url: null, type: null, error: 'Invalid Canva URL format' }; + return { valid: false, url: null, type: null, error: "Invalid Canva URL format" }; } // Generic URL (for screenshot pipeline) - return { valid: true, url: urlResult.url, type: 'generic' }; + return { valid: true, url: urlResult.url, type: "generic" }; } /** @@ -224,20 +220,20 @@ export function sanitizeDesignUrl(url) { * @returns {{ valid: boolean, data: any, error?: string }} */ export function sanitizeJson(jsonString) { - if (typeof jsonString !== 'string') { - return { valid: false, data: null, error: 'Input must be a string' }; + if (typeof jsonString !== "string") { + return { valid: false, data: null, error: "Input must be a string" }; } try { const data = JSON.parse(jsonString); // Check for prototype pollution attempts - const checkPrototypePollution = (obj, path = '') => { - if (obj === null || typeof obj !== 'object') { + const checkPrototypePollution = (obj, path = "") => { + if (obj === null || typeof obj !== "object") { return null; } - const dangerousKeys = ['__proto__', 'constructor', 'prototype']; + const dangerousKeys = ["__proto__", "constructor", "prototype"]; for (const key of Object.keys(obj)) { if (dangerousKeys.includes(key)) { diff --git a/scripts/metrics-dashboard.js b/scripts/metrics-dashboard.js index d90ce4a..6a57f1e 100644 --- a/scripts/metrics-dashboard.js +++ b/scripts/metrics-dashboard.js @@ -16,12 +16,7 @@ * - Actionable optimization recommendations */ -import { - readFileSync, - writeFileSync, - existsSync, - mkdirSync, -} from "fs"; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; @@ -71,24 +66,24 @@ function calculateSummary() { // Duration stats const durations = runs.filter((r) => r.totalDuration).map((r) => r.totalDuration); - const avgDuration = durations.length > 0 - ? durations.reduce((a, b) => a + b, 0) / durations.length - : 0; + const avgDuration = + durations.length > 0 ? durations.reduce((a, b) => a + b, 0) / durations.length : 0; const minDuration = durations.length > 0 ? Math.min(...durations) : 0; const maxDuration = durations.length > 0 ? Math.max(...durations) : 0; // Recent trend (last 7 runs) const recentRuns = runs.slice(-7); - const recentAvg = recentRuns.length > 0 - ? recentRuns.filter((r) => r.totalDuration).reduce((a, r) => a + r.totalDuration, 0) / recentRuns.length - : 0; + const recentAvg = + recentRuns.length > 0 + ? recentRuns.filter((r) => r.totalDuration).reduce((a, r) => a + r.totalDuration, 0) / + recentRuns.length + : 0; // Cache efficiency const cacheMetrics = cache.metrics || {}; const totalCacheOps = (cacheMetrics.cacheHits || 0) + (cacheMetrics.cacheMisses || 0); - const cacheHitRate = totalCacheOps > 0 - ? ((cacheMetrics.cacheHits || 0) / totalCacheOps) * 100 - : 0; + const cacheHitRate = + totalCacheOps > 0 ? ((cacheMetrics.cacheHits || 0) / totalCacheOps) * 100 : 0; // Stage analysis const stageStats = {}; @@ -109,12 +104,14 @@ function calculateSummary() { // Find slowest stages const stageAvgs = Object.entries(stageStats).map(([stage, stats]) => ({ stage, - avgDuration: stats.durations.length > 0 - ? stats.durations.reduce((a, b) => a + b, 0) / stats.durations.length - : 0, - successRate: stats.successes + stats.failures > 0 - ? (stats.successes / (stats.successes + stats.failures)) * 100 - : 100, + avgDuration: + stats.durations.length > 0 + ? stats.durations.reduce((a, b) => a + b, 0) / stats.durations.length + : 0, + successRate: + stats.successes + stats.failures > 0 + ? (stats.successes / (stats.successes + stats.failures)) * 100 + : 100, })); const slowestStages = stageAvgs.sort((a, b) => b.avgDuration - a.avgDuration).slice(0, 5); @@ -131,7 +128,8 @@ function calculateSummary() { min: minDuration, max: maxDuration, recent: Math.round(recentAvg), - trend: recentAvg < avgDuration ? "improving" : recentAvg > avgDuration ? "degrading" : "stable", + trend: + recentAvg < avgDuration ? "improving" : recentAvg > avgDuration ? "degrading" : "stable", }, cache: { hits: cacheMetrics.cacheHits || 0, @@ -178,13 +176,15 @@ function calculateTrends(period = "7d") { .sort(([a], [b]) => a.localeCompare(b)) .map(([date, stats]) => ({ date, - avgDuration: stats.durations.length > 0 - ? Math.round(stats.durations.reduce((a, b) => a + b, 0) / stats.durations.length) - : 0, + avgDuration: + stats.durations.length > 0 + ? Math.round(stats.durations.reduce((a, b) => a + b, 0) / stats.durations.length) + : 0, runs: stats.durations.length, - successRate: stats.successes + stats.failures > 0 - ? Math.round((stats.successes / (stats.successes + stats.failures)) * 100) - : 100, + successRate: + stats.successes + stats.failures > 0 + ? Math.round((stats.successes / (stats.successes + stats.failures)) * 100) + : 100, })); // Calculate trend direction @@ -229,10 +229,7 @@ function compareRuns(runId1, runId2) { }; // Compare stages - const allStages = new Set([ - ...Object.keys(run1.stages || {}), - ...Object.keys(run2.stages || {}), - ]); + const allStages = new Set([...Object.keys(run1.stages || {}), ...Object.keys(run2.stages || {})]); for (const stage of allStages) { const stage1 = run1.stages?.[stage] || {}; @@ -372,7 +369,7 @@ function generateHtmlDashboard() {
Slowest
-
+
Trend: ${summary.duration.trend}
@@ -380,7 +377,7 @@ function generateHtmlDashboard() {

Cache Efficiency

-
${summary.cache.hitRate}%
+
${summary.cache.hitRate}%
Cache hit rate
Hits: ${summary.cache.hits} | Misses: ${summary.cache.misses}
@@ -392,24 +389,28 @@ function generateHtmlDashboard() {

Slowest Stages

- ${summary.slowestStages.map((s) => { - const maxDuration = summary.slowestStages[0]?.avgDuration || 1; - const pct = (s.avgDuration / maxDuration) * 100; - return ` + ${summary.slowestStages + .map((s) => { + const maxDuration = summary.slowestStages[0]?.avgDuration || 1; + const pct = (s.avgDuration / maxDuration) * 100; + return `
${s.stage}
${(s.avgDuration / 1000).toFixed(1)}s
`; - }).join('')} + }) + .join("")}

7-Day Trend

- ${trends.daily ? ` + ${ + trends.daily + ? ` @@ -420,17 +421,24 @@ function generateHtmlDashboard() { - ${trends.daily.slice(-7).map((d) => ` + ${trends.daily + .slice(-7) + .map( + (d) => ` - + - `).join('')} + `, + ) + .join("")}
${d.date} ${d.runs} ${(d.avgDuration / 1000).toFixed(1)}s${d.successRate}%= 70 ? "warning" : "error"}">${d.successRate}%
- ` : '
Not enough data for trends
'} + ` + : '
Not enough data for trends
' + }
@@ -493,7 +501,9 @@ function generateMarkdownDashboard() { ]; for (const s of summary.slowestStages) { - lines.push(`| ${s.stage} | ${(s.avgDuration / 1000).toFixed(1)}s | ${s.successRate.toFixed(0)}% |`); + lines.push( + `| ${s.stage} | ${(s.avgDuration / 1000).toFixed(1)}s | ${s.successRate.toFixed(0)}% |`, + ); } if (trends.daily && trends.daily.length > 0) { @@ -504,7 +514,9 @@ function generateMarkdownDashboard() { lines.push("|------|------|--------------|--------------|"); for (const d of trends.daily.slice(-7)) { - lines.push(`| ${d.date} | ${d.runs} | ${(d.avgDuration / 1000).toFixed(1)}s | ${d.successRate}% |`); + lines.push( + `| ${d.date} | ${d.runs} | ${(d.avgDuration / 1000).toFixed(1)}s | ${d.successRate}% |`, + ); } if (trends.trend) { @@ -639,7 +651,7 @@ switch (args.command) { console.log("─".repeat(50)); for (const d of trends.daily) { console.log( - `${d.date} ${String(d.runs).padStart(4)} ${((d.avgDuration / 1000).toFixed(1) + "s").padStart(12)} ${(d.successRate + "%").padStart(6)}` + `${d.date} ${String(d.runs).padStart(4)} ${((d.avgDuration / 1000).toFixed(1) + "s").padStart(12)} ${(d.successRate + "%").padStart(6)}`, ); } } @@ -681,7 +693,9 @@ switch (args.command) { const diff = comparison.durationDiff / 1000; const sign = diff > 0 ? "+" : ""; - console.log(`Difference: ${sign}${diff.toFixed(1)}s ${diff > 0 ? "(slower)" : diff < 0 ? "(faster)" : ""}`); + console.log( + `Difference: ${sign}${diff.toFixed(1)}s ${diff > 0 ? "(slower)" : diff < 0 ? "(faster)" : ""}`, + ); console.log(""); console.log("Stage Comparison:"); @@ -689,11 +703,15 @@ switch (args.command) { console.log("─".repeat(60)); for (const [stage, data] of Object.entries(comparison.stages)) { - const d1 = data.run1.duration != null ? (data.run1.duration / 1000).toFixed(1) + "s" : "N/A"; - const d2 = data.run2.duration != null ? (data.run2.duration / 1000).toFixed(1) + "s" : "N/A"; + const d1 = + data.run1.duration != null ? (data.run1.duration / 1000).toFixed(1) + "s" : "N/A"; + const d2 = + data.run2.duration != null ? (data.run2.duration / 1000).toFixed(1) + "s" : "N/A"; const stageDiff = (data.durationDiff / 1000).toFixed(1); const icon = data.improved ? "↓" : data.durationDiff > 0 ? "↑" : "="; - console.log(`${stage.padEnd(20)} ${d1.padStart(10)} ${d2.padStart(10)} ${icon} ${stageDiff}s`); + console.log( + `${stage.padEnd(20)} ${d1.padStart(10)} ${d2.padStart(10)} ${icon} ${stageDiff}s`, + ); } } break; diff --git a/scripts/pipeline-cache.js b/scripts/pipeline-cache.js index c430e5c..d268bde 100644 --- a/scripts/pipeline-cache.js +++ b/scripts/pipeline-cache.js @@ -169,7 +169,7 @@ function findFiles(patterns) { .replace(/\*/g, "[^/]*") .replace(/\./g, "\\.") .replace(/{{GLOBSTAR}}/g, ".*") + - "$" + "$", ); return regex.test(filepath); } @@ -346,10 +346,7 @@ function cleanCache(maxAgeDays = 7) { } // Also clean artifact directories - const artifactDirs = [ - join(CACHE_DIR, "artifacts"), - join(CACHE_DIR, "screenshots"), - ]; + const artifactDirs = [join(CACHE_DIR, "artifacts"), join(CACHE_DIR, "screenshots")]; for (const dir of artifactDirs) { if (existsSync(dir)) { @@ -507,7 +504,7 @@ function formatStatus(status) { status.metrics.totalBuilds > 0 ? ((status.metrics.cacheHits / status.metrics.totalBuilds) * 100).toFixed(1) : 0 - }%` + }%`, ); lines.push(` Time saved: ${(status.metrics.timeSaved / 1000).toFixed(1)}s`); @@ -517,7 +514,9 @@ function formatStatus(status) { for (const phase of status.phases.list) { const validMark = phase.valid ? "✓" : "✗"; const duration = phase.duration ? `${(phase.duration / 1000).toFixed(1)}s` : "N/A"; - lines.push(` ${validMark} ${phase.name.padEnd(20)} ${duration.padStart(8)} ${phase.result || ""}`); + lines.push( + ` ${validMark} ${phase.name.padEnd(20)} ${duration.padStart(8)} ${phase.result || ""}`, + ); } } @@ -560,7 +559,9 @@ switch (args.command) { process.exit(2); } const result = hashTarget(args.target, args.options.output); - console.log(args.options.json ? JSON.stringify(result, null, 2) : `Hash: ${result.hash || result.error}`); + console.log( + args.options.json ? JSON.stringify(result, null, 2) : `Hash: ${result.hash || result.error}`, + ); break; } @@ -586,6 +587,7 @@ switch (args.command) { } } process.exit(result.valid ? 0 : 1); + break; } case "update": { @@ -596,9 +598,7 @@ switch (args.command) { const duration = parseInt(args.options.duration || process.argv[4], 10) || 0; const result = updatePhaseCache(args.target, duration); console.log( - args.options.json - ? JSON.stringify(result, null, 2) - : `✓ Cache updated for ${args.target}` + args.options.json ? JSON.stringify(result, null, 2) : `✓ Cache updated for ${args.target}`, ); break; } @@ -614,9 +614,7 @@ switch (args.command) { } else { const success = invalidatePhase(args.target); console.log( - success - ? `✓ Cache invalidated for ${args.target}` - : `⚠ No cache found for ${args.target}` + success ? `✓ Cache invalidated for ${args.target}` : `⚠ No cache found for ${args.target}`, ); } break; diff --git a/scripts/stage-profiler.js b/scripts/stage-profiler.js index b525592..0826d54 100644 --- a/scripts/stage-profiler.js +++ b/scripts/stage-profiler.js @@ -17,14 +17,7 @@ * - Build performance reports */ -import { - readFileSync, - writeFileSync, - existsSync, - mkdirSync, - readdirSync, - statSync, -} from "fs"; +import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; import { execSync } from "child_process"; @@ -237,7 +230,7 @@ function completeRun(finalStatus = "complete") { Object.entries(run.stages).map(([name, data]) => [ name, { duration: data.duration, status: data.status }, - ]) + ]), ), }); @@ -340,12 +333,8 @@ function generateReport(format = "md") { for (const [name, data] of sortedStages) { const duration = data.duration ? (data.duration / 1000).toFixed(2) : "N/A"; const statusIcon = data.status === "pass" ? "✓" : data.status === "fail" ? "✗" : "⏳"; - const bar = data.duration - ? "█".repeat(Math.min(Math.ceil(data.duration / 5000), 20)) - : ""; - lines.push( - `${statusIcon} ${name.padEnd(maxNameLen)} ${duration.padStart(8)}s ${bar}` - ); + const bar = data.duration ? "█".repeat(Math.min(Math.ceil(data.duration / 5000), 20)) : ""; + lines.push(`${statusIcon} ${name.padEnd(maxNameLen)} ${duration.padStart(8)}s ${bar}`); } lines.push("─".repeat(60)); @@ -400,11 +389,9 @@ function analyzePerformance(slowThreshold = 30000) { const avg = durations.reduce((a, b) => a + b, 0) / durations.length; const min = Math.min(...durations); const max = Math.max(...durations); - const variance = - durations.reduce((sum, d) => sum + Math.pow(d - avg, 2), 0) / durations.length; + const variance = durations.reduce((sum, d) => sum + Math.pow(d - avg, 2), 0) / durations.length; const stdDev = Math.sqrt(variance); - const successRate = - (stats.successes / (stats.successes + stats.failures)) * 100; + const successRate = (stats.successes / (stats.successes + stats.failures)) * 100; analysis.stages[stage] = { avgDuration: Math.round(avg), @@ -443,20 +430,18 @@ function analyzePerformance(slowThreshold = 30000) { analysis.recommendations.push( `Found ${analysis.slowStages.length} slow stage(s): ${analysis.slowStages .map((s) => s.stage) - .join(", ")}` + .join(", ")}`, ); } if (analysis.unreliableStages.length > 0) { analysis.recommendations.push( - `Found ${analysis.unreliableStages.length} unreliable stage(s) needing attention` + `Found ${analysis.unreliableStages.length} unreliable stage(s) needing attention`, ); } // Overall trend - const recentDurations = recentRuns - .filter((r) => r.totalDuration) - .map((r) => r.totalDuration); + const recentDurations = recentRuns.filter((r) => r.totalDuration).map((r) => r.totalDuration); if (recentDurations.length >= 3) { const firstHalf = recentDurations.slice(0, Math.floor(recentDurations.length / 2)); const secondHalf = recentDurations.slice(Math.floor(recentDurations.length / 2)); @@ -465,11 +450,11 @@ function analyzePerformance(slowThreshold = 30000) { if (secondAvg > firstAvg * 1.2) { analysis.recommendations.push( - `Build times are trending up (+${((secondAvg / firstAvg - 1) * 100).toFixed(0)}%)` + `Build times are trending up (+${((secondAvg / firstAvg - 1) * 100).toFixed(0)}%)`, ); } else if (secondAvg < firstAvg * 0.8) { analysis.recommendations.push( - `Build times are improving (-${((1 - secondAvg / firstAvg) * 100).toFixed(0)}%)` + `Build times are improving (-${((1 - secondAvg / firstAvg) * 100).toFixed(0)}%)`, ); } } @@ -579,7 +564,9 @@ switch (args.command) { const date = run.timestamp.slice(0, 19).replace("T", " "); const duration = `${(run.totalDuration / 1000).toFixed(1)}s`; const status = run.status === "complete" ? "✓" : "✗"; - console.log(`${status} ${date} ${duration.padStart(8)} ${run.summary.passed}/${run.summary.stageCount} passed`); + console.log( + `${status} ${date} ${duration.padStart(8)} ${run.summary.passed}/${run.summary.stageCount} passed`, + ); } } break; @@ -627,7 +614,7 @@ switch (args.command) { ` ${stage.padEnd(20)} avg: ${(stats.avgDuration / 1000).toFixed(1)}s ` + `min: ${(stats.minDuration / 1000).toFixed(1)}s ` + `max: ${(stats.maxDuration / 1000).toFixed(1)}s ` + - `success: ${stats.successRate}%` + `success: ${stats.successRate}%`, ); } } diff --git a/scripts/visual-diff.js b/scripts/visual-diff.js index 0250094..170f53c 100644 --- a/scripts/visual-diff.js +++ b/scripts/visual-diff.js @@ -282,9 +282,7 @@ function analyzeTypography(actualData, expectedData, width, height, options = {} } function getTextBands(bands) { - return bands - .map((b, i) => ({ ...b, index: i })) - .filter((b) => b.darkRatio > 0.05); + return bands.map((b, i) => ({ ...b, index: i })).filter((b) => b.darkRatio > 0.05); } const actualBands = analyzeBands(actualData); @@ -392,7 +390,10 @@ function analyzeLayout(actualData, expectedData, width, height, options = {}) { const corr = count > 0 ? sum / count : 0; // Prefer smaller absolute offset when correlations are effectively equal const eps = bestCorr * 1e-9; - if (corr > bestCorr + eps || (Math.abs(corr - bestCorr) <= eps && Math.abs(shift) < Math.abs(bestOffset))) { + if ( + corr > bestCorr + eps || + (Math.abs(corr - bestCorr) <= eps && Math.abs(shift) < Math.abs(bestOffset)) + ) { bestCorr = corr; bestOffset = shift; } @@ -412,16 +413,8 @@ function analyzeLayout(actualData, expectedData, width, height, options = {}) { const MAX_SHIFT = Math.min(50, Math.floor(Math.min(width, height) * 0.1)); - const hResult = crossCorrelate( - actualProfiles.horizontal, - expectedProfiles.horizontal, - MAX_SHIFT - ); - const vResult = crossCorrelate( - actualProfiles.vertical, - expectedProfiles.vertical, - MAX_SHIFT - ); + const hResult = crossCorrelate(actualProfiles.horizontal, expectedProfiles.horizontal, MAX_SHIFT); + const vResult = crossCorrelate(actualProfiles.vertical, expectedProfiles.vertical, MAX_SHIFT); const dx = Math.abs(vResult.offset); const dy = Math.abs(hResult.offset); @@ -438,14 +431,18 @@ function analyzeLayout(actualData, expectedData, width, height, options = {}) { offset: hResult.offset, correlationImprovement: hResult.correlation > 0 - ? Math.round(((hResult.correlation - hResult.zeroCorrelation) / hResult.correlation) * 10000) / 10000 + ? Math.round( + ((hResult.correlation - hResult.zeroCorrelation) / hResult.correlation) * 10000, + ) / 10000 : 0, }, verticalProfile: { offset: vResult.offset, correlationImprovement: vResult.correlation > 0 - ? Math.round(((vResult.correlation - vResult.zeroCorrelation) / vResult.correlation) * 10000) / 10000 + ? Math.round( + ((vResult.correlation - vResult.zeroCorrelation) / vResult.correlation) * 10000, + ) / 10000 : 0, }, }; @@ -533,7 +530,8 @@ function compareSingle(actualPath, expectedPath, options) { // Save diff image if output specified const outputPath = - options.output || (options.outputDir ? join(options.outputDir, `diff-${basename(actualPath)}`) : null); + options.output || + (options.outputDir ? join(options.outputDir, `diff-${basename(actualPath)}`) : null); if (outputPath) { savePNG(outputPath, diff); @@ -543,7 +541,12 @@ function compareSingle(actualPath, expectedPath, options) { actual: resolve(actualPath), expected: resolve(expectedPath), diffImage: outputPath ? resolve(outputPath) : null, - dimensions: { width, height, actualSize: `${actual.width}x${actual.height}`, expectedSize: `${expected.width}x${expected.height}` }, + dimensions: { + width, + height, + actualSize: `${actual.width}x${actual.height}`, + expectedSize: `${expected.width}x${expected.height}`, + }, totalPixels, diffPixels: numDiffPixels, mismatchPct: Math.round(mismatchPct * 10000) / 10000, @@ -572,7 +575,9 @@ function compareBatch(actualDir, expectedDir, options) { if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true }); const actualFiles = readdirSync(actualDir).filter((f) => extname(f).toLowerCase() === ".png"); - const expectedFiles = new Set(readdirSync(expectedDir).filter((f) => extname(f).toLowerCase() === ".png")); + const expectedFiles = new Set( + readdirSync(expectedDir).filter((f) => extname(f).toLowerCase() === ".png"), + ); const results = []; let overallPass = true; @@ -608,7 +613,9 @@ function compareBatch(actualDir, expectedDir, options) { } } - const fontIssues = results.filter((r) => r.typographyAnalysis?.fontWeightMismatch || r.typographyAnalysis?.fontFallbackDetected); + const fontIssues = results.filter( + (r) => r.typographyAnalysis?.fontWeightMismatch || r.typographyAnalysis?.fontFallbackDetected, + ); const layoutIssues = results.filter((r) => r.layoutAnalysis?.layoutShiftDetected); const subPixelDominant = results.filter((r) => r.subPixelAnalysis?.subPixelPct > 0.5); @@ -642,7 +649,9 @@ function formatHumanReadable(result) { lines.push(`Expected: ${result.expectedDir}`); lines.push(`Diffs: ${result.outputDir}`); lines.push(""); - lines.push(`Total: ${result.totalFiles} | Pass: ${result.passed} | Fail: ${result.failed} | Skip: ${result.skipped}`); + lines.push( + `Total: ${result.totalFiles} | Pass: ${result.passed} | Fail: ${result.failed} | Skip: ${result.skipped}`, + ); lines.push(`Overall: ${result.overallPass ? "PASS" : "FAIL"}`); lines.push(""); @@ -662,7 +671,9 @@ function formatHumanReadable(result) { lines.push(` Font: fallback detected`); } if (r.layoutAnalysis?.layoutShiftDetected) { - lines.push(` Layout: shift dx=${r.layoutAnalysis.estimatedShift.dx}px dy=${r.layoutAnalysis.estimatedShift.dy}px`); + lines.push( + ` Layout: shift dx=${r.layoutAnalysis.estimatedShift.dx}px dy=${r.layoutAnalysis.estimatedShift.dy}px`, + ); } } } @@ -675,7 +686,9 @@ function formatHumanReadable(result) { lines.push(""); lines.push(`Dimensions: ${result.dimensions.width}x${result.dimensions.height}`); if (result.dimensions.actualSize !== result.dimensions.expectedSize) { - lines.push(` (actual: ${result.dimensions.actualSize}, expected: ${result.dimensions.expectedSize})`); + lines.push( + ` (actual: ${result.dimensions.actualSize}, expected: ${result.dimensions.expectedSize})`, + ); } lines.push(`Diff pixels: ${result.diffPixels} / ${result.totalPixels} (${pct}%)`); lines.push(`Threshold: ${(result.threshold * 100).toFixed(2)}%`); @@ -703,7 +716,9 @@ function formatHumanReadable(result) { const spa = result.subPixelAnalysis; lines.push(""); lines.push("Sub-Pixel Analysis:"); - lines.push(` Total diff clusters: ${spa.clusterCount} (${spa.subPixelClusters} sub-pixel, ${spa.realClusters} real)`); + lines.push( + ` Total diff clusters: ${spa.clusterCount} (${spa.subPixelClusters} sub-pixel, ${spa.realClusters} real)`, + ); lines.push(` Sub-pixel artifacts: ${(spa.subPixelPct * 100).toFixed(1)}% of diff pixels`); lines.push(` Real differences: ${(spa.realDiffPct * 100).toFixed(2)}% of image`); if (spa.subPixelPct > 0.5) { @@ -717,13 +732,19 @@ function formatHumanReadable(result) { lines.push(""); lines.push("Typography Analysis:"); if (ta.fontWeightMismatch) { - lines.push(` WARN Font weight mismatch detected (expected is ${ta.weightDirection}, delta: ${ta.avgWeightDifference})`); + lines.push( + ` WARN Font weight mismatch detected (expected is ${ta.weightDirection}, delta: ${ta.avgWeightDifference})`, + ); } if (ta.fontFallbackDetected) { - lines.push(` WARN Font fallback likely (character density diff: ${(ta.avgDensityDifference * 100).toFixed(1)}%)`); + lines.push( + ` WARN Font fallback likely (character density diff: ${(ta.avgDensityDifference * 100).toFixed(1)}%)`, + ); } if (ta.textBandCountMismatch) { - lines.push(` WARN Text line count differs (actual: ${ta.textBandsActual}, expected: ${ta.textBandsExpected})`); + lines.push( + ` WARN Text line count differs (actual: ${ta.textBandsActual}, expected: ${ta.textBandsExpected})`, + ); } if (!ta.fontWeightMismatch && !ta.fontFallbackDetected && !ta.textBandCountMismatch) { lines.push(" Typography consistent"); @@ -736,7 +757,9 @@ function formatHumanReadable(result) { lines.push(""); lines.push("Layout Analysis:"); if (la.layoutShiftDetected) { - lines.push(` WARN Layout shift detected: dx=${la.estimatedShift.dx}px, dy=${la.estimatedShift.dy}px (magnitude: ${la.shiftMagnitude}px)`); + lines.push( + ` WARN Layout shift detected: dx=${la.estimatedShift.dx}px, dy=${la.estimatedShift.dy}px (magnitude: ${la.shiftMagnitude}px)`, + ); } else { lines.push(" Layout consistent"); } @@ -757,7 +780,9 @@ if (!args.actual || !args.expected) { console.error("Options:"); console.error(" --output Output diff image path (single mode)"); console.error(" --output-dir Output directory for diff images (batch mode)"); - console.error(" --threshold <0.02> Max mismatch ratio to pass (default: from pipeline config)"); + console.error( + " --threshold <0.02> Max mismatch ratio to pass (default: from pipeline config)", + ); console.error(" --region-grid <4> Grid divisions for region analysis (default: 4)"); console.error(" --antialiasing Ignore antialiasing differences (default: true)"); console.error(" --json Output JSON instead of human-readable");