From 6dd16d21effbeab4ed8058dcc7b5bb177b6dd6d8 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 17 Aug 2026 21:21:50 -0700 Subject: [PATCH] CLI: turn the impeccable npm package into a platform-binary shim cli/engine, cli/lib, and cli/bin/commands are gone; their behavior lives in the engine binary. cli/bin/cli.js now resolves the binary from IMPECCABLE_BIN, the @impeccable/cli-- optional dependency (templates under cli/platform-packages/, published by the engine release), the ~/.impeccable/bin// cache, or a checksum-verified download, and execs it. package.json drops the engine dependencies and the library exports; puppeteer moves to devDependencies for the icon scripts. README.npm.md describes the shim. Prepared with AI assistance (Claude Code). --- README.npm.md | 36 +- bun.lock | 45 +- cli/bin/cli.js | 160 +- cli/bin/commands/ignores.mjs | 355 - cli/bin/commands/skills.mjs | 2415 ----- cli/engine/browser/injected/index.mjs | 2204 ---- cli/engine/cli/main.mjs | 436 - cli/engine/design-system.mjs | 1309 --- cli/engine/detect-antipatterns-browser.js | 9043 ----------------- cli/engine/detect-antipatterns.mjs | 51 - cli/engine/engines/browser/detect-url.mjs | 434 - cli/engine/engines/regex/detect-text.mjs | 1319 --- .../engines/static-html/css-cascade.mjs | 1241 --- .../engines/static-html/detect-html.mjs | 288 - .../engines/visual/screenshot-contrast.mjs | 189 - cli/engine/findings.mjs | 18 - cli/engine/node/file-system.mjs | 213 - cli/engine/profile/profiler.mjs | 166 - cli/engine/registry/antipatterns.mjs | 635 -- cli/engine/rules/checks.mjs | 5681 ----------- cli/engine/shared/color.mjs | 596 -- cli/engine/shared/constants.mjs | 127 - cli/engine/shared/fonts.mjs | 30 - cli/engine/shared/inline-ignores.mjs | 148 - cli/engine/shared/page.mjs | 7 - cli/lib/download-providers.js | 35 - cli/lib/impeccable-config.mjs | 640 -- cli/platform-packages/README.md | 15 + .../darwin-arm64/package.json | 17 + cli/platform-packages/darwin-x64/package.json | 17 + .../linux-arm64/package.json | 17 + cli/platform-packages/linux-x64/package.json | 17 + .../windows-x64/package.json | 17 + package.json | 22 +- 34 files changed, 197 insertions(+), 27746 deletions(-) delete mode 100644 cli/bin/commands/ignores.mjs delete mode 100644 cli/bin/commands/skills.mjs delete mode 100644 cli/engine/browser/injected/index.mjs delete mode 100644 cli/engine/cli/main.mjs delete mode 100644 cli/engine/design-system.mjs delete mode 100644 cli/engine/detect-antipatterns-browser.js delete mode 100644 cli/engine/detect-antipatterns.mjs delete mode 100644 cli/engine/engines/browser/detect-url.mjs delete mode 100644 cli/engine/engines/regex/detect-text.mjs delete mode 100644 cli/engine/engines/static-html/css-cascade.mjs delete mode 100644 cli/engine/engines/static-html/detect-html.mjs delete mode 100644 cli/engine/engines/visual/screenshot-contrast.mjs delete mode 100644 cli/engine/findings.mjs delete mode 100644 cli/engine/node/file-system.mjs delete mode 100644 cli/engine/profile/profiler.mjs delete mode 100644 cli/engine/registry/antipatterns.mjs delete mode 100644 cli/engine/rules/checks.mjs delete mode 100644 cli/engine/shared/color.mjs delete mode 100644 cli/engine/shared/constants.mjs delete mode 100644 cli/engine/shared/fonts.mjs delete mode 100644 cli/engine/shared/inline-ignores.mjs delete mode 100644 cli/engine/shared/page.mjs delete mode 100644 cli/lib/download-providers.js delete mode 100644 cli/lib/impeccable-config.mjs create mode 100644 cli/platform-packages/README.md create mode 100644 cli/platform-packages/darwin-arm64/package.json create mode 100644 cli/platform-packages/darwin-x64/package.json create mode 100644 cli/platform-packages/linux-arm64/package.json create mode 100644 cli/platform-packages/linux-x64/package.json create mode 100644 cli/platform-packages/windows-x64/package.json diff --git a/README.npm.md b/README.npm.md index 1e0284238..09e24252c 100644 --- a/README.npm.md +++ b/README.npm.md @@ -1,44 +1,45 @@ # Impeccable CLI -Detect UI anti-patterns and design quality issues from the command line. Scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 61 deterministic rules, including AI-generated UI tells, accessibility violations, and general design quality problems. +Detect UI anti-patterns and design quality issues from the command line, and install the Impeccable design skill into your AI coding harness. The detector scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 59 deterministic rules, including AI-generated UI tells, accessibility violations, and general design quality problems. + +The npm package is a small launcher. It runs the `impeccable` engine binary for your platform, installed alongside it as an optional dependency (`@impeccable/cli--`), and falls back to a per-user cache or a one-time download when that package is missing. ## Quick Start ```bash # Install skills into your AI harness (Claude, Cursor, Gemini, etc.) -npx impeccable skills install +npx impeccable install # Non-interactive install for a specific scope -npx impeccable skills install -y --providers=claude,codex --scope=project +npx impeccable install -y --providers=claude,codex --scope=project # First command to run inside your AI harness /impeccable init # Update skills to the latest version -npx impeccable skills update +npx impeccable update # Install or update skills without hook manifests -npx impeccable skills install --no-hooks +npx impeccable install --no-hooks # Link skills from a Git submodule checkout -npx impeccable skills link --source=.impeccable --providers=claude,cursor +npx impeccable link --source=.impeccable --providers=claude,cursor # List all available commands -npx impeccable skills help +npx impeccable help # Scan files or directories for anti-patterns npx impeccable detect src/ -# Scan a live URL (requires Puppeteer) +# Scan a live URL (uses an installed Chrome, Chromium, or Edge) npx impeccable detect https://example.com # JSON output for CI/tooling npx impeccable detect --json src/ - -# Deprecated compatibility flag; full scan still runs -npx impeccable detect --fast src/ ``` +`npx impeccable skills ` is the legacy namespace and still works. + ## What It Detects **AI Slop Tells**: patterns that scream "AI generated this": @@ -68,16 +69,17 @@ npx impeccable detect --fast src/ ``` impeccable detect [options] [file-or-dir-or-url...] - --fast Regex-only mode (skip jsdom, faster but less accurate) - --json Output findings as JSON - --help Show help + --json Output findings as JSON + --scope Only report rules in a design domain (type, layout) + --help Show help ``` ## Requirements -- Node.js 22.18+ -- `jsdom` (included as dependency, used for HTML scanning) -- `puppeteer` (optional, only needed for URL scanning) +- Node.js 22.18+ to run `npx impeccable`. The engine itself is a self-contained binary and needs no runtime; the skill installed into your harness calls it directly. +- For URL scans, an installed Chrome, Chromium, or Edge (set `IMPECCABLE_BROWSER` to point at one). + +Binary lookup order: `IMPECCABLE_BIN`, the platform package, `~/.impeccable/bin//`, then a download of the pinned version into that cache. Set `IMPECCABLE_BIN` to a local build to skip all of that. ## Part of Impeccable diff --git a/bun.lock b/bun.lock index 6d2390091..ac2a8bcbf 100644 --- a/bun.lock +++ b/bun.lock @@ -4,14 +4,6 @@ "workspaces": { "": { "name": "vibe-design-plugins", - "dependencies": { - "css-select": "^7.0.0", - "css-tree": "^3.2.1", - "domutils": "^4.0.2", - "fflate": "^0.8.3", - "htmlparser2": "^12.0.0", - "marked": "^18.0.5", - }, "devDependencies": { "@ai-sdk/anthropic": "^4.0.7", "@ai-sdk/google": "^4.0.8", @@ -22,11 +14,16 @@ "ai": "^7.0.14", "archiver": "^8.0.0", "playwright": "^1.59.1", + "puppeteer": "^25.1.0", "svelte": "^5", "zod": "^4.3.6", }, "optionalDependencies": { - "puppeteer": "^25.1.0", + "@impeccable/cli-darwin-arm64": "0.1.0", + "@impeccable/cli-darwin-x64": "0.1.0", + "@impeccable/cli-linux-arm64": "0.1.0", + "@impeccable/cli-linux-x64": "0.1.0", + "@impeccable/cli-windows-x64": "0.1.0", }, }, }, @@ -147,8 +144,6 @@ "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], - "boolbase": ["boolbase@2.0.0", "", {}, "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA=="], - "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], @@ -187,12 +182,6 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "css-select": ["css-select@7.0.0", "", { "dependencies": { "boolbase": "^2.0.0", "css-what": "^8.0.0", "domhandler": "^6.0.1", "domutils": "^4.0.2", "nth-check": "^3.0.1" } }, "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g=="], - - "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], - - "css-what": ["css-what@8.0.0", "", {}, "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], @@ -201,14 +190,6 @@ "devtools-protocol": ["devtools-protocol@0.0.1666840", "", {}, "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg=="], - "dom-serializer": ["dom-serializer@3.1.1", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="], - - "domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="], - - "domhandler": ["domhandler@6.0.1", "", { "dependencies": { "domelementtype": "^3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="], - - "domutils": ["domutils@4.0.2", "", { "dependencies": { "dom-serializer": "^3.0.0", "domelementtype": "^3.0.0", "domhandler": "^6.0.0" } }, "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA=="], - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], @@ -217,8 +198,6 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -257,8 +236,6 @@ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], - "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], @@ -285,8 +262,6 @@ "hono": ["hono@4.12.14", "", {}, "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w=="], - "htmlparser2": ["htmlparser2@12.0.0", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "domutils": "^4.0.2", "entities": "^8.0.0" } }, "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw=="], - "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], @@ -327,12 +302,8 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "marked": ["marked@18.0.11", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw=="], - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], - "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], @@ -353,8 +324,6 @@ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], - "nth-check": ["nth-check@3.0.1", "", { "dependencies": { "boolbase": "^2.0.0" } }, "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ=="], - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -421,8 +390,6 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], diff --git a/cli/bin/cli.js b/cli/bin/cli.js index 16459e4e4..8536385ee 100755 --- a/cli/bin/cli.js +++ b/cli/bin/cli.js @@ -1,102 +1,72 @@ #!/usr/bin/env node +// `impeccable` npm shim: finds the platform binary and execs it with argv. +// Order: $IMPECCABLE_BIN, the @impeccable/cli-- optional dependency, +// the version-pinned user cache (~/.impeccable/bin//), then a +// download into that cache from the public release channel. +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; -/** - * Impeccable CLI - * - * Usage: - * npx impeccable detect [file-or-dir-or-url...] - * npx impeccable ignores - * npx impeccable help|install|update - * npx impeccable --help - */ +const require = createRequire(import.meta.url); +const pkg = require('../../package.json'); +const OS = { darwin: 'darwin', linux: 'linux', win32: 'windows' }[process.platform] || process.platform; +const ARCH = { arm64: 'arm64', x64: 'x64' }[process.arch] || process.arch; +const TARGET = `${OS}-${ARCH}`; +const EXE = OS === 'windows' ? 'impeccable.exe' : 'impeccable'; +const PLATFORM_PKG = `@impeccable/cli-${TARGET}`; +// The engine version travels as the pinned optionalDependency range. +const VERSION = String(pkg.optionalDependencies?.[PLATFORM_PKG] || Object.values(pkg.optionalDependencies || {})[0] || '').replace(/^[^\d]*/, ''); +const CACHE_ROOT = process.env.IMPECCABLE_HOME || path.join(os.homedir(), '.impeccable'); +const CACHED = path.join(CACHE_ROOT, 'bin', VERSION, EXE); +const BASE = (process.env.IMPECCABLE_DOWNLOAD_BASE || 'https://github.com/renaissance-geek-inc/impeccable-dist/releases/download').replace(/\/$/, ''); +const URL = `${BASE}/v${VERSION}/impeccable-${TARGET}${OS === 'windows' ? '.exe' : ''}`; -import { readFileSync, existsSync } from 'node:fs'; -import { join, dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const SKILL_COMMANDS = new Set(['help', 'install', 'link', 'update', 'check']); - -// Is this a detect target (the `npx impeccable src/` shorthand) or a mistyped -// command? Flags, URLs, path-shaped args, and real files/dirs (e.g. an -// extension-less `Dockerfile`) are targets; anything else is an unknown command. -function looksLikeDetectTarget(arg) { - const isFlag = arg.startsWith('-'); - const isUrl = /^https?:\/\//i.test(arg); - const isPathShaped = arg.includes('/') || arg.includes('\\') || arg.includes('.'); - const isExistingPath = existsSync(resolve(arg)); - return isFlag || isUrl || isPathShaped || isExistingPath; +function exists(p) { try { return !!p && fs.statSync(p).isFile(); } catch { return false; } } +function fromPackage() { + try { return path.join(path.dirname(require.resolve(`${PLATFORM_PKG}/package.json`)), 'bin', EXE); } catch { return null; } +} +async function download() { + if (!VERSION) return null; + const res = await fetch(URL, { redirect: 'follow' }); + if (!res.ok) return null; + const buf = Buffer.from(await res.arrayBuffer()); + const sum = await fetch(`${URL}.sha256`, { redirect: 'follow' }).then(r => (r.ok ? r.text() : ''), () => ''); + const expected = sum.trim().split(/\s+/)[0]; + if (expected && createHash('sha256').update(buf).digest('hex') !== expected) { + throw new Error(`checksum mismatch downloading ${URL}`); + } + fs.mkdirSync(path.dirname(CACHED), { recursive: true }); + const tmp = `${CACHED}.part.${process.pid}`; + fs.writeFileSync(tmp, buf, { mode: 0o755 }); + fs.renameSync(tmp, CACHED); + return CACHED; +} +async function locate() { + const envBin = process.env.IMPECCABLE_BIN; + if (exists(envBin)) return envBin; + const fromPkg = fromPackage(); + if (exists(fromPkg)) return fromPkg; + if (exists(CACHED)) return CACHED; + return download().catch((err) => { process.stderr.write(`impeccable: ${err.message}\n`); return null; }); } -async function main() { - const args = process.argv.slice(2); - const command = args[0]; - - if (!command || command === '--help' || command === '-h') { - console.log(`Usage: impeccable [options] - -Commands: - detect [file-or-dir-or-url...] Scan for UI anti-patterns and design quality issues - ignores Manage detector ignore rules, files, and values - help List all available skills and commands - install Install impeccable skills into your project or global harness - link Symlink skills from a local checkout or submodule - update Update skills to the latest version - check Check if skill updates are available - -Options: - --help Show this help message - --version Show version number - -Compatibility: - impeccable skills Legacy namespace; still supported.`); - process.exit(0); - } - - if (command === '--version' || command === '-v') { - const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8')); - console.log(pkg.version); - process.exit(0); - } - - if (command === 'detect') { - process.argv = [process.argv[0], process.argv[1], ...args.slice(1)]; - const { detectCli } = await import('../engine/detect-antipatterns.mjs'); - await detectCli(); - } else if (command === 'ignores' || command === 'ignore') { - const { run } = await import('./commands/ignores.mjs'); - await run(args.slice(1)); - } else if (command === 'skills') { - const { run } = await import('./commands/skills.mjs'); - await run(args.slice(1)); - } else if (SKILL_COMMANDS.has(command)) { - const { run } = await import('./commands/skills.mjs'); - await run(args); - } else if (looksLikeDetectTarget(command)) { - // Default: treat as detect arguments (allow `npx impeccable src/` shorthand) - process.argv = [process.argv[0], process.argv[1], ...args]; - const { detectCli } = await import('../engine/detect-antipatterns.mjs'); - await detectCli(); - } else if (command === 'init') { - // The follow-up mistake from issue #472: `/impeccable init` belongs in an AI - // coding agent's chat, and a user who typed it into their shell is likely to - // retry it here as `npx impeccable init`. - console.error(`"init" is not a CLI command. Type /impeccable init in your AI coding agent's chat (Claude Code, Cursor, Codex, ...), not in this terminal.`); - process.exit(1); - } else { - // An unknown bareword: a mistyped command (or an old cached version run - // against newer docs). Fail loudly instead of silently statting it as a path. - console.error(`Unknown command: "${command}"\n\nTo see a list of supported commands, run:\n impeccable --help`); - process.exit(1); - } +const bin = await locate(); +if (!bin) { + process.stderr.write( + `impeccable: no binary for ${TARGET}. Install ${PLATFORM_PKG}@${VERSION}, set IMPECCABLE_BIN, ` + + `or download impeccable-${TARGET} v${VERSION} from ${BASE} into ${CACHED}.\n`, + ); + process.exit(127); } - -main().catch(error => { - if (error?.code === 'IMPECCABLE_PROMPT_ABORT') { - console.log('\nAborted.'); - process.exit(130); - } - - console.error(error?.message || error); - process.exit(1); +const result = spawnSync(bin, process.argv.slice(2), { + stdio: 'inherit', + env: { IMPECCABLE_SELF: 'npx impeccable', ...process.env }, }); +if (result.error) { + process.stderr.write(`impeccable: failed to run ${bin}: ${result.error.message}\n`); + process.exit(127); +} +process.exit(result.status === null ? 1 : result.status); diff --git a/cli/bin/commands/ignores.mjs b/cli/bin/commands/ignores.mjs deleted file mode 100644 index 5e0585dfc..000000000 --- a/cli/bin/commands/ignores.mjs +++ /dev/null @@ -1,355 +0,0 @@ -import path from 'node:path'; - -import { - getConfigPath, - getLocalConfigPath, - normalizeIgnoreValue, - readDetectionConfig, - readRawDetectionConfig, - writeDetectionConfig, - extractFindingIgnoreValue, -} from '../../lib/impeccable-config.mjs'; - -const ACTION_ALIASES = new Map([ - ['status', 'list'], - ['ls', 'list'], - ['list', 'list'], - ['add-rule', 'add-rule'], - ['ignore-rule', 'add-rule'], - ['add-file', 'add-file'], - ['ignore-file', 'add-file'], - ['add-value', 'add-value'], - ['ignore-value', 'add-value'], - ['update-value', 'add-value'], - ['remove-rule', 'remove-rule'], - ['rm-rule', 'remove-rule'], - ['remove-file', 'remove-file'], - ['rm-file', 'remove-file'], - ['remove-value', 'remove-value'], - ['rm-value', 'remove-value'], - ['clear', 'clear'], -]); - -function printUsage() { - console.log(`Usage: impeccable ignores [options] - -Manage detector ignores in .impeccable config. - -Actions: - list Show merged, shared, and local ignores - add-rule [--all-values] Ignore a rule - add-file Ignore files by glob - add-value Ignore one rule/value pair - remove-rule Remove a rule ignore - remove-file Remove a file ignore - remove-value Remove a rule/value ignore - clear Clear detector ignores in the selected scope - -Scope: - --shared Write .impeccable/config.json (default) - --local Write .impeccable/config.local.json - --all For remove/clear, apply to shared and local - -Value options: - --file Scope add-value/remove-value to a file glob - --reason Store or update a reason on add-value - -Examples: - impeccable ignores add-file "src/legacy/**" - impeccable ignores add-value overused-font Inter --reason "Brand font" - impeccable ignores add-value design-system-color "*" --file "src/demo.css" - impeccable ignores remove-value overused-font Inter`); -} - -function parseScope(args, { allowAll = false } = {}) { - const rest = []; - let local = false; - let shared = false; - let all = false; - for (const arg of args) { - if (arg === '--local') local = true; - else if (arg === '--shared') shared = true; - else if (arg === '--all') all = true; - else rest.push(arg); - } - if ([local, shared, all].filter(Boolean).length > 1) { - throw new Error(`Pass only one scope flag: --shared${allowAll ? ', --local, or --all' : ' or --local'}`); - } - if (all && !allowAll) throw new Error('--all is only supported for remove and clear actions'); - return { local, all, rest }; -} - -// An empty glob used to be dropped by filter(Boolean), so `--file=` reported -// success and wrote an entry with no files: the user asked to scope a rule to one -// file and silently got the project-wide suppression instead. Refuse it. -function requireGlob(raw, flag) { - const glob = String(raw ?? '').trim(); - if (!glob) throw new Error(`${flag} requires a non-empty glob`); - // A following flag is not a glob. `--file --reason "why"` consumed `--reason` - // as the scope and left the reason text to fold into the value, storing - // value="* why" files=["--reason"] and reporting success. Same silent-no-op - // class as an unknown flag folding into the value; refuse it the same way. - if (glob.startsWith('--')) throw new Error(`${flag} requires a glob, got the flag ${glob}`); - return glob; -} - -function parseValueArgs(args, { allowUnscopedWildcard = false } = {}) { - const positionals = []; - const files = []; - let reason = ''; - - for (let i = 0; i < args.length; i++) { - const arg = String(args[i] || ''); - if (arg === '--reason') { - const chunks = []; - while (i + 1 < args.length && !String(args[i + 1]).startsWith('--')) chunks.push(args[++i]); - reason = chunks.join(' ').trim(); - } else if (arg.startsWith('--reason=')) { - reason = arg.slice('--reason='.length).trim(); - } else if (arg === '--file' || arg === '--files') { - if (i + 1 >= args.length) throw new Error(`${arg} requires a glob`); - files.push(requireGlob(args[++i], arg)); - } else if (arg.startsWith('--file=')) { - files.push(requireGlob(arg.slice('--file='.length), '--file')); - } else if (arg.startsWith('--files=')) { - files.push(requireGlob(arg.slice('--files='.length), '--files')); - } else if (arg.startsWith('--')) { - throw new Error(`Unknown add-value flag: ${arg}`); - } else { - positionals.push(arg); - } - } - - const [rule, ...valueParts] = positionals; - const value = normalizeIgnoreValue(valueParts.join(' ')); - if (!rule || !value) throw new Error('Pass a rule id and value, e.g. impeccable ignores add-value overused-font Inter'); - // Sorted: the dedup key compares the files array, so an unsorted scope made - // `--file b.css --file a.css` a different entry from `--file a.css --file b.css`. - const scopedFiles = Array.from(new Set(files.filter(Boolean))).sort(); - if (value === '*' && scopedFiles.length === 0 && !allowUnscopedWildcard) { - throw new Error('Wildcard value ignores must be scoped with --file .'); - } - return { - rule: String(rule).trim().toLowerCase(), - value, - files: scopedFiles, - reason, - }; -} - -function formatValues(values) { - if (!values.length) return '(none)'; - return values - .map((entry) => { - const fileSuffix = Array.isArray(entry.files) && entry.files.length - ? ` [${entry.files.join(', ')}]` - : ''; - const reasonSuffix = entry.reason ? ` - ${entry.reason}` : ''; - return `${entry.rule}=${entry.value}${fileSuffix}${reasonSuffix}`; - }) - .join(', '); -} - -function formatConfig(label, config) { - return [ - `${label}:`, - ` ignoreRules: ${config.ignoreRules.length ? config.ignoreRules.join(', ') : '(none)'}`, - ` ignoreFiles: ${config.ignoreFiles.length ? config.ignoreFiles.join(', ') : '(none)'}`, - ` ignoreValues: ${formatValues(config.ignoreValues)}`, - ` designSystem: ${config.designSystem?.enabled === false ? 'disabled' : 'enabled'}`, - ].join('\n'); -} - -function list(cwd) { - const merged = readDetectionConfig(cwd); - const shared = readRawDetectionConfig(cwd); - const local = readRawDetectionConfig(cwd, { local: true }); - return [ - 'Impeccable detector ignores', - ` shared file: ${path.relative(cwd, getConfigPath(cwd)) || getConfigPath(cwd)}`, - ` local file: ${path.relative(cwd, getLocalConfigPath(cwd)) || getLocalConfigPath(cwd)}`, - '', - formatConfig('Merged', merged), - '', - formatConfig('Shared', shared), - '', - formatConfig('Local', local), - ].join('\n'); -} - -function readScopeConfig(cwd, local) { - return readRawDetectionConfig(cwd, { local }); -} - -function writeScopeConfig(cwd, config, local) { - return writeDetectionConfig(cwd, config, { local }); -} - -function parseRuleArgs(args) { - const positionals = []; - let allValues = false; - - for (let i = 0; i < args.length; i++) { - const arg = String(args[i] || ''); - if (arg === '--all-values') { - allValues = true; - } else if (arg === '--reason') { - while (i + 1 < args.length && !String(args[i + 1]).startsWith('--')) i++; - } else if (arg.startsWith('--reason=')) { - // Accepted for symmetry with add-value; ignoreRules stores ids only. - } else if (arg.startsWith('--')) { - throw new Error(`Unknown add-rule flag: ${arg}`); - } else { - positionals.push(arg); - } - } - - return { - rule: String(positionals[0] || '').trim().toLowerCase(), - allValues, - }; -} - -function addRule(cwd, args) { - const { local, rest } = parseScope(args); - const { rule, allValues } = parseRuleArgs(rest); - if (!rule) throw new Error('Pass a rule id, e.g. impeccable ignores add-rule side-tab'); - if (rule === 'overused-font' && !allValues) { - throw new Error('overused-font is value-specific by default. Use add-value overused-font , or add-rule overused-font --all-values for broad suppression.'); - } - const config = readScopeConfig(cwd, local); - if (!config.ignoreRules.includes(rule)) config.ignoreRules.push(rule); - const target = writeScopeConfig(cwd, config, local); - return `Added ${rule} to ${local ? 'local' : 'shared'} detector ignoreRules (${path.relative(cwd, target) || target}).`; -} - -function addFile(cwd, args) { - const { local, rest } = parseScope(args); - const glob = String(rest[0] || '').trim(); - if (!glob) throw new Error('Pass a glob, e.g. impeccable ignores add-file "src/legacy/**"'); - const config = readScopeConfig(cwd, local); - if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob); - const target = writeScopeConfig(cwd, config, local); - return `Added ${glob} to ${local ? 'local' : 'shared'} detector ignoreFiles (${path.relative(cwd, target) || target}).`; -} - -function addValue(cwd, args) { - const { local, rest } = parseScope(args); - const parsed = parseValueArgs(rest); - if (parsed.value !== '*' && !extractFindingIgnoreValue({ antipattern: parsed.rule, ignoreValue: parsed.value })) { - throw new Error(`${parsed.rule} has no extractable ignore value. Use impeccable ignores add-value ${parsed.rule} "*" --file to suppress it in matching files.`); - } - const config = readScopeConfig(cwd, local); - const key = ignoreValueKey(parsed); - const existing = config.ignoreValues.find((entry) => ignoreValueKey(entry) === key); - if (existing) { - if (parsed.reason) existing.reason = parsed.reason; - if (parsed.files.length) existing.files = parsed.files; - } else { - // rule, value, files, createdAt, reason — the same order the normalizers emit, - // so a fresh entry survives the next write untouched. - const entry = { - rule: parsed.rule, - value: parsed.value, - }; - if (parsed.files.length) entry.files = parsed.files; - entry.createdAt = new Date().toISOString(); - if (parsed.reason) entry.reason = parsed.reason; - config.ignoreValues.push(entry); - } - const target = writeScopeConfig(cwd, config, local); - return `Added ${parsed.rule}=${parsed.value} to ${local ? 'local' : 'shared'} detector ignoreValues (${path.relative(cwd, target) || target}).`; -} - -function removeFromScopes(cwd, args, remover) { - const { local, all, rest } = parseScope(args, { allowAll: true }); - const scopes = all ? [false, true] : [local]; - const removed = []; - for (const isLocal of scopes) { - const config = readScopeConfig(cwd, isLocal); - const count = remover(config, rest); - if (count > 0) { - const target = writeScopeConfig(cwd, config, isLocal); - removed.push(`${count} from ${isLocal ? 'local' : 'shared'} (${path.relative(cwd, target) || target})`); - } - } - return removed.length ? `Removed ${removed.join(', ')}.` : 'No matching detector ignore found.'; -} - -function removeRule(cwd, args) { - return removeFromScopes(cwd, args, (config, rest) => { - const rule = String(rest[0] || '').trim().toLowerCase(); - if (!rule) throw new Error('Pass a rule id, e.g. impeccable ignores remove-rule side-tab'); - const before = config.ignoreRules.length; - config.ignoreRules = config.ignoreRules.filter((entry) => entry !== rule); - return before - config.ignoreRules.length; - }); -} - -function removeFile(cwd, args) { - return removeFromScopes(cwd, args, (config, rest) => { - const glob = String(rest[0] || '').trim(); - if (!glob) throw new Error('Pass a glob, e.g. impeccable ignores remove-file "src/legacy/**"'); - const before = config.ignoreFiles.length; - config.ignoreFiles = config.ignoreFiles.filter((entry) => entry !== glob); - return before - config.ignoreFiles.length; - }); -} - -function removeValue(cwd, args) { - return removeFromScopes(cwd, args, (config, rest) => { - const parsed = parseValueArgs(rest, { allowUnscopedWildcard: true }); - const key = ignoreValueKey(parsed); - const before = config.ignoreValues.length; - config.ignoreValues = config.ignoreValues.filter((entry) => ignoreValueKey(entry) !== key); - return before - config.ignoreValues.length; - }); -} - -function clear(cwd, args) { - const { local, all, rest } = parseScope(args, { allowAll: true }); - if (rest.length > 0) throw new Error('clear does not take positional arguments'); - const scopes = all ? [false, true] : [local]; - for (const isLocal of scopes) { - const config = readScopeConfig(cwd, isLocal); - config.ignoreRules = []; - config.ignoreFiles = []; - config.ignoreValues = []; - writeScopeConfig(cwd, config, isLocal); - } - return `Cleared detector ignores in ${all ? 'shared and local config' : local ? 'local config' : 'shared config'}.`; -} - -function ignoreValueKey(entry) { - // Sorted: a file scope is a set. Comparing stored order made an on-disk scope - // miss the sorted argv form, so a re-add duplicated the entry and a remove - // silently failed. Every key that hashes `files` must sort — there are four. - const files = Array.isArray(entry.files) && entry.files.length ? [...entry.files].sort().join('\x1f') : ''; - return `${String(entry.rule || '').trim().toLowerCase()}\0${normalizeIgnoreValue(entry.value)}\0${files}`; -} - -export async function run(args = [], opts = {}) { - const cwd = opts.cwd || process.cwd(); - const actionArg = args[0] || 'list'; - if (actionArg === '--help' || actionArg === '-h') { - printUsage(); - return; - } - const action = ACTION_ALIASES.get(String(actionArg).toLowerCase()); - if (!action) { - throw new Error(`Unknown ignores action: ${actionArg}. Run "impeccable ignores --help".`); - } - const rest = args.slice(1); - let out; - switch (action) { - case 'list': out = list(cwd); break; - case 'add-rule': out = addRule(cwd, rest); break; - case 'add-file': out = addFile(cwd, rest); break; - case 'add-value': out = addValue(cwd, rest); break; - case 'remove-rule': out = removeRule(cwd, rest); break; - case 'remove-file': out = removeFile(cwd, rest); break; - case 'remove-value': out = removeValue(cwd, rest); break; - case 'clear': out = clear(cwd, rest); break; - } - if (out) console.log(out); -} diff --git a/cli/bin/commands/skills.mjs b/cli/bin/commands/skills.mjs deleted file mode 100644 index 36831153e..000000000 --- a/cli/bin/commands/skills.mjs +++ /dev/null @@ -1,2415 +0,0 @@ -/** - * `impeccable skills` subcommand - * - * Usage: - * impeccable help Show all available skills and commands - * impeccable install Install compiled skills from the universal bundle - * impeccable link Symlink compiled skills from a local checkout - * impeccable update Update skills to latest version - */ - -import { execSync } from 'node:child_process'; -import { existsSync, readFileSync, readdirSync, statSync, accessSync, constants, lstatSync, unlinkSync, mkdirSync, mkdtempSync, writeFileSync, rmSync, rmdirSync, renameSync, createWriteStream, realpathSync, symlinkSync, readlinkSync, cpSync } from 'node:fs'; -import { join, resolve, dirname, relative, isAbsolute, sep, delimiter } from 'node:path'; -import { createInterface, emitKeypressEvents } from 'node:readline'; -import { Readable } from 'node:stream'; -import { pipeline } from 'node:stream/promises'; -import { fileURLToPath } from 'node:url'; -import { createHash } from 'node:crypto'; -import { tmpdir, homedir } from 'node:os'; -import { unzipSync } from 'fflate'; -import { getHookConsent, setHookConsent } from '../../lib/impeccable-config.mjs'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const API_BASE = 'https://impeccable.style'; - -// Provider folder names in project roots -const PROVIDER_DIRS = ['.claude', '.cursor', '.gemini', '.agents', '.agent', '.github', '.grok', '.hermes', '.kiro', '.opencode', '.pi', '.qoder', '.trae', '.trae-cn', '.rovodev', '.vibe', '.veto']; -const PROVIDER_ALIASES = { - agent: '.agent', - agents: '.agents', - antigravity: '.agent', - claude: '.claude', - 'claude-code': '.claude', - codex: '.agents', - copilot: '.github', - cursor: '.cursor', - gemini: '.gemini', - github: '.github', - grok: '.grok', - 'grok-build': '.grok', - hermes: '.hermes', - xai: '.grok', - kiro: '.kiro', - opencode: '.opencode', - pi: '.pi', - qoder: '.qoder', - 'rovo-dev': '.rovodev', - rovodev: '.rovodev', - trae: '.trae', - 'trae-cn': '.trae-cn', - vibe: '.vibe', - veto: '.veto', -}; - -const PROVIDER_DISPLAY = { - '.agent': { name: 'Antigravity', input: 'antigravity' }, - '.agents': { name: 'Codex CLI', input: 'codex' }, - '.claude': { name: 'Claude Code', input: 'claude' }, - '.cursor': { name: 'Cursor', input: 'cursor' }, - '.gemini': { name: 'Gemini CLI', input: 'gemini' }, - '.github': { name: 'GitHub Copilot', input: 'github' }, - '.grok': { name: 'Grok Build', input: 'grok' }, - '.hermes': { name: 'Hermes Agent', input: 'hermes' }, - '.kiro': { name: 'Kiro', input: 'kiro' }, - '.opencode': { name: 'OpenCode', input: 'opencode' }, - '.pi': { name: 'Pi Coding Agent', input: 'pi' }, - '.qoder': { name: 'Qoder', input: 'qoder' }, - '.rovodev': { name: 'Rovo Dev', input: 'rovo-dev' }, - '.trae': { name: 'Trae', input: 'trae' }, - '.trae-cn': { name: 'Trae CN', input: 'trae-cn' }, - '.vibe': { name: 'Mistral Vibe', input: 'vibe' }, - '.veto': { name: 'Veto', input: 'veto' }, -}; -const PROVIDER_INPUT_ORDER = ['antigravity', 'claude', 'codex', 'cursor', 'gemini', 'github', 'grok', 'hermes', 'kiro', 'opencode', 'pi', 'qoder', 'trae', 'trae-cn', 'rovo-dev', 'vibe', 'veto']; - -// OpenCode reads global skills from its config directory, not ~/.opencode: -// $OPENCODE_CONFIG_DIR, else $XDG_CONFIG_HOME/opencode, else -// ~/.config/opencode. Writing to ~/.opencode/skills produced an install -// `opencode debug skill` never listed. See issue #406. -function opencodeGlobalConfigDir(home) { - if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR; - if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'opencode'); - return join(home, '.config', 'opencode'); -} - -// Hermes reads skills from `$HERMES_HOME/skills/`, where $HERMES_HOME defaults -// to `~/.hermes` but is also set to a profile path (e.g. -// `~/.hermes/profiles/forge`) when a non-default profile is active. Reading -// the env var matters here at install time: writing to `~/.hermes/skills/` -// from a profile-scoped Hermes invocation would land in the wrong profile -// (the same cross-profile data-corruption class that the active_profile -// fallback warning in hermes_constants.py exists to detect). Used by -// HOME_SKILLS_DIR_OVERRIDES['.hermes'] only; GLOBAL_HARNESS_HINTS reads the -// fixed `~/.hermes` location so detection doesn't leak the developer's real -// HERMES_HOME into test output (test isolation). -// -// Ignore $HERMES_HOME when it doesn't sit under `home` (the caller-supplied -// home dir, which tests inject via HOME=/tmp/...). Without this guard, an -// inherited $HERMES_HOME=/home//.hermes from the developer's shell leaks -// into test output even when the test sets HOME=/tmp/imp-home-xxx: tests -// expect ~/.hermes to live under their tmp home, not under the dev's real -// home. The check uses `resolve()` on both sides so a symlinked test home -// (e.g. /tmp -> /private/tmp on macOS) still compares correctly. -function hermesGlobalHome(home) { - const envHome = process.env.HERMES_HOME; - if (envHome) { - try { - const resolvedEnv = resolve(envHome); - const resolvedHome = resolve(home); - // Honor HERMES_HOME only when it lives under the active home (real - // ~/.hermes or ~/.hermes/profiles/). Cross-home inheritance is - // treated as not-set, so a test running under HOME=/tmp/... doesn't - // pick up the developer's real ~/.hermes. - if (resolvedEnv === resolvedHome || resolvedEnv.startsWith(resolvedHome + sep)) { - return resolvedEnv; - } - } catch { - // fall through to default - } - } - return join(home, '.hermes'); -} - -// Providers whose GLOBAL (home) skills dir is not `/skills`, -// as a function of the home dir. Pi discovers global skills from -// ~/.pi/agent/skills/ (issue #327); OpenCode from its config dir (issue -// #406); Hermes from $HERMES_HOME. Project scope stays `/skills` -// for all three. -const HOME_SKILLS_DIR_OVERRIDES = { - '.agent': (home) => join(home, '.gemini', 'config', 'skills'), - '.hermes': (home) => join(hermesGlobalHome(home), 'skills'), - '.pi': (home) => join(home, '.pi', 'agent', 'skills'), - '.opencode': (home) => join(opencodeGlobalConfigDir(home), 'skills'), -}; - -// When a project has no harness folder yet, infer the target from globally -// installed harnesses (~/.claude, ~/.codex, ...). Codex reads skills from -// .agents/skills, so ~/.codex maps to the .agents bundle variant. -// -// Hermes auto-detection uses the fixed `~/.hermes` location only. When a -// non-default Hermes profile is active (HERMES_HOME points to a profile path), -// the user is expected to be inside a Hermes invocation and can pass -// --providers=hermes explicitly. Auto-detection from a non-default HERMES_HOME -// would also defeat test isolation (tests inject HOME; HERMES_HOME leaks from -// the parent process and would surface the developer's real ~/.hermes in -// detection output). The install path honors $HERMES_HOME; detection does not. -const GLOBAL_HARNESS_HINTS = [ - { home: '.agent', provider: '.agent' }, - // Antigravity nests under ~/.gemini/ too, so any of these also trips the - // .gemini hint above (harmless double-detection — both get pre-selected). - { home: '.gemini/antigravity', provider: '.agent' }, - { home: '.gemini/antigravity-cli', provider: '.agent' }, - { home: '.gemini/antigravity-ide', provider: '.agent' }, - { home: '.claude', provider: '.claude' }, - { home: '.codex', provider: '.agents' }, - { home: '.cursor', provider: '.cursor' }, - { home: '.gemini', provider: '.gemini' }, - { home: '.grok', provider: '.grok' }, - { home: '.hermes', provider: '.hermes' }, - { home: '.kiro', provider: '.kiro' }, - { home: '.opencode', provider: '.opencode' }, - // OpenCode's real global config dir (issue #406); the ~/.opencode entry - // above keeps recognizing machines that only have the legacy dir. - { resolve: opencodeGlobalConfigDir, provider: '.opencode' }, - { home: '.pi', provider: '.pi' }, - { home: '.qoder', provider: '.qoder' }, - { home: '.rovodev', provider: '.rovodev' }, - { home: '.vibe', provider: '.vibe' }, - // Veto is a CLI harness whose managed skill directory is ~/.veto/skills. - // Require its managed state directory as well as the executable so an - // unrelated `veto` binary on PATH does not change project install defaults. - { command: 'veto', provider: '.veto' }, -]; - -// Last-resort default when nothing is detected: Claude Code + the universal -// (.agents, also Codex) folder, which covers the most common setups. -const DEFAULT_TARGETS = ['.claude', '.agents']; -const IGNORED_SKILL_DIR_NAMES = new Set([ - 'codex-primary-runtime', -]); -const IMPECCABLE_HOOK_COMMAND_MARKERS = [ - 'skills/impeccable/scripts/hook-probe.mjs', - 'skills/impeccable/scripts/hook.mjs', - 'skills/impeccable/scripts/hook-before-edit.mjs', - 'skills/impeccable/scripts/hook-after-edit.mjs', - 'skills/impeccable/scripts/hook-stop.mjs', -]; -const PROVIDER_HOOK_ARTIFACTS = { - '.claude': [ - // The hook is a machine-local install side effect, so it lands in the - // gitignored `.claude/settings.local.json` rather than the team-shared - // `settings.json`. The bundle still ships the manifest as `settings.json` - // (the `rel` source), but we write it to `destRel`. A hook the user moved - // into `settings.json` is honored in place; see copyProviderHooks. - { sourceProvider: '.claude', rel: 'settings.json', destProvider: '.claude', destRel: 'settings.local.json' }, - ], - '.cursor': [ - { sourceProvider: '.cursor', rel: 'hooks.json', destProvider: '.cursor' }, - ], - // Codex reads skills from `.agents/skills`, but project hooks from - // `.codex/hooks.json`, so the `.agents` install target owns this sidecar. - '.agents': [ - { sourceProvider: '.codex', rel: 'hooks.json', destProvider: '.codex' }, - ], - // GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. Unlike - // Claude, this is a team-shared, committed file (not a machine-local override), - // so source and dest are the same path. - '.github': [ - { sourceProvider: '.github', rel: 'hooks/impeccable.json', destProvider: '.github' }, - ], - // Grok Build discovers project hooks from `.grok/hooks/*.json`. Team-shared - // by default (commit them if the whole team uses Grok); folder trust is still - // required via `/hooks-trust` or `--trust` before they run. - '.grok': [ - { sourceProvider: '.grok', rel: 'hooks/impeccable.json', destProvider: '.grok' }, - ], -}; - -function userProviderSkillsDir(home, provider) { - const override = HOME_SKILLS_DIR_OVERRIDES[provider]; - if (override) return override(home); - return join(home, provider, 'skills'); -} - -// Compare via realpath: the project root comes from process.cwd() (symlinks -// resolved) while homedir() reflects $HOME verbatim, so a home dir reached -// through a symlink (e.g. /tmp -> /private/tmp) would fail a string compare. -function isHomeDir(root) { - if (root === homedir()) return true; - try { - return realpathSync(root) === realpathSync(homedir()); - } catch { - return false; - } -} - -// Every layout a provider's installed skills can live in under `root`. -// `scope` narrows the answer when the caller knows which install it is -// acting on: 'user' means the provider's global layout, 'project' means -// `/skills`. Without a scope (update/check, where installs of -// either kind may live under `root`) both layouts are candidates when -// `root` is the home dir, since an overridden provider (Pi) keeps its -// global skills elsewhere while a repo rooted at ~ still uses the project -// layout. Scoping matters for the same reason: a project-scope install in -// a home-rooted repo must not be conflated with an existing global one. -function providerSkillsDirCandidates(root, provider, scope) { - if (scope === 'user') return [userProviderSkillsDir(root, provider)]; - const dirs = [join(root, provider, 'skills')]; - if (scope !== 'project' && HOME_SKILLS_DIR_OVERRIDES[provider] && isHomeDir(root)) { - dirs.unshift(userProviderSkillsDir(root, provider)); - } - return dirs; -} - -function existingSkillsDirs(root, provider, scope) { - return providerSkillsDirCandidates(root, provider, scope).filter(existsSync); -} - -let pipedAnswers = null; -class PromptAbortError extends Error { - constructor() { - super('Aborted.'); - this.name = 'PromptAbortError'; - this.code = 'IMPECCABLE_PROMPT_ABORT'; - } -} - -function isPromptAbortError(error) { - return error?.code === 'IMPECCABLE_PROMPT_ABORT'; -} - -function canStyleTerminal() { - return Boolean(process.stdout.isTTY && process.env.NO_COLOR === undefined && process.env.TERM !== 'dumb'); -} - -function ansi(open, close, value) { - const text = String(value); - return canStyleTerminal() ? `${open}${text}${close}` : text; -} - -const ui = { - accent: value => ansi('\x1b[36m', '\x1b[0m', value), - bold: value => ansi('\x1b[1m', '\x1b[22m', value), - dim: value => ansi('\x1b[2m', '\x1b[22m', value), - good: value => ansi('\x1b[32m', '\x1b[0m', value), -}; - -function ask(question) { - if (!process.stdin.isTTY) { - process.stdout.write(question); - if (!pipedAnswers) { - let input = ''; - try { - input = readFileSync(0, 'utf-8'); - } catch {} - pipedAnswers = input.split(/\r?\n/); - } - return Promise.resolve(String(pipedAnswers.shift() || '').trim().toLowerCase()); - } - - const rl = createInterface({ input: process.stdin, output: process.stdout }); - return new Promise((resolve, reject) => { - rl.once('SIGINT', () => { - rl.close(); - reject(new PromptAbortError()); - }); - rl.question(question, ans => { - rl.close(); - resolve(ans.trim().toLowerCase()); - }); - }); -} - -function isInteractivePrompt() { - return Boolean(process.stdin.isTTY && process.stdout.isTTY && typeof process.stdin.setRawMode === 'function'); -} - -function promptKeypressSession(renderInitial, handleKey) { - const input = process.stdin; - const output = process.stdout; - const wasRaw = Boolean(input.isRaw); - let lastLineCount = 0; - let done = false; - - emitKeypressEvents(input); - - return new Promise((resolve, reject) => { - function cleanup() { - if (done) return; - done = true; - input.off('keypress', onKeypress); - if (typeof input.setRawMode === 'function') input.setRawMode(wasRaw); - output.write('\x1b[?25h'); - input.pause(); - } - - function render(lines) { - const nextLines = Array.isArray(lines) ? lines : String(lines).split('\n'); - if (lastLineCount > 0) output.write(`\x1b[${lastLineCount}A`); - const lineCount = Math.max(lastLineCount, nextLines.length); - for (let index = 0; index < lineCount; index++) { - const line = nextLines[index] || ''; - output.write(`\x1b[2K\r${line}\n`); - } - lastLineCount = lineCount; - } - - function finish(value) { - cleanup(); - resolve(value); - } - - function abort() { - cleanup(); - reject(new PromptAbortError()); - } - - function onKeypress(str, key = {}) { - if (key.ctrl && key.name === 'c') { - abort(); - return; - } - const next = handleKey(str, key); - if (!next) return; - if (next.abort) { - abort(); - return; - } - if (next.done) { - render(next.lines); - finish(next.value); - return; - } - render(next.lines); - } - - input.on('keypress', onKeypress); - input.setRawMode(true); - input.resume(); - output.write('\x1b[?25l'); - render(renderInitial()); - }); -} - -function clampIndex(index, length) { - if (length <= 0) return 0; - if (index < 0) return length - 1; - if (index >= length) return 0; - return index; -} - -function visibleWindow(cursor, total, maxVisible) { - const visible = Math.max(1, Math.min(total, maxVisible)); - let start = Math.max(0, cursor - visible + 1); - if (cursor < start) start = cursor; - start = Math.min(start, Math.max(0, total - visible)); - return { start, end: start + visible }; -} - -async function promptRadio(message, options, { initialIndex = 0 } = {}) { - let cursor = clampIndex(initialIndex, options.length); - - const render = () => [ - `${ui.accent('◆')} ${ui.bold(message)}`, - '', - ...options.map((option, index) => { - const active = index === cursor; - const pointer = active ? ui.accent('›') : ' '; - const mark = active ? ui.good('●') : ui.dim('○'); - const label = active ? ui.bold(option.label) : option.label; - const hint = option.hint ? ` ${ui.dim(option.hint)}` : ''; - return ` ${pointer} ${mark} ${label}${hint}`; - }), - '', - ` ${ui.dim('↑/↓ move, enter confirm')}`, - ]; - - return promptKeypressSession(render, (_str, key = {}) => { - if (key.name === 'up' || key.name === 'k') cursor = clampIndex(cursor - 1, options.length); - if (key.name === 'down' || key.name === 'j') cursor = clampIndex(cursor + 1, options.length); - if (key.name === 'return' || key.name === 'enter') { - return { done: true, value: options[cursor].value, lines: render() }; - } - return { lines: render() }; - }); -} - -async function promptCheckbox(message, options, { selectedValues = [] } = {}) { - const selected = new Set(selectedValues); - let cursor = 0; - let error = ''; - let query = ''; - const maxVisible = Math.max(5, Math.min(options.length, (process.stdout.rows || 24) - 9, 10)); - - function filteredOptions() { - const needle = query.trim().toLowerCase(); - if (!needle) return options; - return options.filter(option => option.searchText.toLowerCase().includes(needle)); - } - - function selectedSummary() { - const selectedOptions = options.filter(option => selected.has(option.value)); - if (selectedOptions.length === 0) return ui.dim('none'); - const labels = selectedOptions.map(option => option.label); - if (labels.length <= 4) return labels.join(', '); - return `${labels.slice(0, 4).join(', ')} ${ui.dim(`+${labels.length - 4} more`)}`; - } - - const render = () => { - const filtered = filteredOptions(); - cursor = clampIndex(cursor, filtered.length); - const { start, end } = visibleWindow(cursor, filtered.length, maxVisible); - const lines = [ - `${ui.accent('◆')} ${ui.bold(message)}`, - '', - ` Search: ${query || ui.dim('type to filter')}`, - ` ${ui.dim('↑/↓ move, space select, enter confirm')}`, - '', - ]; - if (filtered.length === 0) { - lines.push(` ${ui.dim('No matches')}`); - } else if (filtered.length > maxVisible) { - lines.push(` ${ui.dim(`Showing ${start + 1}-${end} of ${filtered.length}`)}`); - } - - if (filtered.length > 0) { - for (let index = start; index < end; index++) { - const option = filtered[index]; - const active = index === cursor; - const pointer = active ? ui.accent('›') : ' '; - const mark = selected.has(option.value) ? ui.good('●') : ui.dim('○'); - const label = active ? ui.bold(option.label) : option.label; - const hint = option.hint ? ` ${ui.dim(option.hint)}` : ''; - lines.push(` ${pointer} ${mark} ${label}${hint}`); - } - } - - lines.push(''); - lines.push(` Selected: ${selectedSummary()}`); - if (error) lines.push(` ${error}`); - return lines; - }; - - return promptKeypressSession(render, (str, key = {}) => { - const filtered = filteredOptions(); - if (key.name === 'up') cursor = clampIndex(cursor - 1, filtered.length); - if (key.name === 'down') cursor = clampIndex(cursor + 1, filtered.length); - if (key.name === 'space' || str === ' ') { - const option = filtered[cursor]; - if (option) { - if (selected.has(option.value)) selected.delete(option.value); - else selected.add(option.value); - error = ''; - } - } - if (key.name === 'backspace' || key.name === 'delete') { - query = query.slice(0, -1); - cursor = 0; - error = ''; - } - if (key.ctrl && key.name === 'u') { - query = ''; - cursor = 0; - error = ''; - } - if (str && str.length === 1 && str >= '!' && !key.ctrl && !key.meta) { - query += str; - cursor = 0; - error = ''; - } - if (key.name === 'return' || key.name === 'enter') { - if (selected.size === 0) { - error = ui.dim('Choose at least one harness.'); - return { lines: render() }; - } - return { - done: true, - value: options.filter(option => selected.has(option.value)).map(option => option.value), - lines: render(), - }; - } - return { lines: render() }; - }); -} - -// ─── skills help ────────────────────────────────────────────────────────────── - -async function showHelp() { - let commands; - try { - const res = await fetch(`${API_BASE}/api/commands`); - commands = await res.json(); - } catch { - console.error('Could not fetch command list from impeccable.style. Check your network connection.'); - process.exit(1); - } - - const pad = (s, n) => s + ' '.repeat(Math.max(0, n - s.length)); - - console.log('\n Impeccable Skills & Commands\n'); - console.log(' Install: npx impeccable install'); - console.log(' Link: npx impeccable link --source=.impeccable'); - console.log(' Update: npx impeccable update'); - console.log(' Docs: https://impeccable.style/cheatsheet\n'); - console.log(` ${pad('Command', 22)} Description`); - console.log(` ${'-'.repeat(22)} ${'-'.repeat(52)}`); - - for (const cmd of commands.sort((a, b) => a.id.localeCompare(b.id))) { - // Trim description to fit terminal - const desc = cmd.description.length > 72 - ? cmd.description.substring(0, 69) + '...' - : cmd.description; - console.log(` ${pad('/' + cmd.id, 22)} ${desc}`); - } - console.log(`\n ${commands.length} commands available. Run / in your AI harness.\n`); -} - -// ─── version helpers ───────────────────────────────────────────────────────── - -/** - * Read the skills version from the impeccable SKILL.md frontmatter. - */ -function getSkillsVersion(root, scope) { - for (const d of PROVIDER_DIRS) { - for (const skillsDir of providerSkillsDirCandidates(root, d, scope)) { - const skillMd = join(skillsDir, 'impeccable', 'SKILL.md'); - if (!existsSync(skillMd)) continue; - const content = readFileSync(skillMd, 'utf-8'); - const match = content.match(/^version:\s*(.+)$/m); - if (match) return match[1].trim().replace(/^["']|["']$/g, ''); - } - } - return null; -} - -/** - * Return every file in a directory tree, sorted and relative to the tree root. - */ -function listSkillTreeFiles(root, dir = root) { - if (!existsSync(dir)) return []; - const files = []; - for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { - const full = join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...listSkillTreeFiles(root, full)); - } else if (entry.isFile()) { - files.push(relative(root, full).split(sep).join('/')); - } - } - return files; -} - -/** - * Extract every entry of a zip archive into `targetDir`. - * - * This replaces `extract-zip`, whose `yauzl`/`fd-slicer` read stack stalls on - * Node v24.16.0 / v26.1.0+ (nodejs/node#63487): `pause()`/`resume()` became - * no-ops on destroyed streams, so extraction stops after a handful of entries, - * its promise never settles, and -- because nothing else keeps the event loop - * alive -- the CLI exits 0 with no error, silently installing nothing. - * - * `fflate` decompresses from an in-memory buffer and never touches the fs - * stream path, so it is immune to that regression on every Node version. It is - * pure JS with zero dependencies, which keeps the Windows fix from #198 intact - * (no `unzip` binary required). We write the entries to disk ourselves, which - * lets us guard against zip-slip (`../` entries escaping `targetDir`). - */ -async function extractZip(zipPath, targetDir) { - const entries = unzipSync(readFileSync(zipPath)); - const root = resolve(targetDir); - for (const [entryPath, bytes] of Object.entries(entries)) { - // Directory entries arrive as zero-length names ending in `/`; the files - // beneath them create their parents via mkdirSync below. - if (entryPath.endsWith('/')) continue; - const dest = resolve(root, entryPath); - if (dest !== root && !dest.startsWith(root + sep)) { - throw new Error(`Refusing to extract entry outside target dir: ${entryPath}`); - } - mkdirSync(dirname(dest), { recursive: true }); - writeFileSync(dest, bytes); - } -} - -/** - * Download the universal bundle to a temp dir and return its path. - * Caller is responsible for cleanup. - */ -async function downloadAndExtractBundle() { - const localBundle = process.env.IMPECCABLE_BUNDLE_PATH; - if (localBundle) return copyOrExtractLocalBundle(localBundle); - - const staging = mkdtempSync(join(tmpdir(), 'impeccable-update-')); - const tmpZip = join(staging, 'bundle.zip'); - try { - await downloadFile(`${API_BASE}/api/download/bundle/universal`, tmpZip); - await extractZip(tmpZip, staging); - rmSync(tmpZip, { force: true }); - return staging; - } catch (e) { - rmSync(staging, { recursive: true, force: true }); - throw e; - } -} - -async function copyOrExtractLocalBundle(sourceValue) { - const source = resolve(sourceValue); - if (!existsSync(source)) { - throw new Error(`Local bundle not found: ${source}`); - } - - const staging = mkdtempSync(join(tmpdir(), 'impeccable-local-bundle-')); - try { - if (statSync(source).isDirectory()) { - cpSync(source, staging, { recursive: true }); - } else { - await extractZip(source, staging); - } - return staging; - } catch (e) { - rmSync(staging, { recursive: true, force: true }); - throw e; - } -} - -/** - * Normalize a SKILL.md's content for comparison by stripping - * provider-specific paths. Different install methods (npx skills add - * vs our bundle) resolve {{scripts_path}} to different provider dirs - * (e.g. .agents vs .claude), so we strip those differences. - * Version fields intentionally remain part of the comparison so metadata-only - * releases still refresh installed files. - */ -function normalizeForHash(content) { - return content - .replace(/\.(claude|cursor|agents|agent|github|gemini|codex|grok|hermes|kiro|opencode|pi|qoder|trae|trae-cn|rovodev|vibe|veto)\/skills\//g, '.PROVIDER/skills/'); -} - -function hashSkillFile(filePath) { - return createHash('sha256') - .update(normalizeForHash(readFileSync(filePath, 'utf-8'))) - .digest('hex'); -} - -/** - * Deduplicate providers by resolved path. When .claude/skills is a - * symlink to ../.agents/skills, both resolve to the same directory. - * Returns an array of { provider, localSkillsDir } with one entry - * per unique real path. The first provider that maps to a real path - * wins (so the bundle uses that provider's build). - */ -function deduplicateProviders(root, providers, scope) { - const seen = new Map(); // realPath -> { provider, localSkillsDir } - for (const provider of providers) { - // A provider can hold real installs in more than one layout (a home-rooted - // repo may carry both ~/.pi/agent/skills and ~/.pi/skills). Keep each as - // its own entry so update/check touch every tree, not just the first. - for (const skillsDir of existingSkillsDirs(root, provider, scope)) { - const real = realpathSync(skillsDir); - if (!seen.has(real)) { - seen.set(real, { provider, localSkillsDir: skillsDir }); - } - } - } - return [...seen.values()]; -} - -/** - * Compare local skills against a downloaded bundle. - * Only checks skills that exist in the bundle (ignores user's custom skills - * that aren't part of impeccable). Deduplicates providers that share the same - * real path (symlinks). Compares the full bundled skill tree, not just - * SKILL.md, so script-only fixes and removed files are detected. - * Returns true if every bundle skill matches the local copy. - */ -function isUpToDate(root, providers, bundleDir, scope, agentScope = scope) { - const unique = deduplicateProviders(root, providers, scope); - if (unique.length === 0) return false; - - for (const { provider, localSkillsDir } of unique) { - const bundleSkillsDir = join(bundleDir, provider, 'skills'); - if (!existsSync(bundleSkillsDir)) continue; - - for (const name of readdirSync(bundleSkillsDir)) { - const bundleSkillDir = join(bundleSkillsDir, name); - const localSkillDir = join(localSkillsDir, name); - const bundleMd = join(bundleSkillDir, 'SKILL.md'); - if (!existsSync(bundleMd)) continue; - if (!existsSync(localSkillDir)) return false; - - const bundleFiles = listSkillTreeFiles(bundleSkillDir); - const localFiles = listSkillTreeFiles(localSkillDir); - if (bundleFiles.join('\n') !== localFiles.join('\n')) return false; - - for (const relPath of bundleFiles) { - const bundleHash = hashSkillFile(join(bundleSkillDir, ...relPath.split('/'))); - const localHash = hashSkillFile(join(localSkillDir, ...relPath.split('/'))); - if (bundleHash !== localHash) return false; - } - } - - if (!providerAgentsUpToDate(bundleDir, root, provider, agentScope)) return false; - } - return true; -} - -// ─── skills check ──────────────────────────────────────────────────────────── - -async function check() { - const root = findProjectRoot(); - const installed = isAlreadyInstalled(root); - - if (!installed) { - console.log('Impeccable is not installed in this project.'); - console.log('Run `npx impeccable install` to install.'); - process.exit(0); - } - - const providers = findInstalledProviders(root); - - console.log('Checking for updates...\n'); - try { - const bundleDir = await downloadAndExtractBundle(); - const agentScope = isHomeDir(root) ? 'user' : undefined; - const upToDate = isUpToDate(root, providers, bundleDir, undefined, agentScope); - rmSync(bundleDir, { recursive: true, force: true }); - - if (upToDate) { - const v = getSkillsVersion(root); - console.log(`Skills are up to date${v ? ` (v${v})` : ''}.`); - } else { - console.log('Updates available.'); - console.log('Run `npx impeccable update` to update.'); - } - } catch (e) { - console.error(`Could not check for updates: ${e.message}`); - process.exit(1); - } -} - -// ─── skills install ─────────────────────────────────────────────────────────── - -// Check if impeccable skills are already present in any provider folder -function isAlreadyInstalled(root, scope) { - for (const d of PROVIDER_DIRS) { - for (const skillsDir of existingSkillsDirs(root, d, scope)) { - try { - const entries = readdirSync(skillsDir); - // Look for 'impeccable' skill (or prefixed variant, or legacy 'teach-impeccable') - if (entries.some(e => - e === 'impeccable' || e.endsWith('-impeccable') || - e === 'teach-impeccable' || e.endsWith('-teach-impeccable') - )) { - return d; - } - } catch {} - } - } - return null; -} - -function isSkillDir(skillsDir, name) { - // Skill entries can be real directories or symlinks to directories (npx skills uses symlinks) - const full = join(skillsDir, name); - try { - return statSync(full).isDirectory() && existsSync(join(full, 'SKILL.md')); - } catch { return false; } -} - -function hasRealSkillEntries(skillsDir) { - if (!existsSync(skillsDir)) return false; - let entries; - try { entries = readdirSync(skillsDir); } catch { return false; } - return entries.some(name => - !name.startsWith('.') && - !IGNORED_SKILL_DIR_NAMES.has(name) && - isSkillDir(skillsDir, name) - ); -} - -function isRealSkillDir(skillsDir, name) { - // Only real directories, not symlinks -- renaming the real dir renames the symlink targets too - const full = join(skillsDir, name); - try { - const lstat = lstatSync(full); - return lstat.isDirectory() && !lstat.isSymbolicLink() && existsSync(join(full, 'SKILL.md')); - } catch { return false; } -} - -/** - * One-way migration for installs from the era when the CLI offered a command - * prefix (default `i-`), renaming the skill to e.g. `i-impeccable`. The prefix - * only earned its keep when every command was its own skill; with a single - * `impeccable` skill it does nothing, so it is no longer offered. Rename any - * prefixed impeccable skill back to the canonical `impeccable` (the fresh - * install/update content lands there next) so users aren't left with a stale, - * orphaned `i-impeccable` alongside the new one. Scoped to the impeccable skill - * by name -- never touches third-party skills that happen to start with `i-`. - * Returns the number of skills migrated. - */ -function migrateUnprefixImpeccable(root, scope) { - let migrated = 0; - for (const d of PROVIDER_DIRS) { - for (const skillsDir of existingSkillsDirs(root, d, scope)) { - let entries; - try { entries = readdirSync(skillsDir); } catch { continue; } - for (const name of entries) { - // A prefixed impeccable skill is `impeccable`, not the canonical - // `impeccable` and not an unrelated legacy skill name. - if (name === 'impeccable' || name === 'teach-impeccable') continue; - if (!name.endsWith('-impeccable')) continue; - if (!isRealSkillDir(skillsDir, name)) continue; - - const dest = join(skillsDir, 'impeccable'); - try { - rmSync(dest, { recursive: true, force: true }); - renameSync(join(skillsDir, name), dest); - migrated++; - } catch {} - } - } - } - return migrated; -} - -function getFlagValue(flags, name) { - const prefix = `${name}=`; - const inline = flags.find(f => f.startsWith(prefix)); - if (inline) return inline.slice(prefix.length); - const index = flags.indexOf(name); - if (index !== -1 && flags[index + 1] && !flags[index + 1].startsWith('-')) { - return flags[index + 1]; - } - return null; -} - -function normalizeProviderName(value) { - const raw = String(value || '').trim(); - if (!raw) return null; - if (PROVIDER_DIRS.includes(raw)) return raw; - const key = raw.replace(/^\./, '').toLowerCase(); - return PROVIDER_ALIASES[key] || null; -} - -function parseProviderList(value) { - const providers = []; - const invalid = []; - for (const raw of String(value || '').split(',').map(s => s.trim()).filter(Boolean)) { - const provider = normalizeProviderName(raw); - if (!provider) { - invalid.push(raw); - continue; - } - if (!providers.includes(provider)) providers.push(provider); - } - return { providers, invalid }; -} - -function providerInputName(provider) { - return PROVIDER_DISPLAY[provider]?.input || provider.replace(/^\./, ''); -} - -function providerDisplayName(provider) { - return PROVIDER_DISPLAY[provider]?.name || provider; -} - -function formatProviderList(providers) { - return providers.map(providerInputName).join(', '); -} - -function providerPromptOptions() { - return PROVIDER_INPUT_ORDER.map(input => { - const provider = normalizeProviderName(input); - const label = providerDisplayName(provider); - const hint = `(${provider}/skills)`; - return { - value: provider, - label, - hint, - searchText: `${label} ${input} ${provider} ${hint}`, - }; - }); -} - -function formatPathForDisplay(path, home = homedir()) { - if (path === home) return '~'; - if (path.startsWith(`${home}/`)) return `~/${path.slice(home.length + 1)}`; - return path; -} - -function uniquePaths(paths) { - return [...new Set(paths)]; -} - -function userSkillProbePaths(home, harnessDir, provider) { - return uniquePaths([ - userProviderSkillsDir(home, provider), - join(home, harnessDir, 'skills'), - ]); -} - -function commandOnPath(command) { - const candidates = process.platform === 'win32' - ? [`${command}.exe`, `${command}.cmd`, `${command}.bat`] - : [command]; - for (const directory of String(process.env.PATH || '').split(delimiter)) { - if (!directory) continue; - for (const candidate of candidates) { - const path = resolve(directory, candidate); - try { - if (statSync(path).isFile()) { - accessSync(path, constants.X_OK); - return path; - } - } catch {} - } - } - return null; -} - -function collectInstallDetections(root, home = homedir()) { - const detections = []; - for (const provider of PROVIDER_DIRS) { - const foundPath = join(root, provider); - if (!existsSync(foundPath)) continue; - detections.push({ - provider, - scope: 'project', - foundPath, - installRoot: root, - installPath: join(root, provider, 'skills'), - hasRealSkills: hasRealSkillEntries(join(root, provider, 'skills')), - reason: 'project harness folder', - }); - } - - for (const hint of GLOBAL_HARNESS_HINTS) { - const { provider } = hint; - // A hint is either a fixed dir under home or a resolver for harnesses - // whose location depends on the environment (OpenCode's config dir). - const foundPath = hint.command - ? commandOnPath(hint.command) - : hint.resolve ? hint.resolve(home) : join(home, hint.home); - if (!foundPath || (!hint.command && !existsSync(foundPath))) continue; - if (hint.command && !existsSync(join(home, '.veto'))) continue; - const skillProbePaths = hint.command - ? [userProviderSkillsDir(home, provider)] - : hint.resolve - ? uniquePaths([userProviderSkillsDir(home, provider), join(foundPath, 'skills')]) - : userSkillProbePaths(home, hint.home, provider); - detections.push({ - provider, - scope: 'user', - foundPath, - installRoot: home, - installPath: userProviderSkillsDir(home, provider), - skillProbePaths, - hasRealSkills: skillProbePaths.some(hasRealSkillEntries), - reason: hint.command ? 'CLI on PATH' : 'user harness folder', - }); - } - return detections; -} - -function uniqueProviders(detections) { - const providers = []; - for (const detection of detections) { - if (!providers.includes(detection.provider)) providers.push(detection.provider); - } - return providers; -} - -function defaultDetectedProviders(detections) { - const projectProviders = uniqueProviders(detections.filter(d => d.scope === 'project')); - if (projectProviders.length > 0) return projectProviders; - return uniqueProviders(detections.filter(d => d.scope === 'user')); -} - -/** - * Decide which provider folders to install into. - * 1. An explicit --providers=.claude,.cursor list wins. - * 2. Otherwise, harness folders already present in the project. - * 3. Otherwise, infer from globally installed harnesses (~/.claude, ~/.codex). - * 4. Otherwise, a sensible default (.claude + .agents). - */ -function resolveInstallTargets(root, providersValue) { - if (providersValue) { - return parseProviderList(providersValue).providers; - } - - const detected = defaultDetectedProviders(collectInstallDetections(root)); - if (detected.length > 0) return detected; - - return [...DEFAULT_TARGETS]; -} - -function normalizeInstallScope(value) { - const key = String(value || '').trim().toLowerCase(); - if (['u', 'user', 'home', 'global'].includes(key)) return 'user'; - if (['p', 'project', 'local', 'repo'].includes(key)) return 'project'; - return null; -} - -function getInstallScopeValue(flags) { - if (flags.includes('--user') || flags.includes('--home') || flags.includes('--global')) return 'user'; - if (flags.includes('--project') || flags.includes('--local')) return 'project'; - return getFlagValue(flags, '--scope') || getFlagValue(flags, '--install-scope'); -} - -function defaultInstallScope(detections, providers) { - const selected = new Set(providers); - if (detections.some(d => selected.has(d.provider) && d.scope === 'project')) return 'project'; - if (detections.some(d => selected.has(d.provider) && d.scope === 'user' && d.hasRealSkills)) return 'user'; - return 'project'; -} - -function installRootForScope(scope, projectRoot) { - return scope === 'user' ? homedir() : projectRoot; -} - -function printInstallIntro() { - if (!isInteractivePrompt()) return; - console.log(`${ui.accent(ui.bold('impeccable'))} ${ui.dim('install')}`); - console.log(''); -} - -function formatInstallDetectionLines(projectRoot, detections, home = homedir(), { styled = false } = {}) { - if (detections.length === 0) { - const message = `No harnesses detected under ${formatPathForDisplay(projectRoot, home)} or ${formatPathForDisplay(home, home)}.`; - return styled - ? [`${ui.accent('◇')} ${ui.bold('Detected harnesses')}`, ` ${ui.dim(message)}`] - : [message]; - } - - const names = detections.map(d => providerDisplayName(d.provider)); - const paths = detections.map(d => formatPathForDisplay(d.foundPath, home)); - const nameWidth = Math.max(...names.map(name => name.length)); - const heading = styled ? `${ui.accent('◇')} ${ui.bold('Detected harnesses')}` : 'Detected harnesses:'; - return [ - heading, - ...detections.map((detection, index) => { - const rawName = names[index].padEnd(nameWidth); - const rawFoundPath = paths[index]; - const name = styled ? ui.bold(rawName) : rawName; - const foundPath = styled ? ui.dim(rawFoundPath) : rawFoundPath; - return ` ${name} ${foundPath}`; - }), - ]; -} - -function printInstallDetections(projectRoot, detections) { - for (const line of formatInstallDetectionLines(projectRoot, detections, homedir(), { styled: isInteractivePrompt() })) console.log(line); - console.log(''); -} - -async function promptForProviders(defaultProviders = []) { - if (isInteractivePrompt()) { - return promptCheckbox('Select harnesses', providerPromptOptions(), { selectedValues: defaultProviders }); - } - - const choices = PROVIDER_INPUT_ORDER.join(', '); - const suffix = defaultProviders.length > 0 - ? ` [blank keeps ${formatProviderList(defaultProviders)}]` - : ''; - while (true) { - const answer = await ask(`Select harnesses (comma-separated: ${choices})${suffix}: `); - if (!answer && defaultProviders.length > 0) return [...defaultProviders]; - const { providers, invalid } = parseProviderList(answer); - if (invalid.length > 0) { - console.log(`Unknown provider(s): ${invalid.join(', ')}`); - continue; - } - if (providers.length > 0) return providers; - console.log('Choose at least one provider.'); - } -} - -async function promptDetectedInstallMode(detectedProviders) { - if (isInteractivePrompt()) { - return promptRadio('Install for detected harnesses only, or add more?', [ - { value: 'detected', label: 'Detected only', hint: `(${formatProviderList(detectedProviders)})` }, - { value: 'add', label: 'Customize...' }, - ]); - } - - while (true) { - const answer = await ask(`Install target: [1] Detected only (${formatProviderList(detectedProviders)}) [2] Customize [1]: `); - if (!answer || ['1', 'detected', 'detected only', 'only', 'd'].includes(answer)) return 'detected'; - if (['2', 'customize', 'customise', 'add', 'add more', 'more', 'a', 'n', 'no'].includes(answer)) return 'add'; - console.log('Choose 1 for detected only, or 2 to customize.'); - } -} - -async function chooseInstallProviders(projectRoot, providersValue, { yes } = {}) { - const detections = collectInstallDetections(projectRoot); - if (providersValue) { - const { providers, invalid } = parseProviderList(providersValue); - if (invalid.length > 0) { - throw new Error(`Unknown provider(s): ${invalid.join(', ')}`); - } - return { targets: providers, detections, explicit: true }; - } - - if (yes) { - return { targets: resolveInstallTargets(projectRoot, null), detections, explicit: false }; - } - - printInstallDetections(projectRoot, detections); - const detectedProviders = defaultDetectedProviders(detections); - if (detectedProviders.length === 0) { - return { targets: await promptForProviders(), detections, explicit: false }; - } - - const mode = await promptDetectedInstallMode(detectedProviders); - if (mode === 'add') { - return { targets: await promptForProviders(detectedProviders), detections, explicit: false }; - } - return { targets: detectedProviders, detections, explicit: false }; -} - -async function chooseInstallScope(projectRoot, targets, detections, { yes, scopeValue } = {}) { - const explicitScope = normalizeInstallScope(scopeValue); - if (scopeValue && !explicitScope) { - throw new Error(`Unknown install scope: ${scopeValue}. Use --scope=project or --scope=global.`); - } - if (explicitScope) return explicitScope; - - // Preserve the old scripted behavior: `-y` installs into the current project - // unless the caller explicitly opts into `--scope=global`. - if (yes) return 'project'; - - const fallback = defaultInstallScope(detections, targets); - if (isInteractivePrompt()) { - return promptRadio('Install location', [ - { value: 'project', label: 'Project', hint: `(${formatPathForDisplay(projectRoot)})` }, - { value: 'user', label: 'Global', hint: `(${formatPathForDisplay(homedir())})` }, - ], { initialIndex: fallback === 'user' ? 1 : 0 }); - } - - const answer = await ask(`Install location: project (${formatPathForDisplay(projectRoot)}) or global (${formatPathForDisplay(homedir())})? [${fallback === 'user' ? 'global' : fallback}] `); - if (!answer) return fallback; - const scope = normalizeInstallScope(answer); - if (!scope) { - console.log(`Unknown install location "${answer}", using ${fallback}.`); - return fallback; - } - return scope; -} - -async function chooseInstallPlan(projectRoot, flags, { yes } = {}) { - const providersValue = getFlagValue(flags, '--providers'); - const scopeValue = getInstallScopeValue(flags); - const { targets, detections, explicit } = await chooseInstallProviders(projectRoot, providersValue, { yes }); - if (targets.length === 0) { - throw new Error('Could not determine a target harness folder.'); - } - const scope = await chooseInstallScope(projectRoot, targets, detections, { yes, scopeValue }); - const installRoot = installRootForScope(scope, projectRoot); - return { targets, scope, installRoot, hookRoot: projectRoot, detections, explicit }; -} - -/** - * Whether `localSkillsDir` is a symlink that points at ANOTHER in-project - * provider's skills dir (e.g. `.claude/skills -> ../.agents/skills`, the shape a - * prior `npx skills` install can leave behind). Only these get dropped so each - * provider can receive its own compiled variant. A symlink to anywhere else - - * notably a user's external shared skills dir (`~/.claude/skills -> - * ~/.config/agents/skills`) - is preserved and written through. See issue #295. - */ -function isInProjectProviderLink(localSkillsDir, root, provider) { - let target; - try { - if (!lstatSync(localSkillsDir).isSymbolicLink()) return false; - target = readlinkSync(localSkillsDir); - } catch { - return false; // not a symlink, or unreadable - } - // Resolve the link's TARGET lexically against the link's own directory. We - // deliberately do NOT realpathSync the target: - // * it lets a not-yet-created in-project target still match, so a dangling - // `.claude/skills -> ../.agents/skills` is still dropped; - // * it compares the ACTUAL target, not a shared realpath, so two providers - // pointing at the SAME external dir are never misread as in-project. - const resolvedTarget = resolve(dirname(localSkillsDir), target); - for (const other of PROVIDER_DIRS) { - if (other === provider) continue; - if (resolvedTarget === join(root, other, 'skills')) return true; - } - return false; -} - -/** - * Copy each target provider's compiled skill variant from an extracted bundle - * into the project. Writes real directories (copy, never symlink) so every - * harness keeps the build that was compiled for it. Returns skills written. - * `scope: 'user'` writes to the provider's global skills layout (see - * HOME_SKILLS_DIR_OVERRIDES); anything else writes `/skills`. - */ -function copyProviderSkills(bundleDir, root, targets, { scope } = {}) { - let written = 0; - for (const provider of targets) { - const srcDir = join(bundleDir, provider, 'skills'); - if (existsSync(srcDir)) { - const localSkillsDir = scope === 'user' - ? userProviderSkillsDir(root, provider) - : join(root, provider, 'skills'); - // A previous `npx skills` install may have left this provider's skills dir - // as a symlink to ANOTHER in-project provider's canonical copy. Drop only - // that link so we write a real, provider-specific directory. A user's - // external shared-skills symlink (e.g. ~/.claude/skills -> - // ~/.config/agents/skills) is preserved and written through. See #295. - try { - if (isInProjectProviderLink(localSkillsDir, root, provider)) unlinkSync(localSkillsDir); - } catch {} - for (const skill of readdirSync(srcDir, { withFileTypes: true })) { - if (!skill.isDirectory()) continue; - const src = join(srcDir, skill.name); - const dest = join(localSkillsDir, skill.name); - rmSync(dest, { recursive: true, force: true }); - copyDirSync(src, dest); - written++; - } - // A pre-#406 global OpenCode install lived at ~/.opencode/skills, a - // location OpenCode never reads. Now that the real copy sits in the - // config dir, drop exactly the skills just written from the stranded - // location; sibling skills and everything else in ~/.opencode stay. - // Guards (both flagged in review): a symlinked skills dir is shared - // storage whose target must not be emptied through the link, the - // just-written dir must be compared by realpath rather than string, - // and a home-rooted repo makes `.opencode/skills` a live - // project-scope install rather than a stranded global one. - if (scope === 'user' && provider === '.opencode') { - const legacyDir = join(root, '.opencode', 'skills'); - let migratable = false; - try { - migratable = existsSync(legacyDir) - && !lstatSync(legacyDir).isSymbolicLink() - && realpathSync(legacyDir) !== realpathSync(localSkillsDir) - && !existsSync(join(root, '.git')); - } catch { migratable = false; } - if (migratable) { - for (const skill of readdirSync(srcDir, { withFileTypes: true })) { - if (!skill.isDirectory()) continue; - rmSync(join(legacyDir, skill.name), { recursive: true, force: true }); - } - try { rmdirSync(legacyDir); } catch { /* not empty: siblings stay */ } - } - } - } - } - return written; -} - -// Native subagent definitions that ship in the bundle next to a provider's -// skills. Claude Code's live at `.claude/agents/impeccable-*.md`; project -// agents take precedence over user agents. GitHub Copilot's live at -// `.github/agents/impeccable-*.agent.md`: -// project installs commit them at `/.github/agents/`, user-level -// installs go to `~/.copilot/agents/` (Copilot's user-scope dir, NOT -// `~/.github/`). On a name conflict Copilot lets the user-level file shadow -// the project one, so both paths overwrite existing impeccable-* copies and a -// project install reports any same-named user-level agents that would shadow -// it. Cursor's live at `.cursor/agents/impeccable-*.md`, user scope -// `~/.cursor/agents/`; project agents take precedence there, so no shadow -// warning is needed. -const PROVIDER_AGENT_ARTIFACTS = { - '.claude': { - ext: '.md', - userDir: home => join(home, '.claude', 'agents'), - userShadowsProject: false, - }, - '.github': { - ext: '.agent.md', - userDir: home => join(home, '.copilot', 'agents'), - userShadowsProject: true, - }, - '.cursor': { - ext: '.md', - userDir: home => join(home, '.cursor', 'agents'), - userShadowsProject: false, - }, -}; - -function providerAgentsUpToDate(bundleDir, root, provider, scope) { - const artifact = PROVIDER_AGENT_ARTIFACTS[provider]; - if (!artifact) return true; - const srcDir = join(bundleDir, provider, 'agents'); - if (!existsSync(srcDir)) return true; - - const destDir = scope === 'user' - ? artifact.userDir(root) - : join(root, provider, 'agents'); - const agentFiles = readdirSync(srcDir).filter(name => name.endsWith(artifact.ext)); - return agentFiles.every(name => { - const localPath = join(destDir, name); - return existsSync(localPath) - && hashSkillFile(join(srcDir, name)) === hashSkillFile(localPath); - }); -} - -function copyProviderAgents(bundleDir, root, providers, { scope, home = homedir() } = {}) { - const targets = Array.isArray(providers) ? providers : [providers]; - const results = []; - for (const provider of targets) { - const artifact = PROVIDER_AGENT_ARTIFACTS[provider]; - if (!artifact) continue; - const srcDir = join(bundleDir, provider, 'agents'); - if (!existsSync(srcDir)) continue; - const agentFiles = readdirSync(srcDir).filter(name => name.endsWith(artifact.ext)); - if (agentFiles.length === 0) continue; - - const destDir = scope === 'user' - ? artifact.userDir(root) - : join(root, provider, 'agents'); - mkdirSync(destDir, { recursive: true }); - for (const name of agentFiles) { - writeFileSync(join(destDir, name), readFileSync(join(srcDir, name))); - } - - // A project install can be shadowed by same-named agents in the user-level - // dir; surface them so the freshly installed project agents actually apply. - const userDir = artifact.userDir(home); - const shadowed = artifact.userShadowsProject && scope !== 'user' - ? agentFiles.filter(name => existsSync(join(userDir, name))) - : []; - - results.push({ provider, written: agentFiles.length, destDir, userDir, shadowed }); - } - return results; -} - -function reportProviderAgents(results) { - for (const result of results || []) { - if (result.written === 0) continue; - console.log(`Installed ${providerDisplayName(result.provider)} agents into: ${formatPathForDisplay(result.destDir)}`); - if (result.shadowed.length > 0) { - console.warn(`Warning: user-level agents in ${formatPathForDisplay(result.userDir)} shadow the project copies just installed: ${result.shadowed.join(', ')}.`); - console.warn('Run `npx impeccable update --user` to refresh them, or remove them so the project agents apply.'); - } - } -} - -function refreshProviderSkills(bundleDir, root, providers, scope) { - const unique = deduplicateProviders(root, providers, scope); - let updated = 0; - for (const { provider, localSkillsDir } of unique) { - const srcDir = join(bundleDir, provider, 'skills'); - if (!existsSync(srcDir)) continue; - - const skills = readdirSync(srcDir, { withFileTypes: true }); - for (const skill of skills) { - if (!skill.isDirectory()) continue; - const src = join(srcDir, skill.name); - const dest = join(localSkillsDir, skill.name); - if (existsSync(dest)) rmSync(dest, { recursive: true, force: true }); - copyDirSync(src, dest); - updated++; - } - } - return updated; -} - -function hookArtifactsForProvider(bundleDir, root, provider) { - return (PROVIDER_HOOK_ARTIFACTS[provider] || []).map(({ sourceProvider, rel, destProvider, destRel }) => { - const writeRel = destRel || rel; - const artifact = { - src: join(bundleDir, sourceProvider, rel), - dest: join(root, destProvider, writeRel), - }; - // When the write target is a local override (e.g. settings.local.json), the - // team-shared sibling (settings.json) is where a legacy install or a - // deliberate user move would put our hook. Track it so we never duplicate. - if (writeRel !== rel) { - artifact.sharedDest = join(root, destProvider, rel); - } - return artifact; - }); -} - -// The project-relative hook command path for a provider, used for project-scope -// installs (skillRoot === root). Derived rather than copied from the bundle: the -// Codex bundle ships a `.codex/skills/...` command (correct for a `.codex`- -// directory install), but the CLI lays Codex's skill down at `.agents/skills/`, -// so preserving the bundle token would point the hook at a nonexistent file and -// silently no-op it. Claude keeps its ${CLAUDE_PROJECT_DIR} token so a manifest -// read from a nested cwd (or copied into settings.local.json) still resolves. -function hookScriptRelPathForProvider(provider) { - const script = provider === '.cursor' ? 'hook-before-edit.mjs' : 'hook.mjs'; - const rel = `${provider}/skills/impeccable/scripts/${script}`; - return provider === '.claude' ? '${CLAUDE_PROJECT_DIR}/' + rel : rel; -} - -function hookScriptPathForProvider(skillRoot, provider) { - // `.github` is intentionally absent: its hook manifest (`.github/hooks/ - // impeccable.json`) is a committed, team-shared file that the Copilot cloud - // agent and every teammate read, so the command must stay portable - // (`$(git rev-parse --show-toplevel)/.github/skills/...`). Rewriting it to a - // machine-local absolute skillRoot path would break those. GitHub skills are - // project-scoped (not a home-provider), so the project-relative path resolves. - if (provider === '.cursor') { - return join(skillRoot, provider, 'skills', 'impeccable', 'scripts', 'hook-before-edit.mjs'); - } - if (provider === '.claude' || provider === '.agents' || provider === '.grok') { - return join(skillRoot, provider, 'skills', 'impeccable', 'scripts', 'hook.mjs'); - } - return null; -} - -// Wrap a `node "PATH"` hook command so a missing skill file is a silent no-op -// (exit 0) instead of a Node module-resolution crash. hook.mjs promises to -// "never break a turn. Always exit 0.", but that only holds once Node can load -// the file; a stale/missing path crashes before any of that logic runs. The -// `[ ! -f X ] || node X` form (NOT `... || true`) preserves Node's own exit -// code when the file exists, so Claude's exit-2 blocking signal still reaches -// the agent. POSIX-shell form, consistent with the project's other hook -// commands (e.g. the GitHub manifest's `$(git rev-parse ...)`). -// -// On Windows that guard is a hard failure, not a degraded one (issue #452). -// Codex runs hook commands through COMSPEC (`cmd.exe /C`), where `[` is not a -// command: the guard errors noisily and `||` then runs node even when the file -// is missing, trading the silent no-op for a MODULE_NOT_FOUND crash. Two -// remedies, by provider: -// -// * Codex manifests support a `commandWindows` sibling that Codex 0.146.0+ -// selects on Windows (`command_windows.unwrap_or(command)` in its hook -// discovery). rewriteHookCommandsForSkillRoot adds it with a cmd.exe -// `if exist` guard (form contributed and Windows-tested by @PatrickSys in -// issue #452; `exit /b` forwards node's errorlevel), so the same -// .codex/hooks.json is correct on every OS no matter where it was -// written, and `command` stays the plain POSIX guard. -// * Claude and Cursor manifests have no per-platform field, so a Windows -// install moves the existence check into node itself: a `node -e` wrapper -// that exits 0 when the target is missing and otherwise re-spawns node on -// it with inherited stdio, forwarding the hook's exit code. Cursor's -// hooks.json is committable and can be consumed on a teammate's POSIX -// machine, so the wrapper has to hold there too: it uses only characters -// that survive PowerShell, cmd.exe (issue #445: shims re-parse through -// `cmd /C`, which claims < > | & ^ % !), and sh double-quoting alike, -// with single quotes for the inner string literals. -const WIN32_HOOK_GUARD_SCRIPT = "const p=process.argv[1];const f=require('fs');if(f.existsSync(p)){const r=require('child_process').spawnSync(process.execPath,[p],{stdio:'inherit'});process.exit(r.status===null?1:r.status);}"; - -// POSIX single-quote escaping. JSON.stringify is not shell quoting: inside -// double quotes /bin/sh still expands $(...), backticks, and ${}, and this -// string is baked into a hook manifest the harness re-executes on every edit, -// so an install path embedding $(...) would run it repeatedly (issue #476). -// Windows command forms keep double quotes: cmd.exe treats ' as a literal -// character and performs no command substitution. -function shSingleQuote(value) { - return `'${String(value).replace(/'/g, `'\\''`)}'`; -} - -function windowsHookCommand(quotedPath) { - return `if exist ${quotedPath} (node ${quotedPath} & exit /b)`; -} - -// `quotedPath` carries one pre-quoted form per target shell: { posix, win32 }. -function guardHookCommand(quotedPath, provider) { - // `.agents` (Codex) keeps the POSIX form unconditionally: its Windows - // consumers read the commandWindows sibling instead. - if (provider !== '.agents' && process.platform === 'win32') { - return `node -e "${WIN32_HOOK_GUARD_SCRIPT}" ${quotedPath.win32}`; - } - return `[ ! -f ${quotedPath.posix} ] || node ${quotedPath.posix}`; -} - -// Transform bundled hook commands for the actual install target: -// * absolute — rewrite the (marker) command to the resolved absolute skill -// path. Required when the manifest is a user/global file (~/.claude/ -// settings.local.json) that fires in EVERY project, so ${CLAUDE_PROJECT_DIR} -// would resolve per-project to dirs without a skill copy (issue #399); also -// when a project hook points at a skill installed elsewhere (--scope=global). -// * otherwise — keep the bundle's own ${CLAUDE_PROJECT_DIR}-relative path, -// which correctly resolves for a project-scoped install. -// Either way the command goes through guardHookCommand (POSIX shell guard, or -// the shell-agnostic node -e guard when installing on Windows), and Codex hook -// entries additionally get a `commandWindows` sibling for cmd.exe. -function rewriteHookCommandsForSkillRoot(value, provider, { skillRoot, absolute }) { - const hookScript = hookScriptPathForProvider(skillRoot, provider); - // Providers we don't own a `node "PATH"` command hook for (.github) carry - // their own portable command forms; leave them untouched. - if (!hookScript) return value; - - // Project-scope installs derive the provider's own project-relative path - // rather than trusting the bundle token, which for Codex points at - // `.codex/skills/...` while the CLI installs the skill at `.agents/skills/`. - // The absolute path comes from the install root (project dir or $HOME), so - // its POSIX form gets real single-quote escaping (issue #476). The relative - // form is a per-provider constant and stays double-quoted, because Claude's - // ${CLAUDE_PROJECT_DIR} token must keep expanding at hook time. - const relPath = hookScriptRelPathForProvider(provider); - const quotedPath = absolute - ? { posix: shSingleQuote(hookScript), win32: JSON.stringify(hookScript) } - : { posix: JSON.stringify(relPath), win32: JSON.stringify(relPath) }; - - if (typeof value === 'string') { - if (!valueHasImpeccableHookMarker(value)) return value; - return guardHookCommand(quotedPath, provider); - } - if (Array.isArray(value)) { - return value.map(item => rewriteHookCommandsForSkillRoot(item, provider, { skillRoot, absolute })); - } - if (value && typeof value === 'object') { - const next = {}; - for (const [key, child] of Object.entries(value)) { - next[key] = rewriteHookCommandsForSkillRoot(child, provider, { skillRoot, absolute }); - } - if (provider === '.agents' && typeof value.command === 'string' && valueHasImpeccableHookMarker(value.command)) { - next.commandWindows = windowsHookCommand(quotedPath.win32); - } - return next; - } - return value; -} - -// The file paths the CLI writes hook manifests to (the local override target, -// e.g. settings.local.json — not the shared sibling). -function expectedHookDests(root, providers) { - const targets = Array.isArray(providers) ? providers : [providers]; - return targets.flatMap(provider => - (PROVIDER_HOOK_ARTIFACTS[provider] || []).map(({ rel, destProvider, destRel }) => - join(root, destProvider, destRel || rel)) - ); -} - -// Whether a hook manifest file actually wires up the Impeccable hook. We parse -// the JSON and scan only the `hooks` subtree (via valueHasImpeccableHookMarker), -// not the raw file text: an unrelated string elsewhere — e.g. a permissions -// allow entry that happens to mention the hook path — must not read as a hook. -function fileHasImpeccableHookMarker(file) { - if (!existsSync(file)) return false; - let parsed; - try { - parsed = JSON.parse(readFileSync(file, 'utf-8')); - } catch { - return false; - } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false; - if (!parsed.hooks || typeof parsed.hooks !== 'object') return false; - return valueHasImpeccableHookMarker(parsed.hooks); -} - -// Whether our hook is already wired up for a provider, used to decide if the -// already-installed fast path should top up a missing hook. We look for the -// Impeccable marker — not mere file existence — because the target files -// (settings.local.json, hooks.json) commonly hold unrelated local settings; an -// existence check would falsely report "installed" and skip repairing a missing -// hook that `update` would otherwise add. For Claude we also honor our hook -// living in the shared settings.json sibling (a legacy install or user move). -function hookInstalledForProvider(root, provider) { - const artifacts = PROVIDER_HOOK_ARTIFACTS[provider] || []; - if (artifacts.length === 0) return true; - return artifacts.every(({ destProvider, rel, destRel }) => { - const writeRel = destRel || rel; - if (fileHasImpeccableHookMarker(join(root, destProvider, writeRel))) return true; - if (writeRel !== rel && fileHasImpeccableHookMarker(join(root, destProvider, rel))) return true; - return false; - }); -} - -function valueHasImpeccableHookMarker(value) { - if (typeof value === 'string') { - const normalized = value.replace(/\\/g, '/'); - return IMPECCABLE_HOOK_COMMAND_MARKERS.some(marker => normalized.includes(marker)); - } - if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker); - if (value && typeof value === 'object') { - return Object.values(value).some(valueHasImpeccableHookMarker); - } - return false; -} - -function stripImpeccableHookEntry(entry) { - if (!entry || typeof entry !== 'object') return entry; - // `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's - // flat entry shape, where the marker lives under the shell-command keys. - if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args) - || valueHasImpeccableHookMarker(entry.bash) || valueHasImpeccableHookMarker(entry.powershell)) { - return null; - } - if (!Array.isArray(entry.hooks)) return entry; - - const strippedHooks = entry.hooks - .map(stripImpeccableHookEntry) - .filter(Boolean); - - if (strippedHooks.length === 0 && entry.hooks.some(valueHasImpeccableHookMarker)) { - return null; - } - - return { ...entry, hooks: strippedHooks }; -} - -function stripImpeccableHookEntries(entries) { - if (!Array.isArray(entries)) return []; - return entries - .map(stripImpeccableHookEntry) - .filter(Boolean); -} - -// Remove our hook from a manifest file, preserving any unrelated content. Used -// when the hook is honored in the shared settings.json so a stale machine-local -// copy doesn't make the detector run twice. Drops the file if nothing but our -// hook scaffolding remains. Returns true if it changed anything. -function pruneImpeccableHookFromManifest(manifestPath) { - if (!fileHasImpeccableHookMarker(manifestPath)) return false; - let parsed; - try { - parsed = JSON.parse(readFileSync(manifestPath, 'utf-8')); - } catch { - return false; - } - - const existingHooks = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks) - ? parsed.hooks - : {}; - const cleanedHooks = {}; - for (const [event, entries] of Object.entries(existingHooks)) { - const kept = stripImpeccableHookEntries(entries); - if (kept.length > 0) cleanedHooks[event] = kept; - } - - const next = { ...parsed }; - if (Object.keys(cleanedHooks).length > 0) { - next.hooks = cleanedHooks; - } else { - // Our hook was the only thing here; drop the hook-manifest scaffolding too. - delete next.hooks; - delete next.description; - delete next.version; - } - - if (Object.keys(next).length === 0) { - rmSync(manifestPath, { force: true }); - } else { - writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`); - } - return true; -} - -function mergeHookManifests(existing, fresh) { - const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {}; - const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {}; - const existingHooks = existingObject.hooks && typeof existingObject.hooks === 'object' && !Array.isArray(existingObject.hooks) - ? existingObject.hooks - : {}; - const freshHooks = freshObject.hooks && typeof freshObject.hooks === 'object' && !Array.isArray(freshObject.hooks) - ? freshObject.hooks - : {}; - - const merged = { ...existingObject, hooks: {} }; - if (freshObject.version !== undefined) merged.version = freshObject.version; - if (freshObject.description !== undefined) merged.description = freshObject.description; - - const hookEvents = new Set([...Object.keys(existingHooks), ...Object.keys(freshHooks)]); - for (const event of hookEvents) { - const preserved = stripImpeccableHookEntries(existingHooks[event]); - const added = Array.isArray(freshHooks[event]) ? freshHooks[event] : []; - const mergedEntries = [...preserved, ...added]; - if (mergedEntries.length > 0) merged.hooks[event] = mergedEntries; - } - - return merged; -} - -function readJsonFile(filePath, description) { - try { - return JSON.parse(readFileSync(filePath, 'utf-8')); - } catch (e) { - throw new Error(`${description} is not valid JSON: ${filePath}. ${e.message}`); - } -} - -function copyProviderHooks(bundleDir, root, providers, { force = false, skillRoot = root } = {}) { - const targets = Array.isArray(providers) ? providers : [providers]; - const written = []; - for (const provider of targets) { - for (const { src, dest, sharedDest } of hookArtifactsForProvider(bundleDir, root, provider)) { - if (!existsSync(src)) continue; - - // Leave-it-never-duplicate: our hook already lives in the team-shared - // settings.json (a legacy install or a deliberate user move). Honor it in - // place and skip the local write — but first strip any stale copy from the - // local override, or Claude Code would load both and run the detector - // twice per edit. - if (sharedDest && fileHasImpeccableHookMarker(sharedDest)) { - pruneImpeccableHookFromManifest(dest); - continue; - } - - const freshManifest = readJsonFile(src, 'Bundled hook manifest'); - // Rewrite to an absolute skill path when the skill lives elsewhere than - // this manifest's root (--scope=global project hook) OR when the manifest - // itself is a user/global file. A global settings file fires in every - // project, so ${CLAUDE_PROJECT_DIR} there crashes Node wherever no local - // skill copy exists (issue #399); the resolved absolute path is correct - // for the one global skill it targets. - const absolute = skillRoot !== root || isHomeDir(root); - const fresh = rewriteHookCommandsForSkillRoot(freshManifest, provider, { skillRoot, absolute }); - let next = fresh; - - if (existsSync(dest)) { - try { - const existing = JSON.parse(readFileSync(dest, 'utf-8')); - next = mergeHookManifests(existing, fresh); - } catch { - if (!force) { - throw new Error(`Existing hook manifest is not valid JSON: ${dest}. Re-run with --force to replace it.`); - } - writeFileSync(`${dest}.bak`, readFileSync(dest)); - next = fresh; - } - } - - mkdirSync(dirname(dest), { recursive: true }); - writeFileSync(dest, `${JSON.stringify(next, null, 2)}\n`); - written.push(provider); - } - } - return [...new Set(written)]; -} - -const HOOK_EXPLAINER = [ - '', - 'Impeccable can install a design hook for this project. In Claude/Codex it', - 'checks UI files after edits; in Cursor it checks proposed writes before they', - 'land and can block writes with detector findings. It feeds results back to', - 'your agent so design slop gets caught as you build. Change it later with', - '/impeccable hooks on|off.', - '', -].join('\n'); - -// Decide whether to install the design hook. Prompts once (default yes) the -// first time, records the answer in .impeccable/config.local.json, and never -// re-asks: a recorded decision or an already-installed hook short-circuits, and -// non-interactive runs keep the historical install-by-default behavior. -async function decideHookInstall(root, targets, { yes } = {}) { - if (targets.length === 0) return false; - const consent = getHookConsent(root); - if (consent === 'declined') return false; - if (consent === 'accepted') return true; - // Existing hook users (hook already wired up) are never nagged. - if (targets.length > 0 && targets.every(provider => hookInstalledForProvider(root, provider))) { - return true; - } - // Undecided and not yet installed. Non-interactive (-y or no TTY) keeps the - // historical default-on behavior without recording a (re-promptable) decision. - if (yes || !process.stdin.isTTY) return true; - - process.stdout.write(HOOK_EXPLAINER); - const ans = await ask('Install the design hook? (Y/n) '); - const accepted = !(ans === 'n' || ans === 'no'); - setHookConsent(root, accepted ? 'accepted' : 'declined'); - return accepted; -} - -function resolveLinkSource(sourceValue, root) { - const sourcePath = sourceValue || '.impeccable'; - const checkoutRoot = isAbsolute(sourcePath) ? sourcePath : resolve(root, sourcePath); - const universalRoot = join(checkoutRoot, 'dist', 'universal'); - if (existsSync(universalRoot)) { - return { checkoutRoot, bundleRoot: universalRoot }; - } - if (PROVIDER_DIRS.some(provider => existsSync(join(checkoutRoot, provider, 'skills')))) { - return { checkoutRoot, bundleRoot: checkoutRoot }; - } - throw new Error(`Could not find compiled skills in ${sourcePath}. Expected dist/universal/ or provider skill folders.`); -} - -function pathExistsOrLink(path) { - try { - lstatSync(path); - return true; - } catch { - return false; - } -} - -function isSymlinkTo(dest, expectedSource) { - try { - if (!lstatSync(dest).isSymbolicLink()) return false; - const target = readlinkSync(dest); - const resolvedTarget = resolve(dirname(dest), target); - return realpathSync(resolvedTarget) === realpathSync(expectedSource); - } catch { - return false; - } -} - -function resolveUniqueLinkTargets(root, targets) { - const seen = new Set(); - const unique = []; - for (const provider of targets) { - const localSkillsDir = join(root, provider, 'skills'); - mkdirSync(localSkillsDir, { recursive: true }); - const real = realpathSync(localSkillsDir); - if (seen.has(real)) continue; - seen.add(real); - unique.push({ provider, localSkillsDir }); - } - return unique; -} - -function linkProviderSkills(bundleRoot, root, targets, { force = false } = {}) { - let linked = 0; - let already = 0; - let skipped = 0; - - for (const { provider, localSkillsDir } of resolveUniqueLinkTargets(root, targets)) { - const srcDir = join(bundleRoot, provider, 'skills'); - if (!existsSync(srcDir)) continue; - - for (const skill of readdirSync(srcDir, { withFileTypes: true })) { - if (!skill.isDirectory()) continue; - const src = join(srcDir, skill.name); - const dest = join(localSkillsDir, skill.name); - - if (pathExistsOrLink(dest)) { - if (isSymlinkTo(dest, src)) { - already++; - continue; - } - if (!force) { - console.warn(`Skipped existing ${provider}/skills/${skill.name}. Use --force to replace it with a link.`); - skipped++; - continue; - } - rmSync(dest, { recursive: true, force: true }); - } - - const target = relative(dirname(dest), src) || '.'; - symlinkSync(target, dest, 'dir'); - linked++; - } - } - - return { linked, already, skipped }; -} - -async function link(flags) { - const force = flags.includes('--force'); - const yes = flags.includes('-y') || flags.includes('--yes'); - const sourceValue = getFlagValue(flags, '--source'); - const providersValue = getFlagValue(flags, '--providers'); - const root = findProjectRoot(); - - let source; - try { - source = resolveLinkSource(sourceValue, root); - } catch (e) { - console.error(e.message); - process.exit(1); - } - - const targets = resolveInstallTargets(root, providersValue); - if (targets.length === 0) { - console.error('Could not determine a target harness folder.'); - console.error('Pass one explicitly, e.g. --providers=claude,cursor'); - process.exit(1); - } - - if (!yes) { - console.log(`Source checkout: ${source.checkoutRoot}`); - console.log(`Target harness folder(s): ${targets.join(', ')}`); - const ans = await ask(`Link impeccable skills into ${targets.length} folder(s)? (Y/n) `); - if (ans === 'n' || ans === 'no') { - console.log('Aborted. Re-run with --providers= to choose explicitly (e.g. --providers=claude,cursor).'); - process.exit(0); - } - } - - const result = linkProviderSkills(source.bundleRoot, root, targets, { force }); - if (result.linked === 0 && result.already === 0) { - if (result.skipped > 0) { - console.error('Nothing was linked because matching skill folders already exist.'); - console.error('Existing skills were left untouched. Re-run with --force to replace them with links.'); - } else { - console.error(`Nothing was linked: ${source.bundleRoot} had no variants for ${targets.join(', ')}.`); - } - process.exit(1); - } - - const parts = []; - if (result.linked > 0) parts.push(`${result.linked} linked`); - if (result.already > 0) parts.push(`${result.already} already linked`); - if (result.skipped > 0) parts.push(`${result.skipped} skipped`); - console.log(`Linked impeccable into: ${targets.join(', ')} (${parts.join(', ')}).`); - console.log('Update with `git submodule update --remote` from your project root, then rerun this command if new skills are added.\n'); -} - -async function install(flags) { - const force = flags.includes('--force'); - const yes = flags.includes('-y') || flags.includes('--yes'); - const installHooks = !flags.includes('--no-hooks'); - const projectRoot = findProjectRoot(); - if (!yes) printInstallIntro(); - let plan; - try { - plan = await chooseInstallPlan(projectRoot, flags, { yes }); - } catch (e) { - if (isPromptAbortError(e)) throw e; - console.error(e.message); - console.error('Pass providers explicitly, e.g. --providers=claude,cursor'); - process.exit(1); - } - - const { targets, installRoot, hookRoot, scope, explicit } = plan; - const existing = isAlreadyInstalled(installRoot, scope); - const installedTargets = existing ? findInstalledProviders(installRoot, scope) : []; - // An explicit --providers list is a per-target request: a selected provider - // with no install yet gets a fresh install instead of tripping the global - // "already installed" early exit (issue #500). When every selected provider - // is missing, skip the update branch entirely and take the fresh-install path. - const missingSelectedTargets = (existing && !force && explicit) - ? targets.filter(provider => !installedTargets.includes(provider)) - : []; - - if (existing && !force && missingSelectedTargets.length < targets.length) { - console.log(`Impeccable skills are already installed (found in ${existing}/).`); - const selectedInstalledTargets = targets.filter(provider => installedTargets.includes(provider)); - const linkedTargets = findLinkedProviders(installRoot, selectedInstalledTargets, scope); - const copyTargets = selectedInstalledTargets.filter(provider => !linkedTargets.includes(provider)); - const hookTargets = [...selectedInstalledTargets, ...missingSelectedTargets]; - const wantHooks = installHooks && await decideHookInstall(hookRoot, hookTargets, { yes }); - let bundleDir; - try { - if (linkedTargets.length > 0) { - console.log(`Linked skills found in: ${linkedTargets.join(', ')}`); - console.log('Update the source checkout with `git submodule update --remote`, then rerun `npx impeccable link --source=.impeccable` if new skills are added.'); - if (copyTargets.length > 0) console.log(`Continuing with copied installs in: ${copyTargets.join(', ')}\n`); - } - - let updated = 0; - const missingHookTargets = wantHooks - ? hookTargets.filter(provider => !hookInstalledForProvider(hookRoot, provider)) - : []; - let updateCheckSkipped = false; - if (copyTargets.length > 0 || missingHookTargets.length > 0 || missingSelectedTargets.length > 0) { - try { - bundleDir = await downloadAndExtractBundle(); - } catch (e) { - if (missingHookTargets.length > 0 || missingSelectedTargets.length > 0) throw e; - updateCheckSkipped = true; - console.log(`Could not check for skill updates: ${e.message}`); - } - } - - if (!updateCheckSkipped && copyTargets.length > 0 && !isUpToDate(installRoot, copyTargets, bundleDir, scope)) { - migrateUnprefixImpeccable(installRoot, scope); - updated = refreshProviderSkills(bundleDir, installRoot, copyTargets, scope); - reportProviderAgents(copyProviderAgents(bundleDir, installRoot, copyTargets, { scope })); - const v = getSkillsVersion(installRoot, scope); - console.log(`Updated ${updated} skill(s)${v ? ` to v${v}` : ''}.`); - } - - let freshWritten = 0; - if (!updateCheckSkipped && missingSelectedTargets.length > 0) { - freshWritten = copyProviderSkills(bundleDir, installRoot, missingSelectedTargets, { scope }); - if (freshWritten === 0) { - console.error(`Nothing was installed: the bundle had no variants for ${missingSelectedTargets.join(', ')}.`); - process.exit(1); - } - console.log(`Installed impeccable into: ${missingSelectedTargets.join(', ')} (${scope === 'user' ? 'global' : 'project'})`); - reportProviderAgents(copyProviderAgents(bundleDir, installRoot, missingSelectedTargets, { scope })); - } - - const writtenHookTargets = missingHookTargets.length > 0 - ? copyProviderHooks(bundleDir, hookRoot, missingHookTargets, { skillRoot: installRoot }) - : []; - if (writtenHookTargets.length > 0) console.log(`Installed hooks into: ${writtenHookTargets.join(', ')}`); - - if (updateCheckSkipped) { - console.log('Existing skills were left unchanged.'); - console.log('Run with --force to reinstall.\n'); - } else if (updated === 0 && writtenHookTargets.length === 0 && freshWritten === 0) { - const v = getSkillsVersion(installRoot, scope); - console.log(`Skills are up to date${v ? ` (v${v})` : ''}.`); - console.log('Run with --force to reinstall.\n'); - } else { - console.log('Done!\n'); - } - } catch (e) { - console.error(`Install check failed: ${e.message}`); - process.exit(1); - } finally { - if (bundleDir) rmSync(bundleDir, { recursive: true, force: true }); - } - process.exit(0); - } - - // Decide which harness folders to install into, then copy each harness's own - // compiled variant from the universal bundle. We deliberately do NOT shell out - // to `npx skills add`: its name-based discovery can install the uncompiled - // source, and its symlink default points every harness at one shared variant. - // Copying per-provider variants is the only correct install for this skill. - if (targets.length === 0) { - console.error('Could not determine a target harness folder.'); - console.error('Pass one explicitly, e.g. --providers=.claude,.cursor'); - process.exit(1); - } - - const wantHooks = installHooks && await decideHookInstall(hookRoot, targets, { yes }); - - console.log('\nDownloading impeccable skills...'); - let bundleDir; - try { - bundleDir = await downloadAndExtractBundle(); - } catch (e) { - console.error(`Download failed: ${e.message}`); - process.exit(1); - } - - // Retire any old `i-`-prefixed install so the fresh copy lands on the - // canonical `impeccable` dir instead of orphaning the prefixed one. - migrateUnprefixImpeccable(installRoot, scope); - - let written = 0; - let hookTargets = []; - let agentResults = []; - try { - written = copyProviderSkills(bundleDir, installRoot, targets, { scope }); - agentResults = copyProviderAgents(bundleDir, installRoot, targets, { scope }); - hookTargets = wantHooks ? copyProviderHooks(bundleDir, hookRoot, targets, { force, skillRoot: installRoot }) : []; - } catch (e) { - rmSync(bundleDir, { recursive: true, force: true }); - console.error(`Install failed: ${e.message}`); - process.exit(1); - } - rmSync(bundleDir, { recursive: true, force: true }); - - if (written === 0) { - console.error(`Nothing was installed: the bundle had no variants for ${targets.join(', ')}.`); - process.exit(1); - } - console.log(`Installed impeccable into: ${targets.join(', ')} (${scope === 'user' ? 'global' : 'project'})`); - reportProviderAgents(agentResults); - if (hookTargets.length > 0) console.log(`Installed hooks into: ${hookTargets.join(', ')}`); - - console.log('\nDone! Now type /impeccable init in your AI coding agent\'s chat (not in this terminal) to set up design context.\n'); -} - -// ─── skills update ──────────────────────────────────────────────────────────── - -function findProjectRoot() { - let dir = process.cwd(); - while (dir !== dirname(dir)) { - if (existsSync(join(dir, '.git'))) return dir; - dir = dirname(dir); - } - return process.cwd(); -} - -function findInstalledProviders(root, scope) { - const found = []; - for (const d of PROVIDER_DIRS) { - for (const skillsDir of existingSkillsDirs(root, d, scope)) { - try { - const entries = readdirSync(skillsDir); - if (entries.some(name => isSkillDir(skillsDir, name))) { - found.push(d); - break; - } - } catch {} - } - } - return found; -} - -// Like findInstalledProviders, but only counts a provider whose skills dir -// actually holds the IMPECCABLE skill (canonical, prefixed, or legacy teach-). -// `update` uses this so it never mistakes a repo that vendors OTHER first-party -// skills under .claude/skills for an impeccable install and drops a copy in -// (issue #399, part 2). -function findImpeccableProviders(root, scope) { - const found = []; - for (const d of PROVIDER_DIRS) { - for (const skillsDir of existingSkillsDirs(root, d, scope)) { - let entries; - try { entries = readdirSync(skillsDir); } catch { continue; } - if (entries.some(e => - e === 'impeccable' || e.endsWith('-impeccable') || - e === 'teach-impeccable' || e.endsWith('-teach-impeccable') - )) { - found.push(d); - break; - } - } - } - return found; -} - -// Resolve which install `skills update` should refresh: project-level (the CWD's -// git root) or user-level (~/.claude etc.). Returns a plain descriptor; the -// caller handles the interactive both-exist prompt via `ambiguous`. -function resolveUpdateTarget({ projectRoot, home, explicitScope }) { - // A home-rooted repo (a dotfiles checkout at $HOME) overlaps project and user - // installs under one root; keep the historical unscoped scan so overlapping - // layouts (e.g. Pi's ~/.pi/skills and ~/.pi/agent/skills) both refresh. - const homeRooted = isHomeDir(projectRoot); - if (homeRooted && !explicitScope) { - const providers = findInstalledProviders(home); - return providers.length - ? { root: home, scope: undefined, agentScope: 'user', providers, scopeLabel: 'user level' } - : null; - } - - const projectProviders = homeRooted ? [] : findImpeccableProviders(projectRoot, 'project'); - const userProviders = findImpeccableProviders(home, 'user'); - - if (explicitScope === 'user') { - return userProviders.length - ? { root: home, scope: 'user', providers: userProviders, scopeLabel: 'user level' } - : null; - } - if (explicitScope === 'project') { - return projectProviders.length - ? { root: projectRoot, scope: 'project', providers: projectProviders, scopeLabel: 'this project' } - : null; - } - if (projectProviders.length && userProviders.length) { - return { ambiguous: true, projectRoot, home, projectProviders, userProviders }; - } - if (projectProviders.length) { - return { root: projectRoot, scope: 'project', providers: projectProviders, scopeLabel: 'this project' }; - } - if (userProviders.length) { - return { root: home, scope: 'user', providers: userProviders, scopeLabel: 'user level' }; - } - return null; -} - -function findLinkedProviders(root, providers, scope) { - return providers.filter(provider => { - for (const skillsDir of providerSkillsDirCandidates(root, provider, scope)) { - const skillDir = join(skillsDir, 'impeccable'); - try { - if (lstatSync(skillDir).isSymbolicLink()) return true; - } catch {} - } - return false; - }); -} - -function getModifiedSkillFiles(root, providerDirs) { - // Use git to check if any skill files have local modifications - const modified = []; - try { - const status = execSync('git status --porcelain', { cwd: root, encoding: 'utf8' }); - for (const line of status.split('\n')) { - if (!line.trim()) continue; - const file = line.substring(3); - for (const d of providerDirs) { - if (file.startsWith(`${d}/skills/`)) { - const flag = line.substring(0, 2).trim(); - modified.push({ file, flag }); - } - } - } - } catch { - // Not a git repo or git not available - } - return modified; -} - -async function downloadFile(url, dest, { fetchImpl = globalThis.fetch } = {}) { - let current = url; - let hopsLeft = 5; - while (true) { - const parsed = new URL(current); - if (parsed.protocol !== 'https:') { - throw new Error('Refusing non-HTTPS URL'); - } - const res = await fetchImpl(current, { redirect: 'manual' }); - if (res.status >= 300 && res.status < 400) { - const location = res.headers.get('location'); - if (!location) throw new Error(`HTTP ${res.status}`); - if (hopsLeft <= 0) throw new Error('Too many redirects'); - hopsLeft -= 1; - current = new URL(location, current).href; - continue; - } - if (res.status !== 200) { - throw new Error(`HTTP ${res.status}`); - } - if (!res.body) throw new Error('Empty response body'); - try { - await pipeline(Readable.fromWeb(res.body), createWriteStream(dest, { flags: 'wx' })); - } catch (e) { - if (e.code !== 'EEXIST') rmSync(dest, { force: true }); - throw e; - } - return; - } -} - -async function update(flags = []) { - const yes = flags.includes('-y') || flags.includes('--yes'); - const force = flags.includes('--force'); - const installHooks = !flags.includes('--no-hooks'); - const scopeValue = getInstallScopeValue(flags); - const explicitScope = normalizeInstallScope(scopeValue); - if (scopeValue && !explicitScope) { - console.error(`Unknown update scope: ${scopeValue}. Use --project or --user.`); - process.exit(1); - } - - // Download the latest skills directly from impeccable.style. - // We skip `npx skills update` because it has a known upstream bug - // (vercel-labs/skills#775) where it can't find the lock file. - const projectRoot = findProjectRoot(); - const home = homedir(); - - let target = resolveUpdateTarget({ projectRoot, home, explicitScope }); - if (!target) { - if (explicitScope) { - const where = explicitScope === 'user' ? `user level (${formatPathForDisplay(home)})` : `this project (${projectRoot})`; - console.log(`No impeccable skill folders found at the ${where}.`); - } else { - console.log('No impeccable skill folders found in this project or at the user level.'); - } - console.log('Run `npx impeccable install` to install first.'); - process.exit(1); - } - - // Both a project and a user-level install exist and no scope was given. Never - // silently pick (issue #399, part 2): prompt when interactive, else default to - // the project and say how to target the other. - if (target.ambiguous) { - console.log('Impeccable is installed both here and at the user level:'); - console.log(` project ${projectRoot} (${target.projectProviders.join(', ')})`); - console.log(` user level ${formatPathForDisplay(home)} (${target.userProviders.join(', ')})`); - let pickUser = false; - if (!yes && process.stdin.isTTY) { - const ans = await ask('Update which? [project]/user: '); - pickUser = ['user', 'u', 'global', 'home'].includes(ans); - } else { - console.log('Defaulting to the project. Re-run with --user to update the user-level install instead.'); - } - target = pickUser - ? { root: home, scope: 'user', providers: target.userProviders, scopeLabel: 'user level' } - : { root: projectRoot, scope: 'project', providers: target.projectProviders, scopeLabel: 'this project' }; - } - - const { root, scope, agentScope = scope } = target; - console.log(`Updating the ${target.scopeLabel} install: ${formatPathForDisplay(root)} (${target.providers.join(', ')})`); - const providers = target.providers; - const linkedProviders = findLinkedProviders(root, providers, scope); - const copyProviders = providers.filter(provider => !linkedProviders.includes(provider)); - - if (linkedProviders.length > 0) { - console.log(`Linked skills found in: ${linkedProviders.join(', ')}`); - console.log('Update the source checkout with `git submodule update --remote`, then rerun `npx impeccable link --source=.impeccable` if new skills are added.'); - if (copyProviders.length === 0) process.exit(0); - console.log(`Continuing with copied installs in: ${copyProviders.join(', ')}\n`); - } - - console.log('Checking for updates...'); - - let tmpDir; - try { - tmpDir = await downloadAndExtractBundle(); - } catch (e) { - console.error(`Download failed: ${e.message}`); - process.exit(1); - } - - // Compare local vs remote -- skip if already up to date - if (isUpToDate(root, copyProviders, tmpDir, scope, agentScope)) { - try { - const wantHooks = installHooks && await decideHookInstall(root, copyProviders, { yes }); - const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, copyProviders, { force }) : []; - rmSync(tmpDir, { recursive: true, force: true }); - const v = getSkillsVersion(root, scope); - console.log(`Skills are up to date${v ? ` (v${v})` : ''}.`); - if (hookTargets.length > 0) console.log(`Installed hooks into: ${hookTargets.join(', ')}`); - console.log('Nothing else to do.'); - process.exit(0); - } catch (e) { - rmSync(tmpDir, { recursive: true, force: true }); - console.error(`Update failed: ${e.message}`); - process.exit(1); - } - } - - console.log(`Found skills in: ${copyProviders.join(', ')}`); - - if (!yes) { - const ans = await ask(`Update skills in ${copyProviders.length} provider folder(s)? (Y/n) `); - if (ans === 'n' || ans === 'no') { - rmSync(tmpDir, { recursive: true, force: true }); - console.log('Aborted.'); - process.exit(0); - } - } - - try { - - // Retire any old `i-`-prefixed install up front so the refresh lands on the - // canonical `impeccable` dir rather than orphaning the prefixed copy. - const migrated = migrateUnprefixImpeccable(root, scope); - if (migrated > 0) console.log('Migrated a prefixed install back to /impeccable (the i- prefix is no longer used).'); - - const updated = refreshProviderSkills(tmpDir, root, copyProviders, scope); - reportProviderAgents(copyProviderAgents(tmpDir, root, copyProviders, { scope: agentScope })); - const wantHooks = installHooks && await decideHookInstall(root, providers, { yes }); - const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, providers, { force }) : []; - - rmSync(tmpDir, { recursive: true, force: true }); - - const v = getSkillsVersion(root, scope); - console.log(`Updated ${updated} skill(s)${v ? ` to v${v}` : ''}.`); - if (hookTargets.length > 0) console.log(`Installed hooks into: ${hookTargets.join(', ')}`); - console.log('Done!\n'); - } catch (e) { - console.error(`Update failed: ${e.message}`); - if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); - process.exit(1); - } -} - -function copyDirSync(src, dest) { - mkdirSync(dest, { recursive: true }); - for (const entry of readdirSync(src, { withFileTypes: true })) { - const s = join(src, entry.name); - const d = join(dest, entry.name); - if (entry.isDirectory()) { - copyDirSync(s, d); - } else { - writeFileSync(d, readFileSync(s)); - } - } -} - -// ─── Test surface ─────────────────────────────────────────────────────────── -// Exported so the test suite exercises the real implementation rather than a -// reimplementation in a helper script (which is how bugs slip through). -export { - collectInstallDetections, - copyProviderAgents, - copyProviderHooks, - copyProviderSkills, - decideHookInstall, - downloadAndExtractBundle, - downloadFile, - expectedHookDests, - extractZip, - formatInstallDetectionLines, - hermesGlobalHome, - HOME_SKILLS_DIR_OVERRIDES, - linkProviderSkills, - mergeHookManifests, - migrateUnprefixImpeccable, - resolveInstallTargets, - resolveLinkSource, -}; - -// ─── Router ─────────────────────────────────────────────────────────────────── - -export async function run(args) { - const sub = args[0]; - - if (!sub || sub === 'help' || sub === '--help' || sub === '-h') { - await showHelp(); - } else if (sub === 'install') { - await install(args.slice(1)); - } else if (sub === 'link') { - await link(args.slice(1)); - } else if (sub === 'update') { - await update(args.slice(1)); - } else if (sub === 'check') { - await check(); - } else { - console.error(`Unknown skills command: ${sub}`); - console.error(`Run 'impeccable --help' for available commands.`); - process.exit(1); - } -} diff --git a/cli/engine/browser/injected/index.mjs b/cli/engine/browser/injected/index.mjs deleted file mode 100644 index febf7f297..000000000 --- a/cli/engine/browser/injected/index.mjs +++ /dev/null @@ -1,2204 +0,0 @@ -const IS_BROWSER = typeof window !== 'undefined'; - -// ─── Section 7: Browser UI (IS_BROWSER only) ──────────────────────────────── - -if (IS_BROWSER) { - // Detect extension mode via the script tag's data attribute or the document element fallback. - // currentScript is reliable for synchronously-executing scripts (which our IIFE is). - const _myScript = document.currentScript; - const EXTENSION_MODE = (_myScript && _myScript.dataset.impeccableExtension === 'true') - || document.documentElement.dataset.impeccableExtension === 'true'; - - // Kinpaku gold — pinned to the site's brand token (see - // site/styles/kinpaku-tokens.css --ks-kinpaku). Keep this in sync with - // the picker's C.brand in skill/scripts/live-browser.js and the kit's - // picker section in site/styles/kinpaku-kit.css. - // - // One color across both light and dark host pages. The outline is a - // 2px gesture pointing at an element + a labeled tag — it's a marker, - // not body text, so it doesn't need WCAG AA against the page. The - // label text inside the gold tag is dark (LABEL_INK) which has ~16:1 - // against the leaf gold, so reading the rule name is solid in both - // modes. Hover deepens the gold (preserves chroma — never drops it, - // dropping chroma washes the gold into a sand/olive tone). - const BRAND_COLOR = 'oklch(84% 0.19 80.46)'; - const BRAND_COLOR_HOVER = 'oklch(74% 0.18 80)'; - const LABEL_INK = 'oklch(4% 0.004 95)'; - const LABEL_BG = BRAND_COLOR; - const OUTLINE_COLOR = BRAND_COLOR; - - // Inject hover styles via CSS (more reliable than JS event listeners) - const styleEl = document.createElement('style'); - styleEl.textContent = ` - @keyframes impeccable-reveal { - from { opacity: 0; } - to { opacity: 1; } - } - .impeccable-overlay:not(.impeccable-banner) { - pointer-events: none; - outline: 2px solid ${OUTLINE_COLOR}; - border-radius: 4px; - transition: outline-color 0.15s ease; - animation: impeccable-reveal 0.4s cubic-bezier(0.16, 1, 0.3, 1) both; - animation-play-state: paused; - border-top-left-radius: 0; - } - .impeccable-overlay.impeccable-visible { - animation-play-state: running; - } - .impeccable-overlay.impeccable-hover { - outline-color: ${BRAND_COLOR_HOVER}; - z-index: 100001 !important; - } - .impeccable-overlay.impeccable-hover .impeccable-label { - background: ${BRAND_COLOR_HOVER}; - } - .impeccable-overlay.impeccable-spotlight { - z-index: 100002 !important; - } - .impeccable-overlay.impeccable-spotlight-dimmed { - opacity: 0.15 !important; - animation: none !important; - filter: blur(3px); - } - .impeccable-spotlight-backdrop { - position: fixed; - top: 0; left: 0; right: 0; bottom: 0; - backdrop-filter: blur(3px) brightness(0.6); - -webkit-backdrop-filter: blur(3px) brightness(0.6); - pointer-events: none; - z-index: 99998; - opacity: 0; - outline: none !important; - animation: none !important; - } - .impeccable-spotlight-backdrop.impeccable-visible { - opacity: 1; - } - .impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} { - display: none !important; - } - `; - (document.head || document.documentElement).appendChild(styleEl); - - // Spotlight backdrop element (created lazily on first use) - let spotlightBackdrop = null; - let spotlightTarget = null; - - function getSpotlightBackdrop() { - if (!spotlightBackdrop) { - spotlightBackdrop = document.createElement('div'); - spotlightBackdrop.className = 'impeccable-spotlight-backdrop'; - document.body.appendChild(spotlightBackdrop); - } - return spotlightBackdrop; - } - - function updateSpotlightClipPath() { - if (!spotlightBackdrop || !spotlightTarget) return; - const r = spotlightTarget.getBoundingClientRect(); - // Match the overlay's outer edge: element rect + 4px (2px overlay offset + 2px outline width) - const inset = 4; - const radius = 6; // outline border-radius (4) + outline width (2) - const x1 = r.left - inset; - const y1 = r.top - inset; - const x2 = r.right + inset; - const y2 = r.bottom + inset; - const vw = window.innerWidth; - const vh = window.innerHeight; - // Outer rect + rounded inner rect (evenodd creates a hole) - const path = `M0 0H${vw}V${vh}H0Z M${x1 + radius} ${y1}H${x2 - radius}A${radius} ${radius} 0 0 1 ${x2} ${y1 + radius}V${y2 - radius}A${radius} ${radius} 0 0 1 ${x2 - radius} ${y2}H${x1 + radius}A${radius} ${radius} 0 0 1 ${x1} ${y2 - radius}V${y1 + radius}A${radius} ${radius} 0 0 1 ${x1 + radius} ${y1}Z`; - spotlightBackdrop.style.clipPath = `path(evenodd, "${path}")`; - } - - function showSpotlight(target) { - if (!target || !target.getBoundingClientRect) return; - // Respect the spotlightBlur setting: if disabled, don't show the backdrop - if (window.__IMPECCABLE_CONFIG__?.spotlightBlur === false) { - spotlightTarget = target; - return; - } - spotlightTarget = target; - const bd = getSpotlightBackdrop(); - updateSpotlightClipPath(); - bd.classList.add('impeccable-visible'); - } - - function hideSpotlight() { - spotlightTarget = null; - if (spotlightBackdrop) spotlightBackdrop.classList.remove('impeccable-visible'); - } - - function isInViewport(el) { - const r = el.getBoundingClientRect(); - return r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight && r.right <= window.innerWidth; - } - - // Reposition spotlight on scroll/resize - window.addEventListener('scroll', () => { - if (spotlightTarget) updateSpotlightClipPath(); - }, { passive: true }); - window.addEventListener('resize', () => { - if (spotlightTarget) updateSpotlightClipPath(); - }); - - const overlays = []; - const TYPE_LABELS = {}; - const RULE_CATEGORY = {}; - for (const ap of ANTIPATTERNS) { - TYPE_LABELS[ap.id] = ap.name.toLowerCase(); - RULE_CATEGORY[ap.id] = ap.category || 'quality'; - } - - function isInFixedContext(el) { - let p = el; - while (p && p !== document.body) { - if (getComputedStyle(p).position === 'fixed') return true; - p = p.parentElement; - } - return false; - } - - function positionOverlay(overlay) { - const el = overlay._targetEl; - if (!el) return; - const rect = el.getBoundingClientRect(); - if (overlay._isFixed) { - // Viewport-relative coords for fixed targets - overlay.style.top = `${rect.top - 2}px`; - overlay.style.left = `${rect.left - 2}px`; - } else { - // Document-relative coords for normal targets - overlay.style.top = `${rect.top + scrollY - 2}px`; - overlay.style.left = `${rect.left + scrollX - 2}px`; - } - overlay.style.width = `${rect.width + 4}px`; - overlay.style.height = `${rect.height + 4}px`; - } - - function repositionOverlays() { - for (const o of overlays) { - if (!o._targetEl || o.classList.contains('impeccable-banner')) continue; - // Skip overlays whose target is currently hidden (display: none on the overlay) - if (o.style.display === 'none') continue; - positionOverlay(o); - } - } - - let resizeRAF; - const onResize = () => { - cancelAnimationFrame(resizeRAF); - resizeRAF = requestAnimationFrame(repositionOverlays); - }; - window.addEventListener('resize', onResize); - // Reposition on scroll too -- catches sticky/parallax shifts - window.addEventListener('scroll', onResize, { passive: true }); - // Reposition when body resizes (lazy-loaded images, dynamic content, fonts loading) - if (typeof ResizeObserver !== 'undefined') { - const bodyResizeObserver = new ResizeObserver(onResize); - bodyResizeObserver.observe(document.body); - } - - // Track target element visibility via IntersectionObserver. - // Uses a huge rootMargin so all *rendered* elements count as intersecting, - // while display:none / closed
/ hidden modals etc. do not. - // This is event-driven -- no polling needed. - let overlayIndex = 0; - const visibilityObserver = new IntersectionObserver((entries) => { - for (const entry of entries) { - const overlay = entry.target._impeccableOverlay; - if (!overlay) continue; - if (entry.isIntersecting) { - overlay.style.display = ''; - positionOverlay(overlay); - if (!overlay._revealed) { - overlay._revealed = true; - if (firstScanDone) { - // Subsequent reveals (re-scans, scroll-into-view): instant, no animation - overlay.style.animation = 'none'; - } else { - // Initial scan: staggered cascade reveal - overlay.style.animationDelay = `${Math.min((overlay._staggerIndex || 0) * 60, 600)}ms`; - } - requestAnimationFrame(() => { - overlay.classList.add('impeccable-visible'); - if (overlay._checkLabel) overlay._checkLabel(); - }); - } - } else { - overlay.style.display = 'none'; - } - } - }, { rootMargin: '99999px' }); - - function detachOverlay(overlay) { - if (!overlay) return; - if (typeof overlay._cleanup === 'function') { - try { overlay._cleanup(); } catch { /* best effort overlay teardown */ } - } - if (overlay._targetEl && overlay._targetEl._impeccableOverlay === overlay) { - visibilityObserver.unobserve(overlay._targetEl); - delete overlay._targetEl._impeccableOverlay; - } - const idx = overlays.indexOf(overlay); - if (idx >= 0) overlays.splice(idx, 1); - overlay.remove(); - } - - // Reposition overlays after CSS transitions end (e.g. reveal animations). - // Listens at document level so it catches transitions on ancestor elements - // (the transform may be on a parent, not the flagged element itself). - document.addEventListener('transitionend', (e) => { - if (e.propertyName !== 'transform') return; - for (const o of overlays) { - if (!o._targetEl || o.classList.contains('impeccable-banner') || o.style.display === 'none') continue; - if (e.target === o._targetEl || e.target.contains(o._targetEl)) { - positionOverlay(o); - } - } - }); - - const highlight = function(el, findings) { - if (el._impeccableOverlay) detachOverlay(el._impeccableOverlay); - const hasSlop = findings.some(f => RULE_CATEGORY[f.type || f.id] === 'slop'); - - const fixed = isInFixedContext(el); - const rect = el.getBoundingClientRect(); - const outline = document.createElement('div'); - outline.className = 'impeccable-overlay'; - outline._targetEl = el; - outline._isFixed = fixed; - Object.assign(outline.style, { - position: fixed ? 'fixed' : 'absolute', - top: fixed ? `${rect.top - 2}px` : `${rect.top + scrollY - 2}px`, - left: fixed ? `${rect.left - 2}px` : `${rect.left + scrollX - 2}px`, - width: `${rect.width + 4}px`, height: `${rect.height + 4}px`, - zIndex: '99999', boxSizing: 'border-box', - }); - - // Build per-finding label entries: ✦ prefix for slop - const entries = findings.map(f => { - const name = TYPE_LABELS[f.type || f.id] || f.type || f.id; - const prefix = RULE_CATEGORY[f.type || f.id] === 'slop' ? '\u2726 ' : ''; - return { name: prefix + name, detail: f.detail || f.snippet }; - }); - const allText = entries.map(e => e.name).join(', '); - - const label = document.createElement('div'); - label.className = 'impeccable-label'; - Object.assign(label.style, { - position: 'absolute', bottom: '100%', left: '-2px', - display: 'flex', alignItems: 'center', - whiteSpace: 'nowrap', - fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em', - color: LABEL_INK, lineHeight: '14px', - background: LABEL_BG, - fontFamily: 'system-ui, sans-serif', - borderRadius: '4px 4px 0 0', - }); - - const textSpan = document.createElement('span'); - textSpan.style.padding = '3px 8px'; - textSpan.textContent = allText; - label.appendChild(textSpan); - - // State for cycling mode - let cycleMode = false; - let cycleIndex = 0; - let isHovered = false; - let prevBtn, nextBtn; - - function updateCycleText() { - const e = entries[cycleIndex]; - textSpan.textContent = isHovered ? e.detail : e.name; - } - - function enableCycleMode() { - if (cycleMode || entries.length < 2) return; - cycleMode = true; - - const btnStyle = { - background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)', - fontSize: '11px', cursor: 'pointer', padding: '3px 4px', - fontFamily: 'system-ui, sans-serif', lineHeight: '14px', - pointerEvents: 'auto', - }; - - const navGroup = document.createElement('span'); - Object.assign(navGroup.style, { - display: 'inline-flex', alignItems: 'center', flexShrink: '0', - }); - - prevBtn = document.createElement('button'); - prevBtn.textContent = '\u2039'; - Object.assign(prevBtn.style, btnStyle); - prevBtn.style.paddingLeft = '6px'; - prevBtn.addEventListener('click', (e) => { - e.stopPropagation(); - cycleIndex = (cycleIndex - 1 + entries.length) % entries.length; - updateCycleText(); - }); - - nextBtn = document.createElement('button'); - nextBtn.textContent = '\u203A'; - Object.assign(nextBtn.style, btnStyle); - nextBtn.style.paddingRight = '2px'; - nextBtn.addEventListener('click', (e) => { - e.stopPropagation(); - cycleIndex = (cycleIndex + 1) % entries.length; - updateCycleText(); - }); - - navGroup.appendChild(prevBtn); - navGroup.appendChild(nextBtn); - label.insertBefore(navGroup, textSpan); - textSpan.style.padding = '3px 8px 3px 4px'; - updateCycleText(); - } - - outline.appendChild(label); - - // Start hidden; the IntersectionObserver will show it once the target is rendered - outline.style.display = 'none'; - outline._staggerIndex = overlayIndex++; - el._impeccableOverlay = outline; - visibilityObserver.observe(el); - - // After first paint, check label width vs outline - outline._checkLabel = () => { - if (entries.length > 1 && label.offsetWidth > outline.offsetWidth) { - enableCycleMode(); - } - }; - - // Hover: show detail text, darken - const onMouseEnter = () => { - isHovered = true; - outline.classList.add('impeccable-hover'); - outline.style.outlineColor = BRAND_COLOR_HOVER; - label.style.background = BRAND_COLOR_HOVER; - if (cycleMode) { - updateCycleText(); - } else { - textSpan.textContent = entries.map(e => e.detail).join(' | '); - } - }; - const onMouseLeave = () => { - isHovered = false; - outline.classList.remove('impeccable-hover'); - outline.style.outlineColor = ''; - label.style.background = LABEL_BG; - if (cycleMode) { - updateCycleText(); - } else { - textSpan.textContent = allText; - } - }; - el.addEventListener('mouseenter', onMouseEnter); - el.addEventListener('mouseleave', onMouseLeave); - outline._cleanup = () => { - el.removeEventListener('mouseenter', onMouseEnter); - el.removeEventListener('mouseleave', onMouseLeave); - }; - - document.body.appendChild(outline); - overlays.push(outline); - }; - - const showPageBanner = function(findings) { - if (!findings.length) return; - const banner = document.createElement('div'); - banner.className = 'impeccable-overlay impeccable-banner'; - Object.assign(banner.style, { - position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000', - background: LABEL_BG, color: LABEL_INK, - fontFamily: 'system-ui, sans-serif', fontSize: '13px', - display: 'flex', alignItems: 'center', pointerEvents: 'auto', - height: '36px', overflow: 'hidden', maxWidth: '100vw', - transform: 'translateY(-100%)', - transition: 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)', - }); - requestAnimationFrame(() => requestAnimationFrame(() => { - banner.style.transform = 'translateY(0)'; - })); - - // Scrollable findings area - const scrollArea = document.createElement('div'); - Object.assign(scrollArea.style, { - flex: '1', minWidth: '0', overflowX: 'auto', overflowY: 'hidden', - display: 'flex', gap: '8px', alignItems: 'center', - padding: '0 12px', scrollSnapType: 'x mandatory', - scrollbarWidth: 'none', - }); - for (const f of findings) { - const prefix = RULE_CATEGORY[f.type] === 'slop' ? '\u2726 ' : ''; - const tag = document.createElement('span'); - tag.textContent = `${prefix}${TYPE_LABELS[f.type] || f.type}: ${f.detail}`; - Object.assign(tag.style, { - background: 'rgba(255,255,255,0.15)', padding: '2px 8px', - borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace', - whiteSpace: 'nowrap', flexShrink: '0', scrollSnapAlign: 'start', - }); - scrollArea.appendChild(tag); - } - banner.appendChild(scrollArea); - - // Controls area (only in standalone mode, not extension) - if (!EXTENSION_MODE) { - const controls = document.createElement('div'); - Object.assign(controls.style, { - display: 'flex', alignItems: 'center', gap: '2px', - padding: '0 8px', flexShrink: '0', - }); - - // Toggle visibility button - const toggle = document.createElement('button'); - toggle.textContent = '\u25C9'; // circle with dot (visible state) - toggle.title = 'Toggle overlay visibility'; - Object.assign(toggle.style, { - background: 'none', border: 'none', - color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px', - opacity: '0.85', transition: 'opacity 0.15s', - }); - let overlaysVisible = true; - toggle.addEventListener('click', () => { - overlaysVisible = !overlaysVisible; - document.body.classList.toggle('impeccable-hidden', !overlaysVisible); - toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle - toggle.style.opacity = overlaysVisible ? '0.85' : '0.5'; - }); - controls.appendChild(toggle); - - // Close button - const close = document.createElement('button'); - close.textContent = '\u00d7'; - close.title = 'Dismiss banner'; - Object.assign(close.style, { - background: 'none', border: 'none', - color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px', - }); - close.addEventListener('click', () => banner.remove()); - controls.appendChild(close); - - banner.appendChild(controls); - } - document.body.appendChild(banner); - overlays.push(banner); - }; - - // Heuristic for skipping CSS-in-JS hashed class names like "css-1a2b3c" or "_2x4hG_". - // These change between builds and produce brittle, ugly selectors. - function isLikelyHashedClass(c) { - if (!c) return true; - if (/^(css|sc|emotion|jsx|module)-[\w-]{4,}$/i.test(c)) return true; - if (/^_[\w-]{5,}$/.test(c)) return true; - if (/^[a-z0-9]{6,}$/i.test(c) && /\d/.test(c)) return true; - return false; - } - - function buildSelectorSegment(el) { - const tag = el.tagName.toLowerCase(); - let sel = tag; - - if (el.classList && el.classList.length > 0) { - const classes = [...el.classList] - .filter(c => !c.startsWith('impeccable-') && !isLikelyHashedClass(c)) - .slice(0, 2); - if (classes.length > 0) { - sel += '.' + classes.map(c => CSS.escape(c)).join('.'); - } - } - - // Disambiguate among siblings only if the parent has multiple matches - const parent = el.parentElement; - if (parent) { - try { - const matching = parent.querySelectorAll(':scope > ' + sel); - if (matching.length > 1) { - const sameType = [...parent.children].filter(c => c.tagName === el.tagName); - const idx = sameType.indexOf(el) + 1; - sel += `:nth-of-type(${idx})`; - } - } catch { - const idx = [...parent.children].indexOf(el) + 1; - sel = `${tag}:nth-child(${idx})`; - } - } - return sel; - } - - function generateSelector(el) { - if (el === document.body) return 'body'; - if (el === document.documentElement) return 'html'; - // Read via getAttribute when `el.id` is not a string — a
with a - // named control (e.g. ) shadows the builtin getter and - // returns the element, producing a garbage `#[object …]` selector (#407). - const elId = typeof el.id === 'string' ? el.id : (el.getAttribute('id') || ''); - if (elId) return '#' + CSS.escape(elId); - - const parts = []; - let current = el; - let depth = 0; - const MAX_DEPTH = 10; - - while (current && current !== document.body && current !== document.documentElement && depth < MAX_DEPTH) { - parts.unshift(buildSelectorSegment(current)); - - // Anchor on an ancestor's ID and stop walking up - if (current.id) { - parts[0] = '#' + CSS.escape(current.id); - break; - } - - // Stop as soon as the partial selector uniquely identifies the target - const trySelector = parts.join(' > '); - try { - const matches = document.querySelectorAll(trySelector); - if (matches.length === 1 && matches[0] === el) { - return trySelector; - } - } catch { /* invalid selector — keep walking */ } - - current = current.parentElement; - depth++; - } - - return parts.join(' > '); - } - - function getDirectText(el) { - return [...el.childNodes] - .filter(n => n.nodeType === 3) - .map(n => n.textContent || '') - .join(''); - } - - function getDirectTextRect(el) { - const rects = []; - for (const node of el.childNodes) { - if (node.nodeType !== 3 || !(node.textContent || '').trim()) continue; - const range = document.createRange(); - range.selectNodeContents(node); - for (const rect of range.getClientRects()) { - if (rect.width >= 1 && rect.height >= 1) rects.push(rect); - } - range.detach?.(); - } - if (rects.length === 0) return null; - const left = Math.min(...rects.map(r => r.left)); - const top = Math.min(...rects.map(r => r.top)); - const right = Math.max(...rects.map(r => r.right)); - const bottom = Math.max(...rects.map(r => r.bottom)); - return { - left, - top, - right, - bottom, - width: right - left, - height: bottom - top, - x: left, - y: top, - }; - } - - function collectVisualContrastReasons(el, style) { - const reasons = new Set(); - const bgClip = style.webkitBackgroundClip || style.backgroundClip || ''; - const ownBgImage = style.backgroundImage || ''; - if (bgClip === 'text' && ownBgImage && ownBgImage !== 'none') { - reasons.add('background-clip text'); - } - if (style.textShadow && style.textShadow !== 'none') reasons.add('text shadow'); - - let current = el; - while (current && current.nodeType === 1) { - const tag = current.tagName?.toLowerCase(); - const currentStyle = getComputedStyle(current); - const bgImage = currentStyle.backgroundImage || ''; - const isDocumentSurface = tag === 'body' || tag === 'html'; - - if (!isDocumentSurface && bgImage && bgImage !== 'none') { - if (/url\s*\(/i.test(bgImage)) reasons.add('image background'); - if (/gradient/i.test(bgImage)) reasons.add('gradient background'); - } - if (parseFloat(currentStyle.opacity) < 0.99) reasons.add('opacity stack'); - if (currentStyle.mixBlendMode && currentStyle.mixBlendMode !== 'normal') reasons.add('blend mode'); - if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter'); - if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter'); - - const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor); - if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break; - current = current.parentElement; - } - - const sampleRect = getDirectTextRect(el) || el.getBoundingClientRect(); - if (sampleRect && document.elementsFromPoint) { - const points = [ - [sampleRect.left + sampleRect.width / 2, sampleRect.top + sampleRect.height / 2], - [sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.25)), sampleRect.top + sampleRect.height / 2], - [sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.75)), sampleRect.top + sampleRect.height / 2], - ]; - for (const [x, y] of points) { - if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) continue; - const stack = document.elementsFromPoint(x, y); - const selfIndex = stack.findIndex(node => node === el || el.contains(node) || node.contains?.(el)); - if (selfIndex < 0) continue; - for (const node of stack.slice(selfIndex + 1)) { - const nodeTag = node.tagName?.toLowerCase(); - if (nodeTag === 'img' || nodeTag === 'picture' || nodeTag === 'video' || nodeTag === 'canvas' || nodeTag === 'svg') { - reasons.add(`${nodeTag} underlay`); - break; - } - } - } - } - - return [...reasons]; - } - - function collectVisualContrastCandidates(options = {}) { - const maxCandidates = Number.isFinite(options.maxCandidates) ? options.maxCandidates : 12; - const candidates = []; - for (const el of document.querySelectorAll('*')) { - if (candidates.length >= maxCandidates) break; - if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; - if (el.closest('[id^="impeccable-live-"]')) continue; - if (el === document.body || el === document.documentElement) continue; - if (!isRenderedForBrowserRule(el)) continue; - - const tag = el.tagName.toLowerCase(); - const style = getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') continue; - const directText = getDirectText(el); - const hasDirectText = directText.trim().length > 0; - if (!hasDirectText || isEmojiOnlyText(directText)) continue; - - const bgColor = readOwnBackgroundColor(el, style); - const isStyledButton = (tag === 'a' || tag === 'button') - && bgColor && bgColor.a > 0.5; - if (SAFE_TAGS.has(tag) && !isStyledButton) continue; - - const rect = getDirectTextRect(el) || el.getBoundingClientRect(); - if (!rect || rect.width < 4 || rect.height < 4) continue; - - const reasons = collectVisualContrastReasons(el, style); - if (reasons.length === 0) continue; - // Image-only mode filters here, inside the cap: gradient/opacity/filter - // candidates earlier in DOM order must not consume the budget and - // starve the url()-backed texts this mode exists to sample. - if (options.imageOnly && !reasons.includes('image background')) continue; - - const textColor = parseRgb(style.color) || parseAnyColor(style.color); - const fontSize = parseFloat(style.fontSize) || 16; - const fontWeight = parseInt(style.fontWeight) || 400; - const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700); - const threshold = isLargeText ? 3.0 : 4.5; - const clip = { - x: Math.max(0, Math.floor(rect.left + window.scrollX - 2)), - y: Math.max(0, Math.floor(rect.top + window.scrollY - 2)), - width: Math.max(1, Math.ceil(rect.width + 4)), - height: Math.max(1, Math.ceil(rect.height + 4)), - }; - - candidates.push({ - selector: generateSelector(el), - tagName: tag, - text: directText.trim().replace(/\s+/g, ' ').slice(0, 80), - threshold, - reasons, - clip, - textColor, - preferRenderedForeground: !textColor || textColor.a < 0.99 || reasons.some(reason => - reason === 'opacity stack' || - reason === 'blend mode' || - reason === 'filter' || - reason === 'backdrop filter' || - reason === 'background-clip text' - ), - backgroundClipText: reasons.includes('background-clip text'), - }); - } - return candidates; - } - - const visualContrastImageCache = new Map(); - const visualContrastRasterCache = new WeakMap(); - - function clampByte(value) { - return Math.max(0, Math.min(255, Math.round(value))); - } - - function blendRgba(fg, bg) { - if (!fg) return bg || null; - if (!bg || fg.a == null || fg.a >= 0.999) { - return { r: clampByte(fg.r), g: clampByte(fg.g), b: clampByte(fg.b), a: fg.a == null ? 1 : fg.a }; - } - const alpha = Math.max(0, Math.min(1, fg.a)); - return { - r: clampByte(fg.r * alpha + bg.r * (1 - alpha)), - g: clampByte(fg.g * alpha + bg.g * (1 - alpha)), - b: clampByte(fg.b * alpha + bg.b * (1 - alpha)), - a: 1, - }; - } - - function pickWorstContrastColor(textColor, colors) { - const usable = (colors || []).filter(Boolean); - if (!usable.length) return null; - let worst = usable[0]; - let worstRatio = contrastRatio(textColor, worst); - for (const color of usable.slice(1)) { - const ratio = contrastRatio(textColor, color); - if (ratio < worstRatio) { - worst = color; - worstRatio = ratio; - } - } - return worst; - } - - function firstCssUrl(value) { - const match = String(value || '').match(/url\((?:"([^"]+)"|'([^']+)'|([^)]*))\)/i); - if (!match) return ''; - return (match[1] || match[2] || match[3] || '').trim(); - } - - function getLayerValue(value, index = 0) { - return String(value || '').split(',')[index]?.trim() || ''; - } - - function parsePositionToken(token, container, painted) { - if (!token || token === 'center') return (container - painted) / 2; - if (token === 'left' || token === 'top') return 0; - if (token === 'right' || token === 'bottom') return container - painted; - if (/%$/.test(token)) { - const pct = parseFloat(token) / 100; - return (container - painted) * pct; - } - if (/px$/.test(token)) return parseFloat(token) || 0; - return (container - painted) / 2; - } - - function parsePositionPair(positionValue) { - const tokens = String(positionValue || '50% 50%').trim().split(/\s+/).filter(Boolean); - const first = tokens[0] || '50%'; - if (tokens.length < 2) { - if (first === 'top' || first === 'bottom') return ['50%', first]; - return [first, '50%']; - } - return [first, tokens[1] || '50%']; - } - - function resolvePaintedImageRect(containerRect, image, sizeValue, positionValue) { - const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1; - const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1; - let paintedWidth = intrinsicWidth; - let paintedHeight = intrinsicHeight; - const size = String(sizeValue || 'auto').trim(); - - if (size === 'cover' || size === 'contain') { - const scale = size === 'cover' - ? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight) - : Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight); - paintedWidth = intrinsicWidth * scale; - paintedHeight = intrinsicHeight * scale; - } else if (size && size !== 'auto') { - const parts = size.split(/\s+/); - const widthToken = parts[0]; - const heightToken = parts[1] || 'auto'; - if (/%$/.test(widthToken)) paintedWidth = containerRect.width * (parseFloat(widthToken) / 100); - else if (/px$/.test(widthToken)) paintedWidth = parseFloat(widthToken) || paintedWidth; - if (heightToken === 'auto') paintedHeight = paintedWidth * (intrinsicHeight / intrinsicWidth); - else if (/%$/.test(heightToken)) paintedHeight = containerRect.height * (parseFloat(heightToken) / 100); - else if (/px$/.test(heightToken)) paintedHeight = parseFloat(heightToken) || paintedHeight; - } - - const [xToken, yToken] = parsePositionPair(positionValue); - const positionX = parsePositionToken(xToken, containerRect.width, paintedWidth); - const positionY = parsePositionToken(yToken, containerRect.height, paintedHeight); - return { - left: containerRect.left + positionX, - top: containerRect.top + positionY, - width: paintedWidth, - height: paintedHeight, - intrinsicWidth, - intrinsicHeight, - }; - } - - function parseObjectPosition(positionValue) { - return parsePositionPair(positionValue); - } - - function resolveObjectImageRect(containerRect, image, style) { - const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1; - const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1; - const fit = style.objectFit || 'fill'; - let paintedWidth = containerRect.width; - let paintedHeight = containerRect.height; - if (fit === 'contain' || fit === 'cover') { - const scale = fit === 'cover' - ? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight) - : Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight); - paintedWidth = intrinsicWidth * scale; - paintedHeight = intrinsicHeight * scale; - } else if (fit === 'none') { - paintedWidth = intrinsicWidth; - paintedHeight = intrinsicHeight; - } else if (fit === 'scale-down') { - const containScale = Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight, 1); - paintedWidth = intrinsicWidth * containScale; - paintedHeight = intrinsicHeight * containScale; - } - const [xToken, yToken] = parseObjectPosition(style.objectPosition); - return { - left: containerRect.left + parsePositionToken(xToken, containerRect.width, paintedWidth), - top: containerRect.top + parsePositionToken(yToken, containerRect.height, paintedHeight), - width: paintedWidth, - height: paintedHeight, - intrinsicWidth, - intrinsicHeight, - }; - } - - function pointToImageSource(point, paintedRect) { - if ( - point.x < paintedRect.left || - point.y < paintedRect.top || - point.x > paintedRect.left + paintedRect.width || - point.y > paintedRect.top + paintedRect.height - ) { - return null; - } - return { - x: Math.max(0, Math.min(paintedRect.intrinsicWidth - 1, ((point.x - paintedRect.left) / paintedRect.width) * paintedRect.intrinsicWidth)), - y: Math.max(0, Math.min(paintedRect.intrinsicHeight - 1, ((point.y - paintedRect.top) / paintedRect.height) * paintedRect.intrinsicHeight)), - }; - } - - async function loadVisualContrastImage(src) { - if (!src) return null; - if (visualContrastImageCache.has(src)) return visualContrastImageCache.get(src); - const promise = new Promise(resolve => { - const img = new Image(); - let settled = false; - const finish = value => { - if (settled) return; - settled = true; - clearTimeout(timer); - resolve(value); - }; - const timer = setTimeout(() => finish(null), 800); - try { - const absolute = new URL(src, location.href); - if (absolute.origin !== location.origin && absolute.protocol !== 'data:' && absolute.protocol !== 'blob:') { - img.crossOrigin = 'anonymous'; - } - } catch { - // Let the browser resolve unusual URLs itself. - } - img.onload = () => finish(img); - img.onerror = () => finish(null); - img.src = src; - }); - visualContrastImageCache.set(src, promise); - return promise; - } - - function sampleDrawablePixel(drawable, sourcePoint) { - if (visualContrastRasterCache.has(drawable)) { - const cached = visualContrastRasterCache.get(drawable); - if (!cached || !cached.ctx) return { status: 'unresolved', reason: cached?.reason || 'image sample failed' }; - try { - const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX))); - const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY))); - const data = cached.ctx.getImageData(x, y, 1, 1).data; - return { - status: 'sampled', - color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 }, - }; - } catch (err) { - return { - status: 'unresolved', - reason: /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed', - }; - } - } - - const canvas = document.createElement('canvas'); - const intrinsicWidth = drawable.naturalWidth || drawable.videoWidth || drawable.width || 1; - const intrinsicHeight = drawable.naturalHeight || drawable.videoHeight || drawable.height || 1; - const maxRasterSide = 640; - const scale = Math.min(1, maxRasterSide / Math.max(intrinsicWidth, intrinsicHeight)); - canvas.width = Math.max(1, Math.round(intrinsicWidth * scale)); - canvas.height = Math.max(1, Math.round(intrinsicHeight * scale)); - const ctx = canvas.getContext('2d', { willReadFrequently: true }); - if (!ctx) return { status: 'unresolved', reason: 'canvas unavailable' }; - try { - ctx.drawImage(drawable, 0, 0, canvas.width, canvas.height); - const cached = { - ctx, - width: canvas.width, - height: canvas.height, - scaleX: canvas.width / intrinsicWidth, - scaleY: canvas.height / intrinsicHeight, - }; - visualContrastRasterCache.set(drawable, cached); - const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX))); - const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY))); - const data = ctx.getImageData(x, y, 1, 1).data; - return { - status: 'sampled', - color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 }, - }; - } catch (err) { - const reason = /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed'; - visualContrastRasterCache.set(drawable, { ctx: null, reason }); - return { - status: 'unresolved', - reason, - }; - } - } - - async function sampleCssBackground(el, style, point, textColor) { - const rect = el.getBoundingClientRect(); - const bgImage = style.backgroundImage || ''; - if (bgImage && bgImage !== 'none') { - if (/gradient/i.test(bgImage)) { - const color = pickWorstContrastColor(textColor, parseGradientColors(bgImage)); - if (color) return { status: 'sampled', color, method: 'analytic-gradient' }; - } - if (/url\s*\(/i.test(bgImage)) { - const img = await loadVisualContrastImage(firstCssUrl(bgImage)); - if (!img) return { status: 'unresolved', reason: 'image unavailable' }; - const paintedRect = resolvePaintedImageRect( - rect, - img, - getLayerValue(style.backgroundSize) || 'auto', - getLayerValue(style.backgroundPosition) || '50% 50%', - ); - const sourcePoint = pointToImageSource(point, paintedRect); - if (!sourcePoint) return { status: 'unresolved', reason: 'point outside background image' }; - const sample = sampleDrawablePixel(img, sourcePoint); - if (sample.status === 'sampled') return { ...sample, method: 'canvas-background-image' }; - return sample; - } - } - const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor); - if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' }; - return { status: 'unresolved', reason: 'no readable background' }; - } - - async function sampleImageElement(img, point) { - const rect = img.getBoundingClientRect(); - const style = getComputedStyle(img); - const paintedRect = resolveObjectImageRect(rect, img, style); - const sourcePoint = pointToImageSource(point, paintedRect); - if (!sourcePoint) return { status: 'unresolved', reason: 'point outside image' }; - const sample = sampleDrawablePixel(img, sourcePoint); - if (sample.status === 'sampled') return { ...sample, method: 'canvas-img-underlay' }; - - if (img.currentSrc || img.src) { - const loaded = await loadVisualContrastImage(img.currentSrc || img.src); - if (loaded) { - const loadedRect = { ...paintedRect, intrinsicWidth: loaded.naturalWidth || loaded.width || paintedRect.intrinsicWidth, intrinsicHeight: loaded.naturalHeight || loaded.height || paintedRect.intrinsicHeight }; - const loadedPoint = pointToImageSource(point, loadedRect); - if (loadedPoint) { - const loadedSample = sampleDrawablePixel(loaded, loadedPoint); - if (loadedSample.status === 'sampled') return { ...loadedSample, method: 'canvas-img-underlay' }; - } - } - } - return sample; - } - - function textSamplePoints(rect) { - const insetX = Math.min(12, Math.max(1, rect.width * 0.12)); - const insetY = Math.min(8, Math.max(1, rect.height * 0.22)); - const xs = rect.width < 28 - ? [rect.left + rect.width / 2] - : [rect.left + insetX, rect.left + rect.width / 2, rect.right - insetX]; - const ys = rect.height < 22 - ? [rect.top + rect.height / 2] - : [rect.top + insetY, rect.top + rect.height / 2, rect.bottom - insetY]; - const points = []; - for (const y of ys) { - for (const x of xs) { - if (x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight) points.push({ x, y }); - } - } - return points; - } - - async function sampleVisualBackgroundAtPoint(el, point, textColor, depth = 0) { - if (depth > 8) { - return { status: 'unresolved', reason: 'background stack too deep' }; - } - const stack = typeof document.elementsFromPoint === 'function' - ? document.elementsFromPoint(point.x, point.y) - : []; - const selfIndex = stack.findIndex(node => node === el || el.contains(node)); - const nodes = selfIndex >= 0 ? stack.slice(selfIndex) : [el, ...stack]; - const unresolved = []; - - for (const node of nodes) { - if (!node || node.nodeType !== 1) continue; - if (node.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; - const tag = node.tagName?.toLowerCase(); - if (tag === 'img') { - const sample = await sampleImageElement(node, point); - if (sample.status === 'sampled') return sample; - unresolved.push(sample.reason); - continue; - } - if (tag === 'canvas' || tag === 'video') { - const rect = node.getBoundingClientRect(); - const sourcePoint = pointToImageSource(point, { - left: rect.left, - top: rect.top, - width: rect.width, - height: rect.height, - intrinsicWidth: node.width || node.videoWidth || rect.width, - intrinsicHeight: node.height || node.videoHeight || rect.height, - }); - if (sourcePoint) { - const sample = sampleDrawablePixel(node, sourcePoint); - if (sample.status === 'sampled') return { ...sample, method: `canvas-${tag}-underlay` }; - unresolved.push(sample.reason); - } - continue; - } - const style = getComputedStyle(node); - const sample = await sampleCssBackground(node, style, point, textColor); - if (sample.status === 'sampled') { - if (!sample.color || sample.color.a == null || sample.color.a >= 0.95) return sample; - const under = await sampleVisualBackgroundAtPoint(node.parentElement || document.body, point, textColor, depth + 1); - if (under.status === 'sampled') { - return { - status: 'sampled', - color: blendRgba(sample.color, under.color), - method: `${sample.method}+alpha`, - }; - } - return sample; - } - unresolved.push(sample.reason); - } - - return { - status: 'unresolved', - reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'no readable visual background', - }; - } - - async function analyzeVisualContrastCandidate(candidate) { - let el; - try { - el = document.querySelector(candidate.selector); - } catch { - return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; - } - if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; - if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' }; - - const blockingReason = (candidate.reasons || []).find(reason => - reason === 'background-clip text' || - reason === 'blend mode' || - reason === 'filter' || - reason === 'backdrop filter' || - reason === 'opacity stack' || - reason === 'text shadow' - ); - if (blockingReason) { - return { ...candidate, status: 'unresolved', confidence: 'none', reason: `${blockingReason} needs screenshot pixels` }; - } - - const style = getComputedStyle(el); - const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor; - if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' }; - - const rect = getDirectTextRect(el) || el.getBoundingClientRect(); - if (!rect || rect.width < 4 || rect.height < 4) { - return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing text rect' }; - } - - const points = textSamplePoints(rect); - if (points.length === 0) { - return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'text outside viewport' }; - } - - const ratios = []; - const methods = new Set(); - const unresolved = []; - for (const point of points) { - const sample = await sampleVisualBackgroundAtPoint(el, point, textColor); - if (sample.status !== 'sampled' || !sample.color) { - unresolved.push(sample.reason); - continue; - } - const fg = blendRgba(textColor, sample.color); - ratios.push(contrastRatio(fg, sample.color)); - if (sample.method) methods.add(sample.method); - } - - if (ratios.length < Math.min(3, points.length)) { - return { - ...candidate, - status: 'unresolved', - confidence: 'none', - samples: ratios.length, - reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'not enough readable samples', - }; - } - - ratios.sort((a, b) => a - b); - const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))]; - const measuredRatio = pick(10); - const medianRatio = pick(50); - const status = measuredRatio < candidate.threshold ? 'fail' : 'pass'; - const method = [...methods].sort().join(', ') || 'browser-visual'; - const textLabel = candidate.text ? ` "${candidate.text}"` : ''; - const detail = `browser contrast ${measuredRatio.toFixed(1)}:1 median ${medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) via ${method}${textLabel}`; - return { - ...candidate, - status, - confidence: method.includes('canvas-') ? 'high' : 'medium', - method, - ratio: measuredRatio, - medianRatio, - samples: ratios.length, - finding: status === 'fail' ? { id: 'low-contrast', snippet: detail } : null, - }; - } - - function waitForVisualPaint() { - return new Promise(resolve => { - requestAnimationFrame(() => requestAnimationFrame(resolve)); - }); - } - - async function analyzeVisualContrast(options = {}) { - // imageOnly is enforced inside the collector, before the candidate cap. - const candidates = collectVisualContrastCandidates(options); - const results = []; - const shouldScrollOffscreen = options.scrollOffscreen === true; - const restoreScroll = { x: window.scrollX, y: window.scrollY }; - for (const candidate of candidates) { - if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) { - window.scrollTo(restoreScroll.x, restoreScroll.y); - await waitForVisualPaint(); - } - let result = await analyzeVisualContrastCandidate(candidate); - if (shouldScrollOffscreen && result.status === 'unresolved' && result.reason === 'text outside viewport') { - let el = null; - try { - el = document.querySelector(candidate.selector); - } catch { - el = null; - } - if (el && typeof el.scrollIntoView === 'function') { - el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); - await waitForVisualPaint(); - result = await analyzeVisualContrastCandidate(candidate); - } - } - results.push(result); - } - if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) { - window.scrollTo(restoreScroll.x, restoreScroll.y); - } - return results; - } - - function isElementHidden(el) { - if (!el || el === document.body || el === document.documentElement) return false; - if (typeof el.checkVisibility === 'function') return !el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true }); - // Fallback: zero size or no offsetParent (covers display:none and detached subtrees) - return el.offsetWidth === 0 && el.offsetHeight === 0; - } - - function serializeFindings(allFindings) { - return allFindings.map(({ el, findings }) => ({ - selector: generateSelector(el), - tagName: el.tagName?.toLowerCase() || 'unknown', - rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect) - ? el.getBoundingClientRect().toJSON() : null, - isPageLevel: el === document.body || el === document.documentElement, - isHidden: isElementHidden(el), - findings: findings.map(f => { - const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); - return { - type: f.type || f.id, - category: ap ? ap.category : 'quality', - severity: f.severity || ap?.severity || 'warning', - // Advisory findings (em-dash overuse, etc.) are surfaced but never - // treated as failures; carry the flag so the overlay/extension can - // render them with the mildest affordance and consumers can filter. - advisory: (ap && ap.advisory === true) || f.advisory === true, - detail: f.detail || f.snippet, - ignoreValue: f.ignoreValue || f.value || '', - name: ap ? ap.name : (f.type || f.id), - description: ap ? ap.description : '', - }; - }), - })); - } - - const printSummary = function(allFindings) { - if (allFindings.length === 0) { - console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold'); - return; - } - console.group( - `%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`, - 'color: oklch(84% 0.19 80.46); font-weight: bold' - ); - for (const { el, findings } of allFindings) { - for (const f of findings) { - console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`, - 'color: oklch(84% 0.19 80.46); font-weight: bold', 'color: inherit', el); - } - } - console.groupEnd(); - }; - - function addBrowserFindings(groupMap, el, findings) { - if (!findings || findings.length === 0) return; - // Element-scoped waivers: a data-impeccable-ignore ancestor suppresses - // matching findings for its whole subtree. Applied at this choke point so - // every per-element attribution (checks, layout, occlusion, rhythm) - // honors it; page-level findings attributed to pass through - // untouched, since body has no ignoring ancestor. - const kept = findings.filter(f => !scopedIgnoreActive(el, f.type)); - if (kept.length === 0) return; - const existing = groupMap.get(el); - if (existing) existing.push(...kept); - else groupMap.set(el, [...kept]); - } - - function browserFindingsFromMap(groupMap) { - return [...groupMap.entries()].map(([el, findings]) => ({ el, findings })); - } - - const DESIGN_COLOR_TOLERANCE = 6; - const DESIGN_RADIUS_TOLERANCE_PX = 0.5; - const DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']); - - function normalizeBrowserFontName(value) { - return String(value || '') - .trim() - .replace(/^["']|["']$/g, '') - .replace(/\+/g, ' ') - .replace(/\s+/g, ' ') - .toLowerCase(); - } - - function browserPrimaryFont(stack) { - if (!stack || /var\(/i.test(stack)) return ''; - return String(stack || '') - .split(',') - .map(normalizeBrowserFontName) - .find(font => font && !GENERIC_FONTS.has(font)) || ''; - } - - function browserDesignSystemConfig() { - const raw = window.__IMPECCABLE_CONFIG__?.designSystem; - if (!raw?.present) return null; - const allowedFonts = new Set((raw.allowedFonts || []).map(normalizeBrowserFontName).filter(Boolean)); - const allowedColors = (raw.allowedColors || []) - .filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b)) - .map(color => ({ r: color.r, g: color.g, b: color.b })); - const allowedRadii = (raw.allowedRadii || []) - .map(Number) - .filter(px => Number.isFinite(px)); - return { - present: true, - hasFonts: raw.hasFonts === true && allowedFonts.size > 0, - allowedFonts, - hasColors: raw.hasColors === true && allowedColors.length > 0, - allowedColors, - hasRadii: raw.hasRadii === true && allowedRadii.length > 0, - allowedRadii, - hasPillRadius: raw.hasPillRadius === true, - }; - } - - function browserColorsClose(a, b) { - if (!a || !b) return false; - return Math.max( - Math.abs(a.r - b.r), - Math.abs(a.g - b.g), - Math.abs(a.b - b.b), - ) <= DESIGN_COLOR_TOLERANCE; - } - - function isBrowserDesignColorAllowed(raw, designSystem) { - if (!designSystem?.hasColors) return true; - const text = String(raw || '').trim().toLowerCase(); - if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true; - if (text.includes('var(')) return true; - const parsed = parseAnyColor(text); - if (!parsed) return true; - if ((parsed.a ?? 1) <= 0.05) return true; - return designSystem.allowedColors.some(color => browserColorsClose(parsed, color)); - } - - function isBrowserTransparentCss(value) { - const text = String(value || '').trim().toLowerCase(); - if (!text || text === 'transparent') return true; - const parsed = parseAnyColor(text); - return parsed ? (parsed.a ?? 1) <= 0.05 : false; - } - - function isBrowserDesignRadiusAllowed(raw, designSystem) { - if (!designSystem?.hasRadii) return true; - const text = String(raw || '').trim().toLowerCase(); - if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true; - if (text.includes('var(') || text.includes('%')) return true; - const px = resolveLengthPx(text, 16); - if (px == null || !Number.isFinite(px) || px <= DESIGN_RADIUS_TOLERANCE_PX) return true; - if (designSystem.hasPillRadius && px >= 99) return true; - return designSystem.allowedRadii.some(allowed => Math.abs(allowed - px) <= DESIGN_RADIUS_TOLERANCE_PX); - } - - function browserRadiusTokens(value) { - return String(value || '') - .replace(/\s*\/\s*/g, ' ') - .split(/\s+/) - .map(token => token.trim()) - .filter(Boolean); - } - - function browserHasDirectText(el) { - return [...(el.childNodes || [])].some(node => node.nodeType === 3 && node.textContent.trim().length > 0); - } - - function browserSampleText(el) { - const text = String(el.textContent || '').replace(/\s+/g, ' ').trim(); - return text ? ` "${text.slice(0, 40)}"` : ''; - } - - function shouldSkipDesignElement(el) { - const tag = el.tagName?.toLowerCase?.() || ''; - return DESIGN_SKIP_TAGS.has(tag) || isElementHidden(el); - } - - function checkElementDesignSystemDOM(el, designSystem, seen) { - if (!designSystem?.present || shouldSkipDesignElement(el)) return []; - const findings = []; - const tag = el.tagName?.toLowerCase?.() || 'unknown'; - const style = getComputedStyle(el); - - if (designSystem.hasFonts && browserHasDirectText(el)) { - const font = browserPrimaryFont(style.fontFamily || ''); - if (font && !designSystem.allowedFonts.has(font) && !seen.fonts.has(font)) { - seen.fonts.add(font); - findings.push({ - type: 'design-system-font', - detail: `${tag}${browserSampleText(el)} uses ${font}; not declared in DESIGN.md typography`, - ignoreValue: font, - }); - } - } - - if (designSystem.hasColors) { - const colorChecks = []; - if (browserHasDirectText(el)) colorChecks.push(['text color', style.color]); - if (!isBrowserTransparentCss(style.backgroundColor)) colorChecks.push(['background', style.backgroundColor]); - for (const side of ['Top', 'Right', 'Bottom', 'Left']) { - if ((parseFloat(style[`border${side}Width`]) || 0) > 0) { - colorChecks.push([`border-${side.toLowerCase()}`, style[`border${side}Color`]]); - } - } - if ((parseFloat(style.outlineWidth) || 0) > 0) colorChecks.push(['outline', style.outlineColor]); - - for (const [kind, raw] of colorChecks) { - const label = String(raw || '').trim().replace(/\s+/g, ' '); - if (isBrowserDesignColorAllowed(label, designSystem)) continue; - const key = `${kind}:${label}`; - if (seen.colors.has(key)) continue; - seen.colors.add(key); - findings.push({ - type: 'design-system-color', - detail: `${kind} ${label} on ${tag}${browserSampleText(el)} is outside DESIGN.md colors`, - ignoreValue: label, - }); - } - } - - if (designSystem.hasRadii) { - for (const token of browserRadiusTokens(style.borderRadius || '')) { - if (isBrowserDesignRadiusAllowed(token, designSystem)) continue; - if (seen.radii.has(token)) continue; - seen.radii.add(token); - findings.push({ - type: 'design-system-radius', - detail: `border-radius ${token} on ${tag}${browserSampleText(el)} is outside the DESIGN.md rounded scale`, - ignoreValue: token, - }); - } - } - - return findings; - } - - function decodeBrowserGoogleFamily(value) { - const family = String(value || '').split(':')[0].replace(/\+/g, ' '); - try { - return decodeURIComponent(family); - } catch { - return family; - } - } - - function checkBrowserDesignSystemSources(designSystem, seen) { - if (!designSystem?.hasFonts) return []; - const findings = []; - for (const link of document.querySelectorAll('link[href*="fonts.googleapis.com/css"]')) { - const href = link.getAttribute('href') || ''; - for (const match of href.matchAll(/[?&]family=([^&]+)/g)) { - const display = decodeBrowserGoogleFamily(match[1]); - const font = normalizeBrowserFontName(display); - if (!font || designSystem.allowedFonts.has(font) || seen.fonts.has(font)) continue; - seen.fonts.add(font); - findings.push({ - type: 'design-system-font', - detail: `Google Fonts: ${display} is not declared in DESIGN.md typography`, - ignoreValue: display, - }); - } - } - return findings; - } - - // A page matched by detector.ignoreFiles is waived wholesale: every scan - // stage answers empty so the badge and toast read zero. Mirrors - // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live - // overlay resolves the globs per page (live-browser-ignores.js) and - // forwards the verdict as config.skipScan. - function skipScanActive() { - return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; - } - - function collectBrowserFindings() { - if (skipScanActive()) { - return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; - } - const groupMap = new Map(); - const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; - const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); - const designSystem = browserDesignSystemConfig(); - const designSeen = { fonts: new Set(), colors: new Set(), radii: new Set() }; - // All deterministic rules run in the browser and extension path. - - for (const el of document.querySelectorAll('*')) { - // Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons) - if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; - // Skip browser extension elements (Claude, etc.). Use getAttribute when - // `el.id` is not a string: a with a named control like - // shadows the builtin `id` getter and returns the - // element, whose `.startsWith` throws (issue #407). - const elId = typeof el.id === 'string' ? el.id : (el.getAttribute('id') || ''); - if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue; - // Skip the impeccable live-mode overlay (highlight, tooltip, bar, picker, toast). - // These are inspector chrome, not part of the user's design. - if (el.closest('[id^="impeccable-live-"]')) continue; - // Skip html/body -- page-level findings go in the banner, not a full-page overlay - if (el === document.body || el === document.documentElement) continue; - - const findings = [ - ...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })), - ...checkElementPseudoStripeDOM(el).map(f => ({ type: f.id, detail: f.snippet })), - ...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })), - ...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })), - ...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), - ...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })), - ...checkElementRadialSpotlightDOM(el).map(f => ({ type: f.id, detail: f.snippet })), - ...checkElementIconTileDOM(el).map(f => ({ type: f.id, detail: f.snippet })), - ...checkElementItalicSerifDOM(el).map(f => ({ type: f.id, detail: f.snippet })), - ...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })), - ...checkElementOversizedH1DOM(el).map(f => ({ type: f.id, detail: f.snippet })), - ...checkElementClippedOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), - ...checkElementGptBorderShadowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), - ...checkElementTextOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), - ...checkElementBlinkingCursorDOM(el).map(f => ({ type: f.id, detail: f.snippet, ...(f.severity ? { severity: f.severity } : {}) })), - ...checkElementDesignSystemDOM(el, designSystem, designSeen), - ].filter(f => _ruleOk(f.type)); - - addBrowserFindings(groupMap, el, findings); - - // Hero eyebrow: the offending element is the eyebrow above the heading, - // not the heading itself — highlight the previous sibling instead. - const eyebrowFindings = checkElementHeroEyebrowDOM(el) - .map(f => ({ type: f.id, detail: f.snippet })) - .filter(f => _ruleOk(f.type)); - if (eyebrowFindings.length > 0 && el.previousElementSibling) { - addBrowserFindings(groupMap, el.previousElementSibling, eyebrowFindings); - } - } - - const pageLevelFindings = []; - - const designSourceFindings = checkBrowserDesignSystemSources(designSystem, designSeen) - .filter(f => _ruleOk(f.type)); - if (designSourceFindings.length > 0) { - pageLevelFindings.push(...designSourceFindings); - addBrowserFindings(groupMap, document.body, designSourceFindings); - } - - const typoFindings = checkTypography().filter(f => _ruleOk(f.type)); - if (typoFindings.length > 0) { - pageLevelFindings.push(...typoFindings); - addBrowserFindings(groupMap, document.body, typoFindings); - } - - const sectionKickerFindings = checkKickerAboveHeadingDOM() - .map(f => ({ type: f.id, detail: f.snippet })) - .filter(f => _ruleOk(f.type)); - if (sectionKickerFindings.length > 0) { - pageLevelFindings.push(...sectionKickerFindings); - addBrowserFindings(groupMap, document.body, sectionKickerFindings); - } - - const numberedLabelFindings = checkNumberedSectionLabelsDOM() - .map(f => ({ type: f.id, detail: f.snippet })) - .filter(f => _ruleOk(f.type)); - if (numberedLabelFindings.length > 0) { - pageLevelFindings.push(...numberedLabelFindings); - addBrowserFindings(groupMap, document.body, numberedLabelFindings); - } - - const repeatedTextFindings = checkRepeatedContainerTextDOM() - .map(f => ({ type: f.id, detail: f.snippet })) - .filter(f => _ruleOk(f.type)); - if (repeatedTextFindings.length > 0) { - pageLevelFindings.push(...repeatedTextFindings); - addBrowserFindings(groupMap, document.body, repeatedTextFindings); - } - - // Em-dash overuse (advisory): browser parity with the static/regex path. - // Reads rendered body text so it catches dashes written as HTML entities. - // serializeFindings stamps the advisory flag from the registry. - const emDashFindings = checkEmDashOveruseDOM() - .map(f => ({ type: f.id, detail: f.snippet })) - .filter(f => _ruleOk(f.type)); - if (emDashFindings.length > 0) { - pageLevelFindings.push(...emDashFindings); - addBrowserFindings(groupMap, document.body, emDashFindings); - } - - const layoutFindings = checkLayout().filter(f => _ruleOk(f.type)); - for (const f of layoutFindings) { - const el = f.el || document.body; - addBrowserFindings(groupMap, el, [{ type: f.type, detail: f.detail || f.snippet }]); - } - - // Heading rhythm (browser-only: needs real layout for the gap math) - const headingRhythmFindings = checkHeadingRhythmDOM().filter(f => _ruleOk(f.type)); - for (const f of headingRhythmFindings) { - addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]); - } - - // Edge-flush cards in horizontal scrollers (browser-only: needs real - // layout for the scroller clip box vs card rect math) - const edgeFlushFindings = checkEdgeFlushCardsDOM().filter(f => _ruleOk(f.type)); - for (const f of edgeFlushFindings) { - addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]); - } - - // Text occlusion / element overlap (browser-only: needs real layout + - // elementFromPoint to confirm what actually paints on top) - const occlusionFindings = checkTextOcclusionDOM().filter(f => _ruleOk(f.type)); - for (const f of occlusionFindings) { - addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]); - } - - // First-viewport column overflow — the stretched-hero signature - // (browser-only: needs real layout for the content-extent math) - const colOverflowFindings = checkFirstViewportColumnOverflowDOM().filter(f => _ruleOk(f.type)); - for (const f of colOverflowFindings) { - addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]); - } - - // Page-level quality checks (headings, etc.) - const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type)); - if (qualityFindings.length > 0) { - pageLevelFindings.push(...qualityFindings); - addBrowserFindings(groupMap, document.body, qualityFindings); - } - - const creamFindings = checkCreamPalette(document) - .map(f => ({ type: f.id, detail: f.snippet })) - .filter(f => _ruleOk(f.type)); - if (creamFindings.length > 0) { - pageLevelFindings.push(...creamFindings); - addBrowserFindings(groupMap, document.body, creamFindings); - } - - // Regex-on-HTML checks (shared with Node) - // Clone the document and strip impeccable-live overlay nodes before the - // regex scan, so the inspector's own inline styles (transitions on top/ - // left/width/height, etc.) don't register as page anti-patterns. - const docClone = document.documentElement.cloneNode(true); - for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) { - node.remove(); - } - // Regex findings that name a live selector resolve against the real DOM: - // pseudo-element/class segments are stripped (the host element is the - // anchor), a selector that matches nothing on this page drops the finding - // (the CSS ships here, but the pattern never renders — the live DOM is - // ground truth in the browser), and a match under a data-impeccable-ignore - // ancestor is waived. Selector-less findings stay page-level. - const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => { - if (!f.selector) return true; - const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, ''); - if (!query || /^[,\s]*$/.test(query)) return true; - let matches; - try { - matches = document.querySelectorAll(query); - } catch { - return true; - } - if (matches.length === 0) return false; - return [...matches].some(el => !scopedIgnoreActive(el, f.id)); - }); - if (scopedHtmlFindings.length > 0) { - const mapped = scopedHtmlFindings.map(f => { - const item = { type: f.id, detail: f.snippet }; - if (f.severity) { - item.severity = f.severity; - } else if (f.id === 'pulsing-dot' && f.selector) { - // The string scan promotes header/nav dots on its own; with a live - // layout also promote dots resting in the first ~900px of the page - // (the hero region), which the source scan cannot measure. - try { - const dotEl = document.querySelector(f.selector); - if (dotEl) { - const rect = dotEl.getBoundingClientRect(); - const pageTop = rect.top + (window.scrollY || 0); - if (pageTop <= 900) item.severity = 'error'; - } - } catch { /* unresolvable selector: keep registry severity */ } - } - return item; - }).filter(f => _ruleOk(f.type)); - pageLevelFindings.push(...mapped); - addBrowserFindings(groupMap, document.body, mapped); - } - - // Value-level suppression (issue #639). `disabledRules` above handles - // whole rules; this applies the config's remaining ignoreValues entries, - // which the CLI filters through isIgnoredFindingValue in - // cli/lib/impeccable-config.mjs, so a project waiver like - // overused-font = "geist mono" reaches the overlay and extension too. - const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') - .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); - const _disabledValues = EXTENSION_MODE - ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) - .filter(e => e && typeof e === 'object' && e.rule && e.value) - .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) - : []; - if (_disabledValues.length > 0) { - // The six rules whose findings carry a matchable value; keep in step - // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. - // Everything else is suppressed by rule or by file scope, both already - // resolved into disabledRules before the scan message was sent. - const _directValueRules = new Set([ - 'overused-font', - 'bounce-easing', - 'design-system-font', - 'design-system-color', - 'design-system-radius', - 'design-system-font-size', - ]); - // The design-system checks set `ignoreValue` on their findings; the - // detail fallbacks catch overused-font, whose value lives in its - // sentence. One CLI matcher is not mirrored here: the motion extractor - // (a value-scoped bounce-easing waiver only matches when the finding - // carries ignoreValue directly). The CLI's [?&]family= URL fallback is - // also omitted on purpose: browser findings for these rules always - // carry ignoreValue or a "Primary font:" / "Google Fonts:" / - // font-family sentence, so it is unreachable here. - const _findingValue = (f) => { - if (!f || !_directValueRules.has(f.type || f.id)) return ''; - const direct = f.ignoreValue || f.value; - if (direct) return _normValue(direct); - // The CLI routes bounce-easing through extractMotionIgnoreValue and - // never the font regexes; without a direct ignoreValue there is no - // value to match, so do not invent one from unrelated CSS text. - if ((f.type || f.id) === 'bounce-easing') return ''; - for (const text of [f.detail, f.snippet]) { - if (typeof text !== 'string' || !text) continue; - const primary = text.match(/Primary font:\s*([^()\n;]+)/i); - if (primary) return _normValue(primary[1]); - const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); - if (google) return _normValue(google[1]); - const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); - if (family) return _normValue(family[1]); - } - return ''; - }; - // design-system-color compares by color value, not by spelling: the - // browser reports computed rgb(...) strings while waivers are usually - // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in - // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; - // hsl stays CLI-only. - const _colorKey = (value) => { - const text = String(value || '').trim().toLowerCase(); - const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); - if (hex) { - const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; - const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); - return `${r},${g},${b},${a}`; - } - const rgb = text.match(/^rgba?\((.*)\)$/); - if (!rgb) return ''; - const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); - let parts; - if (body.includes(',')) { - parts = body.split(',').map(p => p.trim()).filter(Boolean); - const last = parts[parts.length - 1]; - if (last && last.includes('/')) { - parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; - } - } else { - parts = body.split(/\s+/).filter(p => p && p !== '/'); - } - if (parts.length < 3 || parts.length > 4) return ''; - const channel = (raw, isAlpha) => { - const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); - if (!m) return null; - let v = parseFloat(m[1]); - if (m[2]) v = isAlpha ? v / 100 : v * 2.55; - const max = isAlpha ? 1 : 255; - if (!Number.isFinite(v) || v < 0 || v > max) return null; - return isAlpha ? v : Math.round(v); - }; - const r = channel(parts[0], false); - const g = channel(parts[1], false); - const b = channel(parts[2], false); - const a = parts[3] === undefined ? 1 : channel(parts[3], true); - if ([r, g, b, a].some(v => v === null)) return ''; - return `${r},${g},${b},${Math.round(a * 255)}`; - }; - const _valueIgnored = (f) => { - const value = _findingValue(f); - if (!value) return false; - const rule = f.type || f.id; - return _disabledValues.some(e => e.rule === rule && (e.value === value - || (rule === 'design-system-color' - && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(value)))); - }; - for (const [el, list] of [...groupMap.entries()]) { - const kept = list.filter(f => !_valueIgnored(f)); - if (kept.length > 0) groupMap.set(el, kept); - else groupMap.delete(el); - } - for (let i = pageLevelFindings.length - 1; i >= 0; i--) { - if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); - } - } - - return { - groupMap, - allFindings: browserFindingsFromMap(groupMap), - pageLevelFindings, - }; - } - - // Visual contrast has three modes. Explicit true runs the full sampled - // pass; explicit false disables it entirely (the deterministic-only mode - // the test suites use). Unset — the default overlay run — samples ONLY - // image-backed text: the one class the analytic walk deliberately skips, - // because a url() layer's pixels are unknowable without looking. In-page - // sampling draws the source image alone to a canvas (glyph ink never - // pollutes it), and a cross-origin image without CORS reports unresolved - // instead of guessing. - function visualContrastMode(options = {}) { - const explicit = typeof options.visualContrast === 'boolean' - ? options.visualContrast - : typeof window.__IMPECCABLE_CONFIG__?.visualContrast === 'boolean' - ? window.__IMPECCABLE_CONFIG__.visualContrast - : null; - if (explicit === true) return 'full'; - if (explicit === false) return false; - return 'image-only'; - } - - function shouldRunVisualContrast(options = {}) { - return visualContrastMode(options) !== false; - } - - function visualContrastOptions(options = {}) { - const config = window.__IMPECCABLE_CONFIG__ || {}; - const scrollOffscreen = typeof options.scrollOffscreen === 'boolean' - ? options.scrollOffscreen - : typeof options.visualContrastScrollOffscreen === 'boolean' - ? options.visualContrastScrollOffscreen - : typeof config.visualContrastScrollOffscreen === 'boolean' - ? config.visualContrastScrollOffscreen - : false; - return { - ...options, - maxCandidates: Number.isFinite(options.visualContrastMaxCandidates) - ? options.visualContrastMaxCandidates - : Number.isFinite(options.maxCandidates) - ? options.maxCandidates - : Number.isFinite(config.visualContrastMaxCandidates) - ? config.visualContrastMaxCandidates - : undefined, - scrollOffscreen, - }; - } - - let lastVisualContrastAnalyses = []; - let lazyVisualContrastObserver = null; - let lazyVisualContrastPending = new WeakMap(); - const lazyVisualContrastResolving = new WeakSet(); - let scanGeneration = 0; - - function rememberVisualContrastAnalysis(result) { - if (!result?.selector) { - lastVisualContrastAnalyses.push(result); - return; - } - const idx = lastVisualContrastAnalyses.findIndex(item => item.selector === result.selector); - if (idx >= 0) lastVisualContrastAnalyses[idx] = result; - else lastVisualContrastAnalyses.push(result); - } - - function disconnectLazyVisualContrastObserver() { - if (lazyVisualContrastObserver) { - lazyVisualContrastObserver.disconnect(); - lazyVisualContrastObserver = null; - } - lazyVisualContrastPending = new WeakMap(); - } - - function addVisualContrastResult(groupMap, result, options = {}) { - if (result.status !== 'fail' || !result.finding || !result.selector) return false; - let el = null; - try { - el = document.querySelector(result.selector); - } catch { - el = null; - } - if (!el) return false; - const findingType = result.finding.type || result.finding.id || 'low-contrast'; - const existing = groupMap.get(el) || []; - if (existing.some(f => (f.type || f.id) === findingType)) return false; - addBrowserFindings(groupMap, el, [{ - type: findingType, - detail: result.finding.detail || result.finding.snippet, - }]); - if (options.decorate && el !== document.body && el !== document.documentElement) { - highlight(el, groupMap.get(el) || []); - } - return true; - } - - function scanResultMeta(options = {}) { - const scanId = options.scanId; - if (typeof scanId !== 'string' && typeof scanId !== 'number') return {}; - return { scanId: String(scanId) }; - } - - function postSerializedFindings(groupMap, options = {}) { - if (!EXTENSION_MODE) return; - const allFindings = browserFindingsFromMap(groupMap); - window.postMessage({ - source: 'impeccable-results', - findings: serializeFindings(allFindings), - count: allFindings.length, - ...scanResultMeta(options), - }, '*'); - } - - function postExtensionError(err) { - if (!EXTENSION_MODE) return; - window.postMessage({ - source: 'impeccable-error', - message: err?.message || String(err), - }, '*'); - } - - function reportVisualContrastError(err, detail = {}) { - window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-error', { - detail: { - ...detail, - message: err?.message || String(err), - }, - })); - if (EXTENSION_MODE) { - postExtensionError(err); - } else { - console.warn('[impeccable] visual contrast scan failed', err); - } - } - - function scheduleLazyVisualContrast(groupMap, analyses, options = {}, runtime = {}) { - disconnectLazyVisualContrastObserver(); - if (options.visualContrastLazy === false || options.scrollOffscreen !== false) return; - if (typeof IntersectionObserver === 'undefined') return; - const unresolved = (analyses || []).filter(result => - result?.status === 'unresolved' && - result.reason === 'text outside viewport' && - result.selector - ); - if (unresolved.length === 0) return; - const generation = runtime.generation || scanGeneration; - - lazyVisualContrastObserver = new IntersectionObserver((entries) => { - for (const entry of entries) { - if (!entry.isIntersecting) continue; - const el = entry.target; - const candidate = lazyVisualContrastPending.get(el); - if (!candidate || lazyVisualContrastResolving.has(el)) continue; - lazyVisualContrastObserver?.unobserve(el); - lazyVisualContrastPending.delete(el); - lazyVisualContrastResolving.add(el); - waitForVisualPaint() - .then(() => analyzeVisualContrastCandidate(candidate)) - .then(result => { - if (generation !== scanGeneration) return; - rememberVisualContrastAnalysis(result); - const added = addVisualContrastResult(groupMap, result, { decorate: true }); - if (added) { - postSerializedFindings(groupMap, options); - window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', { - detail: { - selector: result.selector, - status: result.status, - finding: result.finding || null, - }, - })); - } - }) - .catch(err => { - reportVisualContrastError(err, { selector: candidate.selector }); - }) - .finally(() => { - lazyVisualContrastResolving.delete(el); - }); - } - }, { threshold: 0.5 }); - - for (const candidate of unresolved) { - let el = null; - try { - el = document.querySelector(candidate.selector); - } catch { - el = null; - } - if (!el) continue; - lazyVisualContrastPending.set(el, candidate); - lazyVisualContrastObserver.observe(el); - } - } - - async function addVisualContrastFindings(groupMap, options = {}, runtime = {}) { - if (!shouldRunVisualContrast(options)) { - lastVisualContrastAnalyses = []; - disconnectLazyVisualContrastObserver(); - return []; - } - const resolvedOptions = visualContrastOptions(options); - if (visualContrastMode(options) === 'image-only') resolvedOptions.imageOnly = true; - const analyses = await analyzeVisualContrast(resolvedOptions); - if (runtime.generation && runtime.generation !== scanGeneration) return analyses; - lastVisualContrastAnalyses = analyses; - for (const result of analyses) { - addVisualContrastResult(groupMap, result, { decorate: runtime.decorate }); - } - if (runtime.decorate || runtime.scheduleLazy) scheduleLazyVisualContrast(groupMap, analyses, resolvedOptions, runtime); - return analyses; - } - - async function collectBrowserFindingsAsync(options = {}, runtime = {}) { - const collected = collectBrowserFindings(); - // The visual pass walks the DOM on its own; on a skipScan page it would - // repopulate the emptied scan, so it is skipped with everything else. - if (skipScanActive()) { - lastVisualContrastAnalyses = []; - return { ...collected, allFindings: [], visualContrastAnalyses: [] }; - } - await addVisualContrastFindings(collected.groupMap, options, runtime); - return { - ...collected, - allFindings: browserFindingsFromMap(collected.groupMap), - visualContrastAnalyses: lastVisualContrastAnalyses, - }; - } - - function clearOverlays() { - scanGeneration += 1; - disconnectLazyVisualContrastObserver(); - for (const o of [...overlays]) detachOverlay(o); - overlays.length = 0; - visibilityObserver.disconnect(); - overlayIndex = 0; - } - - function renderBrowserFindings(collected, options = {}) { - const { allFindings, pageLevelFindings } = collected; - - for (const { el, findings } of allFindings) { - if (el === document.body || el === document.documentElement) continue; - highlight(el, findings); - } - - if (pageLevelFindings.length > 0) { - showPageBanner(pageLevelFindings); - } - - if (!EXTENSION_MODE) printSummary(allFindings); - - // In extension mode, post serialized results for the DevTools panel - if (EXTENSION_MODE) { - window.postMessage({ - source: 'impeccable-results', - findings: serializeFindings(allFindings), - count: allFindings.length, - ...scanResultMeta(options), - }, '*'); - } - - // After this scan completes, all subsequent reveals are instant (no stagger, no animation) - setTimeout(() => { firstScanDone = true; }, 1000); - - return allFindings; - } - - let firstScanDone = false; - const scan = function(options = {}) { - clearOverlays(); - const generation = scanGeneration; - const collected = collectBrowserFindings(); - const allFindings = renderBrowserFindings(collected, options); - if (!skipScanActive() && shouldRunVisualContrast(options)) { - addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) - .then(() => { - if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); - }) - .catch(err => { - reportVisualContrastError(err); - }); - } - return allFindings; - }; - - const scanAsync = async function(options = {}) { - clearOverlays(); - const generation = scanGeneration; - if (shouldRunVisualContrast(options)) { - const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true }); - if (generation !== scanGeneration) return []; - return renderBrowserFindings(collected, options); - } - lastVisualContrastAnalyses = []; - return renderBrowserFindings(collectBrowserFindings(), options); - }; - - const detect = function(options = {}) { - lastVisualContrastAnalyses = []; - const { allFindings } = collectBrowserFindings(); - return options.serialize === false ? allFindings : serializeFindings(allFindings); - }; - - const detectAsync = async function(options = {}) { - if (shouldRunVisualContrast(options)) { - const { allFindings } = await collectBrowserFindingsAsync(options); - return options.serialize === false ? allFindings : serializeFindings(allFindings); - } - lastVisualContrastAnalyses = []; - const { allFindings } = collectBrowserFindings(); - return options.serialize === false ? allFindings : serializeFindings(allFindings); - }; - - if (EXTENSION_MODE) { - // Extension mode: listen for commands, don't auto-scan - window.addEventListener('message', (e) => { - if (e.source !== window || !e.data || e.data.source !== 'impeccable-command') return; - if (e.data.action === 'scan') { - if (e.data.config) window.__IMPECCABLE_CONFIG__ = e.data.config; - try { - scan(e.data.config || {}); - } catch (err) { - postExtensionError(err); - } - } - if (e.data.action === 'toggle-overlays') { - const visible = !document.body.classList.contains('impeccable-hidden'); - document.body.classList.toggle('impeccable-hidden', visible); - window.postMessage({ source: 'impeccable-overlays-toggled', visible: !visible }, '*'); - } - if (e.data.action === 'remove') { - clearOverlays(); - styleEl.remove(); - if (spotlightBackdrop) { spotlightBackdrop.remove(); spotlightBackdrop = null; } - document.body.classList.remove('impeccable-hidden'); - } - if (e.data.action === 'highlight') { - try { - const target = e.data.selector ? document.querySelector(e.data.selector) : null; - if (target) { - // Scroll first so positionOverlay reads the post-scroll rect - if (!isInViewport(target) && target.scrollIntoView) { - target.scrollIntoView({ behavior: 'instant', block: 'center' }); - } - for (const o of overlays) { - if (o.classList.contains('impeccable-banner')) continue; - const isMatch = o._targetEl === target; - o.classList.toggle('impeccable-spotlight', isMatch); - o.classList.toggle('impeccable-spotlight-dimmed', !isMatch); - if (isMatch) { - // Force the matching overlay visible immediately, don't wait for IntersectionObserver - o.style.display = ''; - o.style.animation = 'none'; - o.classList.add('impeccable-visible'); - o._revealed = true; - positionOverlay(o); - } - } - showSpotlight(target); - } - } catch { /* invalid selector */ } - } - if (e.data.action === 'unhighlight') { - hideSpotlight(); - for (const o of overlays) { - o.classList.remove('impeccable-spotlight'); - o.classList.remove('impeccable-spotlight-dimmed'); - } - } - }); - window.postMessage({ source: 'impeccable-ready' }, '*'); - } else { - if (window.__IMPECCABLE_CONFIG__?.autoScan !== false) { - const runAutoScan = () => { - try { - scan(); - } catch (err) { - console.warn('[impeccable] scan failed', err); - } - }; - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => setTimeout(runAutoScan, 100)); - } else { - setTimeout(runAutoScan, 100); - } - } - } - - window.impeccableDetect = detect; - window.impeccableDetectAsync = detectAsync; - window.impeccableScan = scan; - window.impeccableScanAsync = scanAsync; - // Raw measurement for the URL engine's content-hidden-at-rest pass: it - // drives a reveal sweep from Node and thresholds the result itself. - window.impeccableMeasureHiddenText = measureHiddenTextDOM; - window.impeccableCollectVisualContrastCandidates = collectVisualContrastCandidates; - window.impeccableAnalyzeVisualContrast = analyzeVisualContrast; - window.impeccableGetLastVisualContrastAnalyses = () => lastVisualContrastAnalyses.slice(); -} diff --git a/cli/engine/cli/main.mjs b/cli/engine/cli/main.mjs deleted file mode 100644 index b2fdc2fa2..000000000 --- a/cli/engine/cli/main.mjs +++ /dev/null @@ -1,436 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { loadDesignSystemForTarget } from '../design-system.mjs'; -import { RULE_SCOPES, filterByScopes } from '../registry/antipatterns.mjs'; -import { createBrowserDetector, detectUrl } from '../engines/browser/detect-url.mjs'; -import { detectHtml } from '../engines/static-html/detect-html.mjs'; -import { detectText } from '../engines/regex/detect-text.mjs'; -import { - filterDetectionFindings, - readDetectionConfig, - shouldIgnoreDetectionFile, -} from '../../lib/impeccable-config.mjs'; -import { - HTML_EXTENSIONS, - buildImportGraph, - detectFrameworkConfig, - isPortListening, - walkDir, -} from '../node/file-system.mjs'; - -// --------------------------------------------------------------------------- -// Output formatting -// --------------------------------------------------------------------------- - -function formatFindingSummary(count) { - return `${count} anti-pattern${count === 1 ? '' : 's'} found.`; -} - -// Local filesystem path behind a file:// URL, or null when it can't be mapped. -function fileUrlToLocalPath(url) { - try { - return fileURLToPath(url); - } catch { - return null; - } -} - -// Advisory findings are detected but never treated as failures: they list in a -// separate, visually dimmed section, are excluded from the failure count that -// drives the exit code, and carry `"advisory": true` in JSON so consumers can -// filter. Every advisory finding carries the flag (stamped by the registry via -// findings.mjs). -function isAdvisory(finding) { - return finding && finding.advisory === true; -} - -function partitionAdvisory(findings) { - const primary = []; - const advisory = []; - for (const f of findings) (isAdvisory(f) ? advisory : primary).push(f); - return { primary, advisory }; -} - -// ANSI dim, when stderr is a TTY. Advisory output is chrome, so keep it quiet. -function dim(text) { - return process.stderr.isTTY ? `\x1b[2m${text}\x1b[0m` : text; -} - -function formatFindingsBody(findings) { - const grouped = {}; - for (const f of findings) { - if (!grouped[f.file]) grouped[f.file] = []; - grouped[f.file].push(f); - } - const out = []; - for (const [file, items] of Object.entries(grouped)) { - const importNote = items[0]?.importedBy?.length ? ` (imported by ${items[0].importedBy.join(', ')})` : ''; - out.push(`\n${file}${importNote}`); - for (const item of items) { - out.push(` ${item.line ? `line ${item.line}: ` : ''}[${item.antipattern}] ${item.snippet}`); - out.push(` → ${item.description}`); - } - } - return out; -} - -function formatAdvisorySection(advisory) { - if (!advisory || advisory.length === 0) return ''; - const lines = [`\n${dim('── Advisory (not counted as failures) ──')}`]; - for (const line of formatFindingsBody(advisory)) lines.push(dim(line)); - lines.push(dim(`\n${advisory.length} advisory note${advisory.length === 1 ? '' : 's'}. Suppress with --no-advisory.`)); - return lines.join('\n'); -} - -// Text/JSON formatter. `findings` is the full set; advisory items are separated -// out into their own section and excluded from the failure summary count. JSON -// output keeps every finding (each advisory one flagged) in a single array. -function formatFindings(findings, jsonMode) { - if (jsonMode) return JSON.stringify(findings, null, 2); - - const { primary, advisory } = partitionAdvisory(findings); - const out = [...formatFindingsBody(primary)]; - out.push(`\n${formatFindingSummary(primary.length)}`); - const advisorySection = formatAdvisorySection(advisory); - if (advisorySection) out.push(advisorySection); - return out.join('\n'); -} - -// --------------------------------------------------------------------------- -// Stdin handling -// --------------------------------------------------------------------------- - -// `optionsFor` maps a local path to scan options carrying that path's own -// project design system (or base options when null). Falls back to a plain -// object so direct/legacy callers still work. -async function detectLocalFile(filePath, options) { - if (HTML_EXTENSIONS.has(path.extname(filePath).toLowerCase())) { - return detectHtml(filePath, options); - } - return detectText(fs.readFileSync(filePath, 'utf-8'), filePath, options); -} - -async function handleStdin(optionsFor = () => ({})) { - const resolve = typeof optionsFor === 'function' ? optionsFor : () => optionsFor; - const chunks = []; - for await (const chunk of process.stdin) chunks.push(chunk); - const input = Buffer.concat(chunks).toString('utf-8'); - try { - const parsed = JSON.parse(input); - const fp = parsed?.tool_input?.file_path; - if (fp && fs.existsSync(fp)) { - return detectLocalFile(fp, resolve(fp)); - } - } catch { /* not JSON */ } - return detectText(input, '', resolve(null)); -} - - -// --------------------------------------------------------------------------- -// CLI -// --------------------------------------------------------------------------- - -async function confirm(question) { - const rl = (await import('node:readline')).default.createInterface({ - input: process.stdin, output: process.stderr, - }); - return new Promise((resolve) => { - rl.question(`${question} [Y/n] `, (answer) => { - rl.close(); - resolve(!answer || /^y(es)?$/i.test(answer.trim())); - }); - }); -} - -function printUsage() { - console.log(`Usage: impeccable detect [options] [file-or-dir-or-url...] - -Scan files or URLs for UI anti-patterns and design quality issues. - -Options: - --json Output results as JSON - --quiet In text mode, only print the final findings count - --scope Only report rules in the given design domain - (type, layout). Comma-separated. - --viewport Browser viewport for URL scans (default 1280x800), - e.g. --viewport 390x844 for a mobile-width pass - --no-config Do not apply project config, detector ignores, inline - ignore comments, or DESIGN.md - --no-inline-ignores Do not honor in-file impeccable-disable* ignore comments - --no-design-system Do not load local DESIGN.md / .impeccable/design.json context - --no-advisory Suppress advisory findings entirely (e.g. em-dash overuse) - --help Show this help message - -Advisory findings: - Some rules are advisory: detected and listed in a separate section, but never - counted as failures and never changing the exit code. They stay out of the - failure count so they never block automation. --no-advisory hides them. - -Project config: - Respects .impeccable/config.json and .impeccable/config.local.json detector - settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, - and detector.designSystem.enabled. - -Inline ignores: - In-file comments waive a finding where it lives and travel with the file: - - .brand { font-family: Inter } /* impeccable-disable-line overused-font */ - // impeccable-disable-next-line bounce-easing: intentional bounce - impeccable-disable applies to the whole file; -line / -next-line are scoped. - List one or more rule ids (comma-separated), or omit them / use * for all. - -Detection modes: - HTML files Static HTML/CSS analysis (default, catches linked CSS) - Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) - URLs Puppeteer full browser rendering (auto-detected; - http(s):// and file:// URLs) - -Examples: - impeccable detect src/ - impeccable detect index.html - impeccable detect https://example.com - impeccable detect --json . - impeccable detect --no-config src/`); -} - -async function detectCli() { - let args = process.argv.slice(2).map(arg => { - if (arg === '-json') return '--json'; - if (arg === '-fast') return '--fast'; - return arg; - }); - if (args[0] === 'detect') args = args.slice(1); - const jsonMode = args.includes('--json'); - const quietMode = args.includes('--quiet'); - const helpMode = args.includes('--help'); - const noAdvisory = args.includes('--no-advisory'); - // --fast (regex-only) is deprecated: since the jsdom removal, the static - // HTML/CSS analysis is fast and covers every rule, so the regex-only path - // only loses coverage for no real speed win. Accept the flag for back-compat - // but ignore it and run the full scan. - if (args.includes('--fast')) { - process.stderr.write( - 'Note: --fast is deprecated and ignored. The full scan is fast now and runs every rule.\n', - ); - } - if (args.includes('--gpt') || args.includes('--gemini')) { - process.stderr.write( - 'Note: --gpt and --gemini are deprecated and ignored. Generated-UI tells now run by default.\n', - ); - } - const configEnabled = !args.includes('--no-config'); - const detectionConfig = configEnabled - ? readDetectionConfig(process.cwd()) - : { ignoreRules: [], ignoreFiles: [], ignoreValues: [] }; - const scopes = []; - for (let i = 0; i < args.length; i++) { - if (args[i] !== '--scope' && !args[i].startsWith('--scope=')) continue; - const inline = args[i].startsWith('--scope='); - const value = inline ? args[i].slice('--scope='.length) : args[i + 1]; - const parsed = (value && !value.startsWith('--')) - ? value.split(',').map(s => s.trim()).filter(Boolean) - : []; - // A bare `--scope` would otherwise fall out of `targets` and scan unscoped; - // fail loudly so a mistyped pre-scan never runs the wrong rule set. - if (parsed.length === 0) { - process.stderr.write( - `Error: --scope requires a value. Valid scopes: ${[...RULE_SCOPES].join(', ')}\n`, - ); - process.exit(1); - } - scopes.push(...parsed); - args.splice(i, inline ? 1 : 2); - i -= 1; - } - let viewport = null; - for (let i = 0; i < args.length; i++) { - if (args[i] !== '--viewport' && !args[i].startsWith('--viewport=')) continue; - const inline = args[i].startsWith('--viewport='); - const value = inline ? args[i].slice('--viewport='.length) : args[i + 1]; - const match = /^(\d{2,5})x(\d{2,5})$/i.exec(value || ''); - if (!match) { - process.stderr.write('Error: --viewport requires a WxH value, e.g. --viewport 390x844\n'); - process.exit(1); - } - viewport = { width: Number(match[1]), height: Number(match[2]) }; - args.splice(i, inline ? 1 : 2); - i -= 1; - } - const unknownScopes = scopes.filter(s => !RULE_SCOPES.has(s)); - if (unknownScopes.length > 0) { - process.stderr.write( - `Error: unknown --scope value(s): ${unknownScopes.join(', ')}. Valid scopes: ${[...RULE_SCOPES].join(', ')}\n`, - ); - process.exit(1); - } - const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false; - // Inline `impeccable-disable*` waivers are part of the scanned file, so they - // apply by default. `--no-config` (raw scan) and the dedicated - // `--no-inline-ignores` both turn them off. - const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores'); - const baseScanOptions = { inlineIgnores: inlineIgnoresEnabled }; - if (viewport) baseScanOptions.viewport = viewport; - // DESIGN.md must resolve from EACH scan target's own project root, not from - // process.cwd(): scanning project B's files from inside project A applied A's - // design rules (cross-project contamination). Resolve per target, memoized by - // resolved project root so a multi-file scan pays the read once per project. - // A target with no project marker above it gets no design system (never cwd's). - const designSystemCache = new Map(); - const scanOptionsFor = (localPath) => { - if (!designSystemEnabled || !localPath) return baseScanOptions; - const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache }); - return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions; - }; - const targets = args.filter(a => !a.startsWith('--')); - - if (helpMode) { printUsage(); process.exit(0); } - - let allFindings = []; - - if (!process.stdin.isTTY && targets.length === 0) { - allFindings = await handleStdin(scanOptionsFor); - } else { - const paths = targets.length > 0 ? targets : [process.cwd()]; - // file:// URLs get the same Puppeteer-rendered pass as http(s) — the - // real cascade, real computed styles, real layout. Callers that want a - // browser-grade scan of a local artifact can pass file:///abs/path.html - // instead of the bare path (which stays on the static engine). - const urlRe = /^(?:https?|file):\/\//i; - const urlTargetCount = paths.filter(target => urlRe.test(target)).length; - const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null; - - try { - for (const target of paths) { - if (urlRe.test(target)) { - // A file:// URL points at a local artifact, so its design system - // resolves from that file's project. A remote http(s) URL has no - // local project — it gets base options (no design system), never - // process.cwd()'s. - const urlOptions = /^file:/i.test(target) - ? scanOptionsFor(fileUrlToLocalPath(target)) - : baseScanOptions; - try { - const scanner = browserDetector - ? (url) => browserDetector.detectUrl(url, urlOptions) - : (url) => detectUrl(url, urlOptions); - allFindings.push(...await scanner(target)); - } catch (e) { process.stderr.write(`Error: ${e.message}\n`); } - continue; - } - - const resolved = path.resolve(target); - let stat; - try { stat = fs.statSync(resolved); } - catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; } - - if (stat.isDirectory()) { - // Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) - if (!jsonMode && !quietMode) { - const fwConfig = detectFrameworkConfig(resolved); - if (fwConfig) { - const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint); - if (probe.listening && probe.matched) { - process.stderr.write( - `\n${fwConfig.name} dev server detected on localhost:${fwConfig.port}.\n` + - `For more accurate results, scan the running site:\n` + - ` npx impeccable detect http://localhost:${fwConfig.port}\n\n` - ); - } else if (probe.listening && !probe.matched) { - process.stderr.write( - `\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` + - `Port ${fwConfig.port} is in use by another service. Start the ${fwConfig.name} dev server and scan via URL for best results.\n\n` - ); - } else { - process.stderr.write( - `\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` + - `Start the dev server and scan via URL for best results:\n` + - ` npx impeccable detect http://localhost:${fwConfig.port}\n\n` - ); - } - } - } - - const files = walkDir(resolved) - .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig)); - const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; - - // Warn and confirm if scanning many files (static HTML/CSS processes each HTML file) - if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) { - process.stderr.write( - `\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` + - `Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` + - `Target a specific subdirectory to narrow scope.\n` - ); - const ok = await confirm('Continue?'); - if (!ok) { process.stderr.write('Aborted.\n'); process.exit(0); } - } - - // Build import graph for multi-file awareness - const graph = buildImportGraph(files); - // Build reverse map: file -> set of files that import it - const importedByMap = new Map(); - for (const [importer, imports] of graph) { - for (const imported of imports) { - if (!importedByMap.has(imported)) importedByMap.set(imported, new Set()); - importedByMap.get(imported).add(importer); - } - } - - for (const file of files) { - // Each file resolves its own project design system (cached by root), - // so a scan spanning sibling projects applies the right rules per file. - const fileOptions = scanOptionsFor(file); - const fileFindings = await detectLocalFile(file, fileOptions); - // Annotate findings with import context - const importers = importedByMap.get(file); - if (importers && importers.size > 0) { - const importerNames = [...importers].map(f => path.basename(f)); - for (const f of fileFindings) { - f.importedBy = importerNames; - } - } - allFindings.push(...fileFindings); - } - } else if (stat.isFile()) { - if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; - const fileOptions = scanOptionsFor(resolved); - allFindings.push(...await detectLocalFile(resolved, fileOptions)); - } - } - } finally { - if (browserDetector) await browserDetector.close(); - } - } - - allFindings = filterDetectionFindings(allFindings, detectionConfig); - allFindings = filterByScopes(allFindings, scopes); - // --no-advisory drops advisory findings before any output or exit-code math. - if (noAdvisory) allFindings = allFindings.filter((f) => !isAdvisory(f)); - - // The exit code and failure count reflect non-advisory findings only. An - // advisory-only scan still prints its notes but exits 0 (a clean pass), so - // advisory rules never break CI or block automation. - const { primary, advisory } = partitionAdvisory(allFindings); - - if (allFindings.length > 0) { - if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); - else if (quietMode) { - process.stderr.write(formatFindingSummary(primary.length) + '\n'); - if (advisory.length > 0) { - process.stderr.write(dim(`${advisory.length} advisory note${advisory.length === 1 ? '' : 's'} (not counted).`) + '\n'); - } - } - else process.stderr.write(formatFindings(allFindings, false) + '\n'); - // Set the exit code instead of calling process.exit(): a piped stdout is - // written asynchronously, and exiting right after a large write truncates - // the JSON at the pipe buffer boundary (~64 KiB). - process.exitCode = primary.length > 0 ? 2 : 0; - return; - } - if (jsonMode) process.stdout.write('[]\n'); - process.exitCode = 0; -} - -export { formatFindings, handleStdin, confirm, printUsage, detectCli }; diff --git a/cli/engine/design-system.mjs b/cli/engine/design-system.mjs deleted file mode 100644 index 28e01b1d2..000000000 --- a/cli/engine/design-system.mjs +++ /dev/null @@ -1,1309 +0,0 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; - -import { finding } from './findings.mjs'; -import { GENERIC_FONTS } from './shared/constants.mjs'; -import { parseAnyColor, resolveLengthPx } from './rules/checks.mjs'; - -const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; -const FALLBACK_DIRS = ['.agents/context', 'docs']; -// Files/dirs whose presence marks a directory as a project root. Mirrors the -// walk-up semantics of skill/scripts/context.mjs (`resolveProject`), which the -// CLI can't import (separate tree). `.git` and `package.json` are the common -// boundaries; `.impeccable` is our own project marker. -const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable']; -// Monorepo-root recognition, mirroring context.mjs's isMonorepoRoot: declared -// workspace globs (package.json `workspaces`, pnpm-workspace.yaml `packages:`) -// or a marker file beside apps/ or packages/ children. -const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json']; -const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages']; -const COLOR_CHANNEL_TOLERANCE = 6; -// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the -// difference between a documented shadow and drift), so shadow matching cannot -// reuse the r/g/b-only channel tolerance. -const SHADOW_ALPHA_TOLERANCE = 0.02; -const RADIUS_TOLERANCE_PX = 0.5; -const FONT_SIZE_TOLERANCE_PX = 0.5; -const FONT_SIZE_LITERAL_RE = /^-?[\d.]+(?:px|rem)$/; - -const CSS_COLOR_RE = /#[0-9a-f]{3,8}\b|rgba?\([^)]+\)|oklch\([^)]+\)|hsla?\([^)]+\)/gi; -const FONT_DECL_RE = /font-family\s*:\s*([^;}\n]+)/gi; -const FONT_JS_RE = /fontFamily\s*[:=]\s*["'`]([^"'`]+)["'`]/g; -const GOOGLE_FONT_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi; -const BORDER_RADIUS_RE = /border-radius\s*:\s*([^;}\n]+)/gi; -const BORDER_RADIUS_JS_RE = /borderRadius\s*[:=]\s*["'`]([^"'`]+)["'`]/g; -const FONT_SIZE_DECL_RE = /font-size\s*:\s*([^;}\n]+)/gi; -const FONT_SIZE_JS_RE = /fontSize\s*[:=]\s*["'`]([^"'`]+)["'`]/g; -const TAILWIND_FONT_SIZE_RE = /\btext-\[(-?[\d.]+(?:px|rem))\]/g; -const STATIC_DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']); - -function firstExisting(dir, names) { - for (const name of names) { - const abs = path.join(dir, name); - if (fs.existsSync(abs)) return abs; - } - return null; -} - -function resolveDesignMdPath(cwd = process.cwd()) { - const root = firstExisting(cwd, DESIGN_NAMES); - if (root) return { path: root, contextDir: cwd }; - - for (const rel of FALLBACK_DIRS) { - const dir = path.resolve(cwd, rel); - const found = firstExisting(dir, DESIGN_NAMES); - if (found) return { path: found, contextDir: dir }; - } - - return null; -} - -function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) { - const candidates = [ - path.join(cwd, '.impeccable', 'design.json'), - path.join(cwd, 'DESIGN.json'), - path.join(contextDir, 'DESIGN.json'), - ]; - return candidates.find((candidate, index) => - candidates.indexOf(candidate) === index && fs.existsSync(candidate) - ) || null; -} - -function parseFrontmatter(md) { - const lines = String(md || '').split(/\r?\n/); - if (lines[0]?.trim() !== '---') return null; - let end = -1; - for (let i = 1; i < lines.length; i++) { - if (lines[i].trim() === '---') { end = i; break; } - } - if (end === -1) return null; - try { - return parseYamlSubset(lines.slice(1, end).join('\n')); - } catch { - return null; - } -} - -function parseYamlSubset(yaml) { - const root = {}; - const stack = [{ indent: -1, obj: root }]; - - for (const raw of String(yaml || '').split(/\r?\n/)) { - if (!raw.trim() || /^\s*#/.test(raw)) continue; - const indent = raw.match(/^\s*/)[0].length; - const content = raw.slice(indent); - const colonIdx = findTopLevelColon(content); - if (colonIdx === -1) continue; - - while (stack.length > 1 && stack[stack.length - 1].indent >= indent) stack.pop(); - - const key = unquoteYamlKey(content.slice(0, colonIdx).trim()); - const rest = stripInlineYamlComment(content.slice(colonIdx + 1).trim()); - const parent = stack[stack.length - 1].obj; - - if (rest === '') { - const obj = {}; - parent[key] = obj; - stack.push({ indent, obj }); - } else { - parent[key] = parseScalar(rest); - } - } - - return root; -} - -function findTopLevelColon(s) { - let inQuote = null; - for (let i = 0; i < s.length; i++) { - const ch = s[i]; - if (inQuote) { - if (ch === inQuote && s[i - 1] !== '\\') inQuote = null; - } else if (ch === '"' || ch === "'") { - inQuote = ch; - } else if (ch === ':') { - return i; - } - } - return -1; -} - -function unquoteYamlKey(key) { - if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) { - return key.slice(1, -1); - } - return key; -} - -function stripInlineYamlComment(s) { - let inQuote = null; - for (let i = 0; i < s.length; i++) { - const ch = s[i]; - if (inQuote) { - if (ch === inQuote && s[i - 1] !== '\\') inQuote = null; - } else if (ch === '"' || ch === "'") { - inQuote = ch; - } else if (ch === '#' && i > 0 && /\s/.test(s[i - 1])) { - return s.slice(0, i).trimEnd(); - } - } - return s; -} - -// YAML double-quoted scalars process backslash escapes. Stripping the outer -// quotes without unescaping leaves them in place, so a nested font family like -// fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif" -// reaches allowedFonts as '\"ibm plex sans' and never matches the same family -// declared in CSS. Scanner instead of a regex: the escape set is small and the -// backslash handling stays readable. -// The full YAML 1.2 double-quote escape set (spec section 5.7). -const YAML_SIMPLE_ESCAPES = { - '0': '\0', - a: '\x07', - b: '\b', - t: '\t', - n: '\n', - v: '\v', - f: '\f', - r: '\r', - e: '\x1b', - ' ': ' ', - '"': '"', - '/': '/', - '\\': '\\', - N: '\u0085', - _: '\u00a0', - L: '\u2028', - P: '\u2029', -}; -const YAML_HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 }; - -function unescapeYamlDoubleQuoted(body) { - let out = ''; - for (let i = 0; i < body.length; i++) { - const ch = body[i]; - if (ch !== '\\' || i === body.length - 1) { - out += ch; - continue; - } - const next = body[i + 1]; - if (Object.prototype.hasOwnProperty.call(YAML_SIMPLE_ESCAPES, next)) { - out += YAML_SIMPLE_ESCAPES[next]; - i++; - continue; - } - // \xNN, \uNNNN, \UNNNNNNNN. Malformed or out-of-range sequences stay - // literal rather than corrupting the rest of the scalar. - const hexLen = YAML_HEX_ESCAPE_LENGTHS[next]; - if (hexLen) { - const hex = body.slice(i + 2, i + 2 + hexLen); - const codePoint = hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex) ? parseInt(hex, 16) : -1; - if (codePoint >= 0 && codePoint <= 0x10ffff) { - out += String.fromCodePoint(codePoint); - i += 1 + hexLen; - continue; - } - } - out += ch; - } - return out; -} - -function parseScalar(raw) { - const s = raw.trim(); - if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) { - return unescapeYamlDoubleQuoted(s.slice(1, -1)); - } - // Single-quoted YAML escapes only the quote itself, by doubling it. - if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) { - return s.slice(1, -1).split("''").join("'"); - } - if (s === 'true') return true; - if (s === 'false') return false; - if (s === 'null' || s === '~') return null; - if (/^-?\d+$/.test(s)) return Number(s); - if (/^-?\d*\.\d+$/.test(s)) return Number(s); - return s; -} - -function safeReadJson(filePath) { - if (!filePath) return null; - try { - return JSON.parse(fs.readFileSync(filePath, 'utf-8')); - } catch { - return null; - } -} - -function normalizeFontName(value) { - return String(value || '') - .trim() - .replace(/\s*!important\s*$/i, '') - .trim() - .replace(/^["']|["']$/g, '') - .replace(/\+/g, ' ') - .replace(/\s+/g, ' ') - .toLowerCase(); -} - -function splitFontStack(stack) { - return String(stack || '') - .replace(/\s*!important\s*$/i, '') - .split(',') - .map(normalizeFontName) - .filter(Boolean); -} - -function primaryFont(stack) { - if (!stack || /var\(/i.test(stack) || !isLiteralFontStack(stack)) return ''; - return splitFontStack(stack).find(font => !GENERIC_FONTS.has(font)) || ''; -} - -function isLiteralFontStack(stack) { - const text = String(stack || ''); - return !/[$`{}]|\s\+\s|\|\|/.test(text); -} - -function cssColorLabel(raw) { - return String(raw || '').trim().replace(/\s+/g, ' '); -} - -function colorKey(color) { - if (!color) return ''; - return `${color.r},${color.g},${color.b}`; -} - -function colorsClose(a, b) { - if (!a || !b) return false; - return Math.max( - Math.abs(a.r - b.r), - Math.abs(a.g - b.g), - Math.abs(a.b - b.b), - ) <= COLOR_CHANNEL_TOLERANCE; -} - -function hslToRgb(H, S, L, alpha = 1) { - const h = (((H % 360) + 360) % 360) / 360; - const s = Math.max(0, Math.min(1, S)); - const l = Math.max(0, Math.min(1, L)); - const hue2rgb = (p, q, t) => { - if (t < 0) t += 1; - if (t > 1) t -= 1; - if (t < 1 / 6) return p + (q - p) * 6 * t; - if (t < 1 / 2) return q; - if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; - return p; - }; - const q = l < 0.5 ? l * (1 + s) : l + s - l * s; - const p = 2 * l - q; - return { - r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255), - g: Math.round(hue2rgb(p, q, h) * 255), - b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255), - a: alpha, - }; -} - -function parseDesignColor(value) { - const text = String(value || '').trim(); - const parsed = parseAnyColor(text); - if (parsed) return parsed; - const hsl = text.match(/hsla?\(\s*([-\d.]+)(?:deg)?\s*,?\s*([\d.]+)%\s*,?\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+))?\s*\)/i); - if (hsl) { - return hslToRgb( - parseFloat(hsl[1]), - parseFloat(hsl[2]) / 100, - parseFloat(hsl[3]) / 100, - hsl[4] !== undefined ? parseFloat(hsl[4]) : 1, - ); - } - return null; -} - -function addDesignColor(out, value, label) { - const parsed = parseDesignColor(value); - if (!parsed) return; - const key = colorKey(parsed); - if (!out.allowedColorKeys.has(key)) { - out.allowedColorKeys.set(key, { color: parsed, labels: [] }); - } - out.allowedColorKeys.get(key).labels.push(label || cssColorLabel(value)); -} - -function addColorObject(out, colors, prefix = 'colors') { - if (!colors || typeof colors !== 'object') return; - for (const [name, value] of Object.entries(colors)) { - if (typeof value === 'string') { - addDesignColor(out, value, `${prefix}.${name}`); - } - } -} - -function addSidecarColors(out, sidecar) { - const colorMeta = sidecar?.extensions?.colorMeta; - if (!colorMeta || typeof colorMeta !== 'object') return; - - for (const [name, meta] of Object.entries(colorMeta)) { - if (!meta || typeof meta !== 'object') continue; - if (typeof meta.canonical === 'string') addDesignColor(out, meta.canonical, `sidecar.${name}`); - if (Array.isArray(meta.tonalRamp)) { - for (const [index, value] of meta.tonalRamp.entries()) { - if (typeof value === 'string') addDesignColor(out, value, `sidecar.${name}.tonalRamp[${index}]`); - } - } - } -} - -function addTypographyFonts(out, typography) { - if (!typography || typeof typography !== 'object') return; - for (const role of Object.values(typography)) { - if (!role || typeof role !== 'object') continue; - if (typeof role.fontFamily !== 'string') continue; - for (const font of splitFontStack(role.fontFamily)) { - if (!GENERIC_FONTS.has(font)) out.allowedFonts.add(font); - } - } -} - -function addFontSizeStep(out, raw, { fluid = false } = {}) { - const text = String(raw ?? '').trim().toLowerCase(); - if (!FONT_SIZE_LITERAL_RE.test(text)) return; - const px = resolveLengthPx(text, 16); - if (px == null || !Number.isFinite(px) || px <= 0) return; - out.allowedFontSizes.push({ value: text, px, fluid }); -} - -// Split a fluid value into its three terms, or null when it is not a -// well-formed clamp(). Used both to read DESIGN.md's fluid roles and to -// validate fluid values in source, so the two stay symmetric. -function parseClampArgs(raw) { - const match = /^clamp\(\s*([\s\S]+)\s*\)$/i.exec(String(raw ?? '').trim()); - if (!match) return null; - const args = splitTopLevelArgs(match[1]); - return args.length === 3 ? args : null; -} - -// A fluid role declares its two fixed endpoints and interpolates between them -// with a viewport unit. Both endpoints are documented sizes, so they belong in -// the allowlist; the middle term is viewport-relative and never a fixed step. -// Endpoints are marked `fluid` because they do not *enumerate* a ramp: see -// `hasFontSizes` below for why that distinction has to survive. -function addClampEndpoints(out, raw) { - const args = parseClampArgs(raw); - if (!args) return false; - addFontSizeStep(out, args[0], { fluid: true }); - addFontSizeStep(out, args[2], { fluid: true }); - return true; -} - -function splitTopLevelArgs(s) { - const args = []; - let depth = 0; - let current = ''; - for (const ch of String(s)) { - if (ch === '(') depth++; - else if (ch === ')') depth--; - if (ch === ',' && depth === 0) { - args.push(current.trim()); - current = ''; - continue; - } - current += ch; - } - if (current.trim()) args.push(current.trim()); - return args; -} - -function addTypographySizes(out, typography) { - if (!typography || typeof typography !== 'object') return; - - // `scale` is the enumerated ramp: a name -> size map, since the frontmatter - // parser has no list support. It sits alongside the named roles. - const scale = typography.scale; - if (scale && typeof scale === 'object') { - for (const value of Object.values(scale)) { - if (typeof value !== 'string' && typeof value !== 'number') continue; - addFontSizeStep(out, value); - } - } - - for (const [name, role] of Object.entries(typography)) { - if (name === 'scale') continue; - if (!role || typeof role !== 'object') continue; - const raw = String(role.fontSize ?? '').trim().toLowerCase(); - if (addClampEndpoints(out, raw)) continue; - addFontSizeStep(out, raw); - } -} - -function addRoundedScale(out, rounded) { - if (!rounded || typeof rounded !== 'object') return; - for (const [rawName, value] of Object.entries(rounded)) { - const name = unquoteYamlKey(rawName).toLowerCase(); - addRoundedToken(out, name, value); - } -} - -function addRoundedToken(out, name, value) { - if (typeof value !== 'string' && typeof value !== 'number') return; - const raw = String(value).trim(); - if (!raw || /var\(/i.test(raw) || raw.includes('%')) return; - const px = resolveLengthPx(raw, 16); - if (px == null || !Number.isFinite(px)) return; - out.allowedRadii.push({ name, value: raw, px }); - if (/(^|\.)(full|pill|round|rounded-full)$/.test(name)) out.hasPillRadius = true; -} - -function addSidecarRadii(out, sidecar) { - const roundedMeta = sidecar?.extensions?.roundedMeta; - if (!roundedMeta || typeof roundedMeta !== 'object') return; - - for (const [rawName, meta] of Object.entries(roundedMeta)) { - const name = unquoteYamlKey(rawName).toLowerCase(); - if (typeof meta === 'string' || typeof meta === 'number') { - addRoundedToken(out, `sidecar.${name}`, meta); - continue; - } - if (!meta || typeof meta !== 'object') continue; - for (const key of ['canonical', 'value']) { - if (typeof meta[key] === 'string' || typeof meta[key] === 'number') { - addRoundedToken(out, `sidecar.${name}.${key}`, meta[key]); - } - } - for (const key of ['values', 'aliases']) { - if (!Array.isArray(meta[key])) continue; - for (const [index, value] of meta[key].entries()) { - addRoundedToken(out, `sidecar.${name}.${key}[${index}]`, value); - } - } - if (/^(full|pill|round|rounded-full)$/.test(name) || /^(full|pill|round)$/i.test(String(meta.role || ''))) { - out.hasPillRadius = true; - } - } -} - -// Sidecar `extensions.shadows` entries ({ name, value, purpose }) carry the -// documented shadow vocabulary that Stitch's frontmatter schema can't hold. -// Their colors go into a separate allowlist — NOT allowedColorKeys — because a -// shadow black is only documented *as a shadow*: feeding it into the general -// color allowlist would legalize #000 as a page ground (alpha is dropped from -// colorKey), which is the hole issue #547 warns against. -function addSidecarShadows(out, sidecar) { - const shadows = sidecar?.extensions?.shadows; - if (!Array.isArray(shadows)) return; - - for (const entry of shadows) { - if (typeof entry?.value !== 'string') continue; - for (const match of entry.value.matchAll(CSS_COLOR_RE)) { - const parsed = parseDesignColor(match[0]); - if (parsed) out.allowedShadowColors.push({ color: parsed }); - } - } -} - -function normalizeDesignSystem(input = {}) { - const frontmatter = input.frontmatter || {}; - const sidecar = input.sidecar || null; - const out = { - present: true, - sourcePath: input.sourcePath || null, - sidecarPath: input.sidecarPath || null, - mdNewerThanJson: input.mdNewerThanJson === true, - allowedFonts: new Set(), - allowedColorKeys: new Map(), - allowedRadii: [], - allowedFontSizes: [], - allowedShadowColors: [], - hasPillRadius: false, - }; - - addTypographyFonts(out, frontmatter.typography); - addTypographySizes(out, frontmatter.typography); - addColorObject(out, frontmatter.colors); - addSidecarColors(out, sidecar); - addRoundedScale(out, frontmatter.rounded); - addSidecarRadii(out, sidecar); - addSidecarShadows(out, sidecar); - - out.hasFonts = out.allowedFonts.size > 0; - out.hasColors = out.allowedColorKeys.size > 0; - out.hasRadii = out.allowedRadii.length > 0; - // Gate on *enumerated* steps only. A fully fluid system declares clamp - // endpoints but no discrete ramp, so treating those endpoints as the whole - // allowlist would flag every intermediate size. Abstain instead. - out.hasFontSizes = out.allowedFontSizes.some(entry => !entry.fluid); - return out; -} - -function loadDesignSystemForCwd(cwd = process.cwd()) { - const md = resolveDesignMdPath(cwd); - if (!md) return null; - - let frontmatter = null; - let mdStat = null; - try { - mdStat = fs.statSync(md.path); - frontmatter = parseFrontmatter(fs.readFileSync(md.path, 'utf-8')); - } catch { - return null; - } - if (!frontmatter || typeof frontmatter !== 'object') return null; - - const sidecarPath = resolveDesignSidecarPath(cwd, md.contextDir); - const sidecar = safeReadJson(sidecarPath); - let sidecarStat = null; - try { - if (sidecarPath) sidecarStat = fs.statSync(sidecarPath); - } catch { - sidecarStat = null; - } - - return normalizeDesignSystem({ - frontmatter, - sidecar, - sourcePath: md.path, - sidecarPath, - mdNewerThanJson: !!(mdStat && sidecarStat && mdStat.mtimeMs > sidecarStat.mtimeMs + 1000), - }); -} - -// Directory to begin the project-root walk from, given a scan target that may -// be a file or a directory (and may not exist yet). -function designSystemStartDir(targetPath, cwd = process.cwd()) { - const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath); - try { - return fs.statSync(abs).isDirectory() ? abs : path.dirname(abs); - } catch { - // Nonexistent path: treat an extension-bearing leaf as a file. - return path.extname(abs) ? path.dirname(abs) : abs; - } -} - -// Same two groups as context.mjs's readProjectPatternGroups: Impeccable -// projectRoots govern any path they match (positive or negated); package-manager -// globs only apply to paths the Impeccable group does not match. -function readWorkspacePatternGroups(dir) { - const impeccable = []; - for (const name of ['config.json', 'config.local.json']) { - const roots = safeReadJson(path.join(dir, '.impeccable', name))?.projectRoots; - if (Array.isArray(roots)) { - impeccable.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim())); - } - } - const pkg = []; - const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces; - if (Array.isArray(workspaces)) pkg.push(...workspaces); - else if (Array.isArray(workspaces?.packages)) pkg.push(...workspaces.packages); - const lernaPackages = safeReadJson(path.join(dir, 'lerna.json'))?.packages; - if (Array.isArray(lernaPackages)) pkg.push(...lernaPackages); - try { - let inPackages = false; - for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) { - const trimmed = stripInlineYamlComment(line).trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/); - if (flow) { - pkg.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)); - break; - } - if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; } - if (!inPackages) continue; - const item = trimmed.match(/^-\s*(.+)$/); - if (item) pkg.push(item[1].trim().replace(/^['"]|['"]$/g, '')); - else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break; - } - } catch { /* no pnpm-workspace.yaml */ } - return [impeccable, pkg]; -} - -function readWorkspacePatterns(dir) { - return readWorkspacePatternGroups(dir).flat(); -} - -function isMonorepoRoot(dir) { - if (readWorkspacePatterns(dir).some(pattern => !String(pattern).trim().startsWith('!'))) return true; - if (!MONOREPO_MARKER_FILES.some(file => fs.existsSync(path.join(dir, file)))) return false; - return MONOREPO_FALLBACK_PROJECT_DIRS.some(name => { - try { - return fs.readdirSync(path.join(dir, name), { withFileTypes: true }).some(entry => entry.isDirectory()); - } catch { - return false; - } - }); -} - -function monorepoOwnsPath(root, boundaryDir) { - const rel = path.relative(root, boundaryDir); - if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false; - const relSegments = rel.split(path.sep).filter(Boolean); - - function normalizeWorkspacePattern(pattern) { - return String(pattern || '') - .trim() - .replace(/^['"]|['"]$/g, '') - .replace(/^\.\//, '') - .replace(/\/+$/, ''); - } - - function escapeRegExp(s) { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - } - - function segmentMatches(patternSegment, relSegment) { - if (patternSegment === '*') return true; - if (!patternSegment.includes('*')) return patternSegment === relSegment; - const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`); - return re.test(relSegment); - } - - function matchGlobSegments(patternSegments, relSegments) { - function rec(pi, ri) { - if (pi === patternSegments.length) return ri === relSegments.length; - if (patternSegments[pi] === '**') { - if (pi === patternSegments.length - 1) return true; - for (let k = ri; k <= relSegments.length; k++) { - if (rec(pi + 1, k)) return true; - } - return false; - } - if (ri >= relSegments.length) return false; - if (!segmentMatches(patternSegments[pi], relSegments[ri])) return false; - return rec(pi + 1, ri + 1); - } - return rec(0, 0); - } - - // Negations like !packages/excluded must also cover nested dirs under that path. - function matchesNegation(pattern) { - const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean); - if (!patternSegments.length) return false; - if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments); - if (relSegments.length < patternSegments.length) return false; - for (let i = 0; i < patternSegments.length; i++) { - if (!segmentMatches(patternSegments[i], relSegments[i])) return false; - } - return true; - } - - // Positive globs identify workspace packages at exact depth (`*` is a direct - // child). A nested package.json under that package is still owned: the - // ancestor directory of glob length must itself be a package. - function positiveOwns(pattern) { - const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean); - if (!patternSegments.length) return false; - if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments); - if (relSegments.length < patternSegments.length) return false; - for (let i = 0; i < patternSegments.length; i++) { - if (!segmentMatches(patternSegments[i], relSegments[i])) return false; - } - if (relSegments.length === patternSegments.length) return true; - const ancestorDir = path.join(root, ...relSegments.slice(0, patternSegments.length)); - return fs.existsSync(path.join(ancestorDir, 'package.json')); - } - - function groupOwns(rawPatterns) { - const patterns = rawPatterns.map(normalizeWorkspacePattern).filter(Boolean); - if (!patterns.length) return null; - const excluded = patterns.some((pattern) => ( - pattern.startsWith('!') && matchesNegation(pattern.slice(1)) - )); - const included = patterns.filter((pattern) => !pattern.startsWith('!')).some(positiveOwns); - if (!excluded && !included) return null; - if (excluded) return false; - return true; - } - - const [impeccable, pkg] = readWorkspacePatternGroups(root); - const fromImpeccable = groupOwns(impeccable); - if (fromImpeccable !== null) return fromImpeccable; - const fromPkg = groupOwns(pkg); - if (fromPkg !== null) return fromPkg; - if ([...impeccable, ...pkg].some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) { - return false; - } - return relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]); -} - -// Both forms of the home directory. The walk compares path strings, and a -// symlinked home (e.g. /home -> /var/home) never string-matches the physical -// paths a cwd-resolved target produces, which would let the post-boundary walk -// sail through $HOME and inherit from it. -function homeDirForms() { - const homeDir = path.resolve(os.homedir()); - const forms = new Set([homeDir]); - try { - forms.add(fs.realpathSync(homeDir)); - } catch { /* keep the logical form only */ } - return forms; -} - -// Walk up from `startDir` to the directory that governs the target's design -// system, mirroring skill/scripts/context.mjs's project-boundary semantics: -// -// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the -// design root — that's where the rules live. -// - A directory carrying a project marker (.git / package.json / .impeccable) -// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits -// the ancestor DESIGN.md only when that ancestor's workspace declarations -// include the path (negations win; a nested package under a matched -// workspace still inherits). Marker-only roots (turbo/nx/lerna/pnpm -// with no globs) still own apps/ and packages/. A stray nested -// package that matches no glob does not inherit. This is detect's -// contamination contract, not skill-context's repoRoot fallback for -// excluded paths. A nested separate repository (.git with no workspace -// declaration) still inherits nothing (issue #570). -// - Reaching the home directory / filesystem root with neither means no -// design system at all — never process.cwd()'s. -// -// Returns { dir, hasDesign } for the stopping directory, or null when the walk -// runs out. This is the fix for cross-project contamination. -export function findDesignRoot(startDir) { - let dir = path.resolve(startDir); - const homeDirs = homeDirForms(); - let boundary = null; - while (true) { - if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true }; - if (boundary) { - // Past the boundary the walk only looks for the monorepo root that owns - // the workspace path (workspace globs including negations, or marker-only - // apps/packages fallback). Monorepo-root before .git, same order as - // context.mjs: a workspace root carrying its own .git is still recognized, - // while a .git that declares no workspaces is a separate repository and - // stops the walk with nothing inherited. The home directory is never an - // owning root, same as context.mjs's findMonorepoRoot, which stops at - // homeDir before its monorepo check. - if (!homeDirs.has(dir) && isMonorepoRoot(dir)) { - if (monorepoOwnsPath(dir, boundary.dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) }; - return boundary; - } - if (fs.existsSync(path.join(dir, '.git'))) return boundary; - } else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) { - boundary = { dir, hasDesign: false }; - // A boundary that is itself a monorepo root, or a separate repository - // with its own .git, inherits nothing from above. - if (isMonorepoRoot(dir) || fs.existsSync(path.join(dir, '.git'))) return boundary; - } - if (homeDirs.has(dir)) return boundary; - const parent = path.dirname(dir); - if (parent === dir) return boundary; - dir = parent; - } -} - -// Resolve the design system that governs a specific scan target, by walking up -// from the target's own location — never process.cwd(). Scanning project B's -// files from inside project A applies B's DESIGN.md (or none), not A's. -// -// Pass a `cache` Map to memoize by resolved design root across a multi-file -// scan; a target with no design root above it resolves to null. -export function loadDesignSystemForTarget(targetPath, { cache, cwd = process.cwd() } = {}) { - const startDir = designSystemStartDir(targetPath, cwd); - const found = findDesignRoot(startDir); - const key = found ? `root:${found.dir}` : '\0none'; - if (cache && cache.has(key)) return cache.get(key); - const loaded = found?.hasDesign ? loadDesignSystemForCwd(found.dir) : null; - if (cache) cache.set(key, loaded); - return loaded; -} - -function isAllowedFont(font, designSystem) { - if (!font || GENERIC_FONTS.has(font)) return true; - if (!designSystem?.hasFonts) return true; - return designSystem.allowedFonts.has(font); -} - -function isAllowedColorRaw(raw, designSystem) { - if (!designSystem?.hasColors) return true; - const text = String(raw || '').trim().toLowerCase(); - if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true; - if (text.includes('var(')) return true; - const parsed = parseDesignColor(text); - if (!parsed) return true; - if ((parsed.a ?? 1) <= 0.05) return true; - for (const entry of designSystem.allowedColorKeys.values()) { - if (colorsClose(parsed, entry.color)) return true; - } - return false; -} - -// A color is a documented shadow color only when both the r/g/b channels AND -// the alpha match a sidecar shadow token's color. Alpha has to be compared -// here because colorKey()/colorsClose() drop it, and a match on r/g/b alone -// would let every black at every alpha through. -function isAllowedShadowColorRaw(raw, designSystem) { - if (!designSystem?.allowedShadowColors?.length) return false; - const parsed = parseDesignColor(String(raw || '').trim().toLowerCase()); - if (!parsed) return false; - return designSystem.allowedShadowColors.some(entry => - colorsClose(parsed, entry.color) && - Math.abs((parsed.a ?? 1) - (entry.color.a ?? 1)) <= SHADOW_ALPHA_TOLERANCE, - ); -} - -function isAllowedRadiusRaw(raw, designSystem) { - if (!designSystem?.hasRadii) return true; - const text = String(raw || '').trim().toLowerCase(); - if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true; - if (text.includes('var(') || text.includes('%')) return true; - const px = resolveLengthPx(text, 16); - if (px == null || !Number.isFinite(px) || px <= RADIUS_TOLERANCE_PX) return true; - if (designSystem.hasPillRadius && px >= 99) return true; - return designSystem.allowedRadii.some(entry => Math.abs(entry.px - px) <= RADIUS_TOLERANCE_PX); -} - -// One term of a font-size value. `unjudgeable` covers var(), calc(), percentages -// and units the ramp cannot resolve (em is parent-relative, not root-relative); -// those abstain rather than guess. -function fontSizeStepStatus(raw, designSystem) { - const text = String(raw || '').trim().toLowerCase(); - if (!FONT_SIZE_LITERAL_RE.test(text)) return 'unjudgeable'; - const px = resolveLengthPx(text, 16); - if (px == null || !Number.isFinite(px) || px <= 0) return 'unjudgeable'; - return designSystem.allowedFontSizes.some( - entry => Math.abs(entry.px - px) <= FONT_SIZE_TOLERANCE_PX, - ) ? 'on-ramp' : 'off-ramp'; -} - -// The off-ramp endpoints of a fluid value, or null when `raw` is not a fluid -// value at all. Only the min and max are judged: the viewport term interpolates -// between them and is never a fixed step. -// -// Reading clamp endpoints as documented steps without also checking them in -// usage would let `clamp(99rem, 1vw, 200rem)` through, which is how a fluid -// declaration stayed invisible until someone measured computed styles. -export function offRampClampEndpoints(raw, designSystem) { - if (!designSystem?.hasFontSizes) return null; - const args = parseClampArgs(String(raw || '').trim().replace(/\s*!important\s*$/i, '')); - if (!args) return null; - return [args[0], args[2]].filter( - endpoint => fontSizeStepStatus(endpoint, designSystem) === 'off-ramp', - ); -} - -function isAllowedFontSizeRaw(raw, designSystem) { - if (!designSystem?.hasFontSizes) return true; - const text = String(raw || '').trim().toLowerCase().replace(/\s*!important\s*$/, ''); - const offRampEndpoints = offRampClampEndpoints(text, designSystem); - if (offRampEndpoints) return offRampEndpoints.length === 0; - return fontSizeStepStatus(text, designSystem) !== 'off-ramp'; -} - -function lineLooksCommented(line) { - const trimmed = String(line || '').trim(); - return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*') || trimmed.startsWith('/g, ' ') - .replace(/<[^>]+>/g, ' ') - .replace(/\s+/g, ' '); -} - -const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']); - -function extFromFilePath(filePath) { - return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : ''; -} - -function shouldRunPageAnalyzers(content, filePath) { - if (!isFullPage(content)) return false; - const ext = extFromFilePath(filePath); - return !ext || PAGE_ANALYZER_EXTS.has(ext); -} - -const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']); -const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']); -const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']); -const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']); - -function isInsideOpeningJsxTag(source) { - const tagStart = source.lastIndexOf('<'); - if (tagStart === -1 || !/^<[A-Za-z][\w.:-]*/.test(source.slice(tagStart))) return false; - - let quote = ''; - for (let cursor = tagStart + 1; cursor < source.length; cursor++) { - const char = source[cursor]; - if (quote) { - if (char === '\\') cursor++; - else if (char === quote) quote = ''; - } else if (char === "'" || char === '"') { - quote = char; - } else if (char === '>') { - return false; - } - } - return true; -} - -/** - * Blank JavaScript comments without moving any following source. Regex - * findings keep their original line numbers, while prose examples inside - * comments cannot masquerade as rendered markup. - */ -function stripJsComments(content, options = {}) { - let state = 'code'; - let output = ''; - let lastSignificant = ''; - let previousSignificant = ''; - let antePreviousSignificant = ''; - let currentWord = ''; - let currentWordPrefix = ''; - let wordSeparated = false; - let regexCharClass = false; - let jsxExpressionDepth = 0; - let lastClosedBraceKind = ''; - const braceKinds = []; - const templateExpressionDepths = []; - - const braceKind = (startsJsxExpression = false) => ( - !startsJsxExpression && ( - !lastSignificant || - lastSignificant === ')' || - lastSignificant === ';' || - lastSignificant === '}' || - (previousSignificant === '=' && lastSignificant === '>') || - BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord) - ) ? 'block' : 'expression' - ); - - const recordSignificant = (char) => { - if (/\s/.test(char)) { - wordSeparated = true; - return; - } - const isWordChar = /[\w$]/.test(char); - if (isWordChar && (wordSeparated || !currentWord)) { - currentWord = ''; - currentWordPrefix = lastSignificant; - } else if (!isWordChar) { - currentWordPrefix = ''; - } - wordSeparated = false; - antePreviousSignificant = previousSignificant; - previousSignificant = lastSignificant; - lastSignificant = char; - currentWord = isWordChar ? currentWord + char : ''; - }; - - for (let i = 0; i < content.length; i++) { - const char = content[i]; - const next = content[i + 1]; - - if (state === 'line-comment') { - if (char === '\n') { - output += char; - state = 'code'; - } else { - output += ' '; - } - continue; - } - - if (state === 'block-comment') { - if (char === '*' && next === '/') { - output += ' '; - i++; - state = 'code'; - } else { - output += char === '\n' ? '\n' : ' '; - } - continue; - } - - if (state === 'regex') { - output += char; - if (char === '\\' && next) { - output += next; - i++; - } else if (char === '[') { - regexCharClass = true; - } else if (char === ']') { - regexCharClass = false; - } else if (char === '/' && !regexCharClass) { - state = 'code'; - recordSignificant('/'); - } - continue; - } - - if (state === 'template' && char === '$' && next === '{') { - output += '${'; - i++; - recordSignificant('$'); - recordSignificant('{'); - templateExpressionDepths.push(1); - braceKinds.push('expression'); - if (jsxExpressionDepth) jsxExpressionDepth++; - state = 'code'; - continue; - } - - if (state !== 'code') { - output += char; - if (char === '\\' && next) { - output += next; - i++; - } else if ( - (state === 'single-quote' && char === "'") || - (state === 'double-quote' && char === '"') || - (state === 'template' && char === '`') - ) { - state = 'code'; - recordSignificant(char); - } - continue; - } - - const jsxUrlSeparator = options.jsx && char === '/' && next === '/' && - jsxExpressionDepth === 0 && - (output.endsWith('http:') || - output.endsWith('https:') || - (/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1)) && - /^[\w.-]+\.[A-Za-z]{2,}(?=[:/?#\s<]|$)/.test(content.slice(i + 2)))); - const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') && - previousSignificant === lastSignificant && - antePreviousSignificant !== lastSignificant; - if (char === '/' && next === '/' && jsxUrlSeparator) { - output += '//'; - i++; - recordSignificant('/'); - recordSignificant('/'); - } else if (char === '/' && next === '/') { - output += ' '; - i++; - state = 'line-comment'; - } else if (char === '/' && next === '*') { - output += ' '; - i++; - state = 'block-comment'; - } else if (templateExpressionDepths.length && char === '{') { - output += char; - templateExpressionDepths[templateExpressionDepths.length - 1]++; - braceKinds.push(braceKind()); - if (jsxExpressionDepth) jsxExpressionDepth++; - recordSignificant(char); - } else if (templateExpressionDepths.length && char === '}') { - output += char; - const depthIndex = templateExpressionDepths.length - 1; - templateExpressionDepths[depthIndex]--; - lastClosedBraceKind = braceKinds.pop() || ''; - if (jsxExpressionDepth) jsxExpressionDepth--; - recordSignificant(char); - if (templateExpressionDepths[depthIndex] === 0) { - templateExpressionDepths.pop(); - state = 'template'; - } - } else if ( - char === '/' && - (!lastSignificant || - (/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) || - (lastSignificant === '}' && lastClosedBraceKind === 'block') || - (previousSignificant === '=' && lastSignificant === '>') || - (currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord))) - ) { - output += char; - state = 'regex'; - regexCharClass = false; - } else { - output += char; - const startsJsxExpression = options.jsx && char === '{' && jsxExpressionDepth === 0 && - (/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1, -1)) || - isInsideOpeningJsxTag(output.slice(0, -1))); - if (char === '{') braceKinds.push(braceKind(startsJsxExpression)); - else if (char === '}') lastClosedBraceKind = braceKinds.pop() || ''; - if (char === '{' && (jsxExpressionDepth || startsJsxExpression)) jsxExpressionDepth++; - else if (char === '}' && jsxExpressionDepth) jsxExpressionDepth--; - recordSignificant(char); - if (char === "'") state = 'single-quote'; - else if (char === '"') state = 'double-quote'; - else if (char === '`') state = 'template'; - } - } - - return output; -} - -function stripCssComments(content) { - return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' ')); -} - -function blankHtmlComments(text) { - return text.replace(//g, comment => comment.replace(/[^\n]/g, ' ')); -} - -function blankCssLineCommentsInStyleBlocks(text) { - const re = /]*>([\s\S]*?)<\/style>/gi; - let output = ''; - let lastIndex = 0; - let match; - while ((match = re.exec(text)) !== null) { - const inner = match[1]; - const openLength = match[0].length - inner.length - ''.length; - output += text.slice(lastIndex, match.index); - output += match[0].slice(0, openLength); - output += blankCssLineComments(inner); - output += match[0].slice(openLength + inner.length); - lastIndex = re.lastIndex; - } - return output + text.slice(lastIndex); -} - -function blankHtmlAndCssCommentsOutsideScripts(text) { - const re = /]*>[\s\S]*?<\/script>/gi; - let output = ''; - let lastIndex = 0; - let match; - while ((match = re.exec(text)) !== null) { - output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index)))); - output += match[0]; - lastIndex = re.lastIndex; - } - return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex)))); -} - -function blankCssLineComments(text) { - let output = ''; - let state = 'code'; - let urlDepth = 0; - for (let i = 0; i < text.length; i++) { - const char = text[i]; - const next = text[i + 1]; - if (state === 'line') { - if (char === '\n') { - output += '\n'; - state = 'code'; - } else { - output += ' '; - } - continue; - } - if (state === 'single' || state === 'double') { - output += char; - if (char === '\\' && next) { - output += next; - i++; - } else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) { - state = 'code'; - } - continue; - } - const prev = output.length ? output[output.length - 1] : ''; - if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') { - output += ' '; - i++; - state = 'line'; - continue; - } - if (char === "'") state = 'single'; - else if (char === '"') state = 'double'; - if (char === '(') { - const behind = output.replace(/\s+$/, ''); - if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++; - } else if (char === ')' && urlDepth) { - urlDepth--; - } - output += char; - } - return output; -} - -function findAstroFrontmatterClose(text) { - if (!text.startsWith('---')) return -1; - let cursor = text.indexOf('\n'); - if (cursor === -1) return -1; - cursor += 1; - while (cursor < text.length) { - if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) { - let end = cursor + 3; - while (text[end] === ' ' || text[end] === '\t') end++; - if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1; - } - const char = text[cursor]; - const next = text[cursor + 1]; - if (char === "'" || char === '"') { - const close = findQuotedStringEnd(text, cursor, char); - if (close === -1) return -1; - cursor = close + 1; - continue; - } - if (char === '`') { - const close = findTemplateLiteralEnd(text, cursor); - if (close === -1) return -1; - cursor = close + 1; - continue; - } - if (char === '/' && next === '/') { - const lineEnd = text.indexOf('\n', cursor); - if (lineEnd === -1) return -1; - cursor = lineEnd; - continue; - } - if (char === '/' && next === '*') { - const commentEnd = text.indexOf('*/', cursor + 2); - if (commentEnd === -1) return -1; - cursor = commentEnd + 2; - continue; - } - if (char === '/' && next !== '/' && next !== '*') { - const close = findRegexLiteralEnd(text, cursor); - if (close !== -1) { - cursor = close + 1; - continue; - } - } - cursor++; - } - return -1; -} - -function blankAstroFrontmatterComments(text) { - const close = findAstroFrontmatterClose(text); - if (close === -1) return text; - return stripJsComments(text.slice(0, close)) + text.slice(close); -} - -function blankCommentsForMatchers(text, ext) { - if (PAGE_ANALYZER_EXTS.has(ext)) { - const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text; - return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter); - } - if (STYLESHEET_EXTS.has(ext)) { - const withoutBlocks = stripCssComments(text); - return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks); - } - return text; -} - -function firstOverusedGoogleFont(text) { - return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || ''; -} - -// CSS named colors whose channels are equal (achromatic). Anything outside -// this set falls through to the format parsers, and an unrecognized spelling -// stays non-neutral so a real accent is never skipped. -const NEUTRAL_COLOR_KEYWORDS = new Set([ - 'transparent', 'currentcolor', - 'black', 'white', 'gray', 'grey', 'silver', - 'dimgray', 'dimgrey', 'darkgray', 'darkgrey', 'lightgray', 'lightgrey', - 'gainsboro', 'whitesmoke', -]); - -function hexChannels(color) { - const long = color.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})(?:[0-9a-f]{2})?$/i); - if (long) return [parseInt(long[1], 16), parseInt(long[2], 16), parseInt(long[3], 16)]; - const short = color.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])(?:[0-9a-f])?$/i); - if (short) return [1, 2, 3].map((i) => parseInt(short[i] + short[i], 16)); - return null; -} - -/** - * Split one box-shadow layer into top-level tokens. - * - * Whitespace inside parens does not separate tokens: `rgb(0 0 0)` and - * `var(--x, 4px)` are each a single value, and splitting them on spaces would - * read their innards as separate lengths. - */ -function tokenizeShadowLayer(layer) { - const tokens = []; - let depth = 0; - let current = ''; - for (const char of String(layer || '')) { - if (char === '(') depth++; - else if (char === ')') depth--; - else if (depth === 0 && /\s/.test(char)) { - if (current) tokens.push(current); - current = ''; - continue; - } - current += char; - } - if (current) tokens.push(current); - return tokens; -} - -function lastMatch(text, re) { - const all = [...String(text || '').matchAll(re)]; - return all.length ? all[all.length - 1] : null; -} - -function isShadowLength(token) { - return /^-?\d*\.?\d+(?:px)?$/i.test(String(token || '')); -} - -/** - * Neutrality test for colors as written in source CSS. - * - * shared/color.mjs's isNeutralColor only parses the computed function forms a - * browser or jsdom emits (rgb/oklch/lab/...) and deliberately reports every - * other spelling as chromatic so an unknown format is never silently skipped. - * That default is wrong for authored CSS, where `#000` and `black` are the - * normal spellings: calling it directly reports a plain black hairline as a - * colored stripe. Handle hex and named neutrals here, then defer. - */ -function isNeutralAuthoredColor(rawColor) { - const c = String(rawColor || '').trim().toLowerCase(); - if (!c) return false; - if (NEUTRAL_COLOR_KEYWORDS.has(c)) return true; - // Modern rgb() takes space-separated channels (`rgb(0 0 0)`). shared/color.mjs - // parses only the comma form a browser's getComputedStyle emits, so authored - // space-separated neutrals fell through it and reported as chromatic — the - // exemption this function exists for, missed. Normalize before delegating. - if (/^rgba?\(/i.test(c)) { - const channels = c.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i); - if (channels) { - const values = [1, 2, 3].map((i) => Number(channels[i])); - return (Math.max(...values) - Math.min(...values)) < 30; - } - return isNeutralColor(c); - } - if (/^(?:hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c); - const channels = hexChannels(c); - if (channels) return (Math.max(...channels) - Math.min(...channels)) < 30; - return false; -} - -function isNeutralBorderColor(str) { - const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i); - if (!m) return false; - return isNeutralAuthoredColor(m[1]); -} - -const REGEX_MATCHERS = [ - // --- Side-tab --- - { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, - test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; }, - fmt: (m) => m[0] }, - { id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi, - test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; }, - fmt: (m) => m[0].replace(/\s*;?\s*$/, '') }, - { id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi, - test: (m, line) => !isSafeElement(line) && +m[1] >= 3, - fmt: (m) => m[0] }, - { id: 'side-tab', regex: /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi, - test: (m, line) => !isSafeElement(line) && +m[1] >= 3, - fmt: (m) => m[0] }, - { id: 'side-tab', regex: /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi, - test: (m, line) => !isSafeElement(line) && +m[1] >= 3, - fmt: (m) => m[0] }, - { id: 'side-tab', regex: /border(?:Left|Right)\s*[:=]\s*["'`](\d+)px\s+solid/g, - test: (m) => +m[1] >= 3, - fmt: (m) => m[0] }, - // --- Border accent on rounded --- - { id: 'border-accent-on-rounded', regex: /\bborder-[tb]-(\d+)\b/g, - test: (m, line) => hasRounded(line) && +m[1] >= 1, - fmt: (m) => m[0] }, - { id: 'border-accent-on-rounded', regex: /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid/gi, - test: (m, line) => +m[1] >= 3 && hasBorderRadius(line), - fmt: (m) => m[0] }, - // --- Overused font --- - { id: 'overused-font', regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica|Fraunces|Geist Sans|Geist Mono|Geist|Mona Sans|Plus Jakarta Sans|Space Grotesk|Recoleta|Instrument Sans|Instrument Serif)\b/gi, - test: () => true, - fmt: (m) => m[0] }, - { id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi, - test: (m) => { - m.overusedGoogleFont = firstOverusedGoogleFont(m[0]); - return Boolean(m.overusedGoogleFont); - }, - fmt: (m) => `Google Fonts: ${m.overusedGoogleFont || firstOverusedGoogleFont(m[0])}` }, - // --- Gradient text --- - { id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi, - test: (m, line) => /gradient/i.test(line), - fmt: () => 'background-clip: text + gradient' }, - // --- Gradient text (Tailwind) --- - { id: 'gradient-text', regex: /\bbg-clip-text\b/g, - test: (m, line) => /\bbg-gradient-to-/i.test(line), - fmt: () => 'bg-clip-text + bg-gradient' }, - // --- Tailwind gray on colored bg --- - { id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, - test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line), - fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } }, - // --- Tailwind AI palette --- - { id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, - test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b| `${m[0]} on heading` }, - { id: 'ai-color-palette', regex: /\bfrom-(?:purple|violet|indigo)-(\d+)\b/g, - test: (m, line) => /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(line), - fmt: (m) => `${m[0]} gradient` }, - // --- Bounce/elastic easing --- - { id: 'bounce-easing', regex: /\banimate-bounce\b/g, - test: () => true, - fmt: () => 'animate-bounce (Tailwind)' }, - { id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi, - test: () => true, - fmt: (m) => { - const token = m[1] - .split(/[,\s]+/) - .find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part)); - return `animation: ${token || m[1].trim()}`; - } }, - { id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g, - test: (m) => { - const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]); - return y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1; - }, - fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` }, - // --- Layout property transition --- - // JSX inline style objects use comma-delimited quoted values, not semicolons (issue #548). - { id: 'layout-transition', regex: /transition\s*:\s*(?:(['"])((?:(?!\1)[^\\]|\\.)*)\1|([^;{}]+))/gi, - test: (m) => { - const val = (m[2] ?? m[3] ?? '').toLowerCase(); - if (/\ball\b/.test(val)) return false; - return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val); - }, - fmt: (m) => { - const raw = m[2] ?? m[3] ?? ''; - const found = raw.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); - return `transition: ${found ? found.join(', ') : raw.trim()}`; - } }, - { id: 'layout-transition', regex: /transition-property\s*:\s*(?:(['"])((?:(?!\1)[^\\]|\\.)*)\1|([^;{}]+))/gi, - test: (m) => { - const val = (m[2] ?? m[3] ?? '').toLowerCase(); - if (/\ball\b/.test(val)) return false; - return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val); - }, - fmt: (m) => { - const raw = m[2] ?? m[3] ?? ''; - const found = raw.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); - return `transition-property: ${found ? found.join(', ') : raw.trim()}`; - } }, - // --- Broken image: src="" or src="#" or src=" " --- - { id: 'broken-image', regex: /]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi, - test: () => true, - fmt: (m) => m[0].slice(0, 100) }, - // --- Broken image: with no src attribute at all --- - { id: 'broken-image', regex: /])*>/gi, - test: (m) => !/\bsrc\s*=/i.test(m[0]), - fmt: (m) => m[0].slice(0, 100) }, -]; - -const REGEX_ANALYZERS = [ - // Flat type hierarchy - (content, filePath) => { - const sizes = new Set(); - const REM = 16; - let m; - const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; - while ((m = sizeRe.exec(content)) !== null) { - const px = m[2] === 'px' ? +m[1] : +m[1] * REM; - if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); - } - const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; - while ((m = clampRe.exec(content)) !== null) { - sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10); - sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10); - } - const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 }; - for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); } - if (sizes.size < 3) return []; - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - if (ratio >= 2.0) return []; - const lines = content.split('\n'); - let line = 1; - for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } } - return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)]; - }, - // Monotonous spacing (regex) - (content, filePath) => { - const vals = []; - let m; - const pxRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi; - while ((m = pxRe.exec(content)) !== null) { const v = +m[1]; if (v > 0 && v < 200) vals.push(v); } - const remRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi; - while ((m = remRe.exec(content)) !== null) { const v = Math.round(parseFloat(m[1]) * 16); if (v > 0 && v < 200) vals.push(v); } - const gapRe = /gap\s*:\s*(\d+)px/gi; - while ((m = gapRe.exec(content)) !== null) vals.push(+m[1]); - const twRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g; - while ((m = twRe.exec(content)) !== null) vals.push(+m[1] * 4); - const rounded = vals.map(v => Math.round(v / 4) * 4); - if (rounded.length < 10) return []; - const counts = {}; - for (const v of rounded) counts[v] = (counts[v] || 0) + 1; - const maxCount = Math.max(...Object.values(counts)); - const pct = maxCount / rounded.length; - const unique = [...new Set(rounded)].filter(v => v > 0); - if (pct <= 0.6 || unique.length > 3) return []; - const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]; - return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)]; - }, - // Em-dash overuse (ADVISORY): the AI cadence tell is em-dash *saturation*, - // not the occasional dash. Humans use em-dashes legitimately, so this rule is - // advisory (surfaced separately, never a failure, hook-skipped by default) and - // its threshold is deliberately conservative. Two gates must both hold: - // 1. Absolute floor of EM_DASH_FLOOR (8) dashes — a page with a handful - // never fires, no matter how short. - // 2. Density: at least one dash per EM_DASH_CHARS_PER_DASH (500) characters - // of body text, so a long article that uses eight across several thousand - // words is left alone while a short, dash-per-clause landing page is not. - // Raised from the old flat 5-dash floor, which fired on ordinary long prose. - // - // stripHtmlToText drops tags but leaves character-entity escapes intact, so - // a model that writes `—`, `—`, or `—` renders an em-dash - // the counter never saw. Decode the em-dash entities (named, zero-padded - // decimal, upper/lower hex) to the literal glyph first. En-dash entities are - // deliberately left alone: the rule counts em-dashes, and the literal `–` - // was never counted either. - (content, filePath) => { - const text = stripHtmlToText(content) - .replace(/—|�*8212;|�*2014;/gi, '—'); - let count = 0; - const re = /[—]|--(?=\S)/g; - while (re.exec(text) !== null) count++; - if (count < EM_DASH_FLOOR) return []; - // Saturation gate: dashes must be dense in the prose, not sprinkled through - // a long document. textLength <= count * chars-per-dash means the density is - // at or above the threshold. - if (text.length > count * EM_DASH_CHARS_PER_DASH) return []; - return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)]; - }, - // Marketing buzzwords: SaaS phrase list - (content, filePath) => { - const text = stripHtmlToText(content); - const lower = text.toLowerCase(); - const BUZZWORDS = [ - 'streamline your', 'empower your', 'supercharge your', - 'unleash your', 'unleash the power', 'leverage the power', - 'built for the modern', 'trusted by leading', 'trusted by the world', - 'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade', - 'next-generation', 'cutting-edge', 'transform your business', - 'revolutionize', 'game-changer', 'game changing', - 'mission-critical', 'best of breed', 'future-proof', 'future proof', - 'seamless experience', 'seamlessly integrate', - 'drive engagement', 'drive growth', 'drive results', - 'harness the power', - ]; - let count = 0; - let firstSample = ''; - for (const phrase of BUZZWORDS) { - let from = 0; - while (true) { - const idx = lower.indexOf(phrase, from); - if (idx === -1) break; - count++; - if (!firstSample) { - firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim(); - } - from = idx + phrase.length; - } - } - if (count === 0) return []; - return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)]; - }, - // Aphoristic cadence: manufactured-contrast + short-rebuttal - (content, filePath) => { - const text = stripHtmlToText(content); - const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g; - const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g; - let count = 0; - let firstSample = ''; - let m; - NOT_A_RE.lastIndex = 0; - while ((m = NOT_A_RE.exec(text)) !== null) { - count++; - if (!firstSample) firstSample = m[0].trim().slice(0, 80); - } - SHORT_REBUTTAL_RE.lastIndex = 0; - while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) { - count++; - if (!firstSample) firstSample = m[0].trim().slice(0, 80); - } - if (count < 3) return []; - return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)]; - }, - // Dark glow / chromatic halo shadows (page-level). Shared scanner handles - // any color format, single-level var() resolution, zero-offset halos on - // any background, and text-shadow glows. - (content, filePath) => { - const hits = scanCssTextForGlow(content); - if (hits.length === 0) return []; - const lines = content.substring(0, hits[0].index).split('\n'); - return [finding('dark-glow', filePath, hits[0].snippet, lines.length)]; - }, - // Radial-gradient background halo on a dark page (the gradient sibling - // of the dark-glow shadow tell). - (content, filePath) => { - const hits = scanCssTextForRadialHalo(content); - if (hits.length === 0) return []; - const lines = content.substring(0, hits[0].index).split('\n'); - return [finding('radial-halo', filePath, hits[0].snippet, lines.length)]; - }, - // Auto-scrolling marquees ( or infinite horizontal loop - // animations). - (content, filePath) => scanCssTextForMarquee(content).map(hit => finding('marquee', filePath, hit.snippet)), -]; - -// --------------------------------------------------------------------------- -// Structural CSS checks used by source files whose styles are not parsed by -// the static HTML engine. -// --------------------------------------------------------------------------- - -const CHROMATIC_SHADOW_TOKEN_RE = /(?:^|-)(?:accent|kinpaku|patina|gold|red|orange|amber|yellow|lime|green|emerald|teal|cyan|blue|indigo|violet|purple|magenta|pink|rose|coral|aqua|mint|burgundy|crimson|scarlet)(?:-|$)/i; - -function insetStripeColorIsChromatic(rawColor) { - const color = String(rawColor || '').trim().replace(/\s*!important\s*$/i, ''); - if (/^(?:currentcolor|transparent|inherit|unset)$/i.test(color)) return false; - const variable = color.match(/^var\(\s*(--[\w-]+)/i); - if (variable) return CHROMATIC_SHADOW_TOKEN_RE.test(variable[1]); - if (!/^(?:#|rgba?\(|hsla?\(|hwb\(|oklch\(|oklab\(|lch\(|lab\(|color\(|[a-z]+$)/i.test(color)) return false; - return !isNeutralAuthoredColor(color); -} - -/** - * Blank out comment bodies while preserving every byte offset (and therefore - * every line number) so commented-out CSS is not scanned as live rules. - */ -function blankCssComments(css) { - return css.replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, ' ')); -} - -function scanInsetStripeCss(rawContent, filePath, lineOffset = 0) { - const content = blankCssComments(rawContent); - const findings = []; - const ruleRe = /([^{};]+)\{([^{}]*)\}/g; - let match; - // Deriving each line with content.slice(0, offset).split('\n') re-scans the - // whole prefix per rule, which is O(n^2) on a large stylesheet. Rule matches - // arrive in source order, so carry a monotonic cursor instead: one pass total. - let scanOffset = 0; - let scanLine = 1; - const lineAtOffset = (offset) => { - while (scanOffset < offset) { - if (content[scanOffset] === '\n') scanLine++; - scanOffset++; - } - return scanLine; - }; - while ((match = ruleRe.exec(content)) !== null) { - // The selector group is `[^{};]+`, which greedily absorbs the whitespace and - // newlines trailing the previous rule. Advance past that run before deriving - // the line, or every rule after the first reports the preceding line. - const selectorStart = match.index + (match[1].length - match[1].trimStart().length); - const selector = match[1].trim().replace(/\s+/g, ' '); - if (!selector) continue; - if (/:(?:hover|focus|focus-visible|focus-within|active|checked|target)\b/i.test(selector)) continue; - if (/\[aria-selected\s*[*^$|~]?=\s*["']?true/i.test(selector)) continue; - if (/\[aria-current(?!\s*[*^$|~]?=\s*["']?false)/i.test(selector)) continue; - if (/(?:^|[\s._[-])(?:active|current|selected)(?![\w])/i.test(selector)) continue; - if (/(?:^|[\s>+~,(])(?:button|hr|tr|td|th|table|blockquote|pre|code)(?![\w-])/i.test(selector)) continue; - - // Read the last of a repeated declaration, not the first: that is what the - // cascade paints. Taking the first both flagged stripes that a later - // `box-shadow: none` had cancelled and missed stripes that overrode an - // earlier value, and mis-skipped rules whose narrow width was overridden. - const width = lastMatch(match[2], /(?:^|;)\s*(?:width|inline-size)\s*:\s*(\d+(?:\.\d+)?)px/gi); - if (width && Number(width[1]) <= 40) continue; - const declaration = lastMatch(match[2], /(?:^|;)\s*box-shadow\s*:\s*([^;]+)/gi); - if (!declaration || !/\binset\b/i.test(declaration[1])) continue; - // `!important` qualifies the declaration, not the shadow value, so strip it - // before the layers are read. Tokenizing split it into its own token, which - // made the color count wrong and silently stopped flagging stripes declared - // with it — a shape the previous regex handled. - const shadowValue = declaration[1].replace(/\s*!\s*important\s*$/i, '').trim(); - - for (const rawLayer of shadowValue.split(/,(?![^(]*\))/)) { - const layer = rawLayer.trim(); - // Parse the layer by its grammar rather than by one spelling of it. - // A box-shadow layer is `inset? && {2,4} && ?` in any - // order, so `inset 4px 0 red`, `4px 0 0 red inset`, and `red 4px 0 inset` - // all paint the same stripe. Matching a fixed token order missed three - // valid spellings in a row; enumerate the tokens instead. Tokenizing must - // respect parens: `rgb(0 0 0)` is one color token, and splitting it on - // whitespace would read its channels as lengths. - const tokens = tokenizeShadowLayer(layer); - if (!tokens.some((token) => /^inset$/i.test(token))) continue; - const rest = tokens.filter((token) => !/^inset$/i.test(token)); - const lengths = rest.filter(isShadowLength); - const colors = rest.filter((token) => !isShadowLength(token)); - // Only the two offsets are required; omitted blur/spread default to 0, - // which is exactly the stripe shape. More than one non-length token is a - // layer shape we do not claim to understand, so leave it alone. - if (lengths.length < 2 || lengths.length > 4 || colors.length !== 1) continue; - const values = lengths.map((token) => ({ - n: Number(token.replace(/px$/i, '')), - hasPx: /px$/i.test(token), - })); - const x = values[0]; - const y = values[1]; - const blur = values[2] ? values[2].n : 0; - const spread = values[3] ? values[3].n : 0; - if ((x.n !== 0 && !x.hasPx) || (y.n !== 0 && !y.hasPx) || blur !== 0 || spread !== 0) continue; - const ax = Math.abs(x.n); - const ay = Math.abs(y.n); - if (!((ax >= 3 && ax <= 12 && ay === 0) || (ay >= 3 && ay <= 12 && ax === 0))) continue; - if (!insetStripeColorIsChromatic(colors[0])) continue; - const edge = ay === 0 ? (x.n > 0 ? 'left' : 'right') : (y.n > 0 ? 'top' : 'bottom'); - const line = lineOffset + lineAtOffset(selectorStart); - findings.push(finding('side-tab', filePath, `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`, line)); - break; - } - } - return findings; -} - -// --------------------------------------------------------------------------- -// Style block extraction (Astro/Vue/Svelte