mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da18929df0 | ||
|
|
a110ec5ed7 | ||
|
|
8eedb150c5 | ||
|
|
55d11fb2ad | ||
|
|
776c019041 | ||
|
|
68a15b6be4 | ||
|
|
42be79eab5 | ||
|
|
1e4e74793a | ||
|
|
0306b41949 | ||
|
|
f1e9b3df3a | ||
|
|
2f9dc05978 | ||
|
|
221064858e | ||
|
|
41ff946121 | ||
|
|
793feda5a0 | ||
|
|
c0d50e36da | ||
|
|
67e8757401 | ||
|
|
1fd1eb11bc | ||
|
|
a42d4a7060 | ||
|
|
a1560fb0f5 | ||
|
|
d5403f9d65 | ||
|
|
1f4021b16c | ||
|
|
046a8593f5 | ||
|
|
e371c99f08 | ||
|
|
d949abd180 | ||
|
|
07667ed08f | ||
|
|
1c897a09c8 | ||
|
|
617b3a6e5e | ||
|
|
c7539c867d | ||
|
|
4f50db2bca | ||
|
|
f726894373 | ||
|
|
99a284a0d9 | ||
|
|
b86f2cc353 | ||
|
|
8b0c895703 | ||
|
|
1268f10b76 | ||
|
|
795e8ed5e5 |
@@ -1,6 +1,7 @@
|
||||
---
|
||||
name: impeccable
|
||||
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
|
||||
version: 3.8.0
|
||||
---
|
||||
|
||||
Designs and iterates production-grade frontend interfaces. Real working code, committed design choices, exceptional craft.
|
||||
@@ -9,7 +10,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
|
||||
|
||||
You MUST do these steps before proceeding:
|
||||
|
||||
1. Run `node .agents/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
1. Run `node .agents/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .agents/skills/impeccable/scripts/context.mjs --target <path>` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code and Codex use `PostToolUse` and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project).
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks$impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks$impeccable.json` is committed to the repository's default branch.
|
||||
|
||||
On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands.
|
||||
|
||||
@@ -51,7 +51,7 @@ Prefer the narrowest exception:
|
||||
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
|
||||
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
|
||||
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
|
||||
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
|
||||
- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable <rule>` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it.
|
||||
|
||||
Example value-specific exception:
|
||||
|
||||
@@ -81,8 +81,8 @@ node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
|
||||
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
|
||||
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code and Codex do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks$impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks$impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Codex: run live helper commands, the app dev server, and any dependency-installi
|
||||
|
||||
Execute in order. No step skipped, no step reordered.
|
||||
|
||||
1. `live.mjs`: boot.
|
||||
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .agents/skills/impeccable/scripts/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
|
||||
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* init flow.
|
||||
*
|
||||
* Path resolution (first match wins):
|
||||
* 1. cwd, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. .agents/context/ then docs/
|
||||
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. Active project .agents/context/ then docs/
|
||||
* 3. Monorepo root context, using the same order, as a per-file fallback
|
||||
* 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* escape hatch, only consulted when defaults are empty
|
||||
* 4. cwd as a "nothing found" default
|
||||
* 5. Active project root as a "nothing found" default
|
||||
*
|
||||
* `resolveContextDir()` and `loadContext()` are also exported for the
|
||||
* server-side scripts (live.mjs, live-server.mjs) that need the structured
|
||||
@@ -19,10 +20,25 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.svelte-kit',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'coverage',
|
||||
]);
|
||||
|
||||
// ─── Update check ──────────────────────────────────────────────────────────
|
||||
// Piggyback a lightweight skill-version check on the once-per-session boot.
|
||||
@@ -38,41 +54,623 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o
|
||||
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
|
||||
const FETCH_TIMEOUT_MS = 1200;
|
||||
|
||||
export function resolveContextDir(cwd = process.cwd()) {
|
||||
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return cwd;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(cwd, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (envDir && envDir.trim()) {
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
return cwd;
|
||||
export function resolveContextDir(cwd = process.cwd(), options = {}) {
|
||||
return resolveContext(cwd, options).contextDir;
|
||||
}
|
||||
|
||||
export function loadContext(cwd = process.cwd()) {
|
||||
const contextDir = resolveContextDir(cwd);
|
||||
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
|
||||
const designPath = firstExisting(contextDir, DESIGN_NAMES);
|
||||
export function loadContext(cwd = process.cwd(), options = {}) {
|
||||
const resolved = resolveContext(cwd, options);
|
||||
const absCwd = path.resolve(cwd);
|
||||
const productPath = resolved.productPath;
|
||||
const designPath = resolved.designPath;
|
||||
const product = productPath ? safeRead(productPath) : null;
|
||||
const design = designPath ? safeRead(designPath) : null;
|
||||
return {
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
productPath: productPath ? path.relative(cwd, productPath) : null,
|
||||
productPath: productPath ? path.relative(absCwd, productPath) : null,
|
||||
hasDesign: !!design,
|
||||
design,
|
||||
designPath: designPath ? path.relative(cwd, designPath) : null,
|
||||
contextDir,
|
||||
designPath: designPath ? path.relative(absCwd, designPath) : null,
|
||||
contextDir: resolved.contextDir,
|
||||
productContextDir: productPath ? path.dirname(productPath) : null,
|
||||
designContextDir: designPath ? path.dirname(designPath) : null,
|
||||
projectRoot: resolved.projectRoot,
|
||||
repoRoot: resolved.repoRoot,
|
||||
isMonorepo: resolved.isMonorepo,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveContext(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const project = resolveProject(absCwd, options);
|
||||
const projectContextDir = resolveLocalContextDir(project.projectRoot);
|
||||
const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
|
||||
? resolveLocalContextDir(project.repoRoot)
|
||||
: null;
|
||||
|
||||
let productPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
|
||||
let designPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
|
||||
|
||||
let envContextDir = null;
|
||||
if (!productPath && !designPath) {
|
||||
envContextDir = resolveEnvContextDir(absCwd);
|
||||
if (envContextDir) {
|
||||
productPath = firstExisting(envContextDir, PRODUCT_NAMES);
|
||||
designPath = firstExisting(envContextDir, DESIGN_NAMES);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contextDir: productPath
|
||||
? path.dirname(productPath)
|
||||
: designPath
|
||||
? path.dirname(designPath)
|
||||
: envContextDir || project.projectRoot,
|
||||
productPath,
|
||||
designPath,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
isMonorepo: project.isMonorepo,
|
||||
targetDir: project.targetDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
|
||||
return resolveProject(cwd, options).projectRoot;
|
||||
}
|
||||
|
||||
export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
|
||||
if (hasTargetOption(options)) return null;
|
||||
const project = resolveProject(cwd);
|
||||
if (
|
||||
!project.isMonorepo
|
||||
|| !project.projectRoot
|
||||
|| !project.repoRoot
|
||||
|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const targetCandidates = discoverTargetCandidates(project.repoRoot);
|
||||
// No discoverable child apps (e.g. `workspaces: ["."]`, a root-only workspace,
|
||||
// or a marker file with no apps/packages children): there is nothing to choose,
|
||||
// so treat the repo root as the active project rather than blocking on an empty
|
||||
// selection prompt that the user cannot answer.
|
||||
if (targetCandidates.length === 0) return null;
|
||||
return {
|
||||
targetPath: null,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
targetCandidates,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProject(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const targetDir = resolveTargetDir(absCwd, options);
|
||||
let repoRoot = findMonorepoRoot(targetDir);
|
||||
if (!repoRoot && targetDir !== absCwd) {
|
||||
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
||||
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
||||
repoRoot = cwdRepoRoot;
|
||||
}
|
||||
}
|
||||
if (!repoRoot) {
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: absCwd,
|
||||
repoRoot: absCwd,
|
||||
isMonorepo: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
|
||||
repoRoot,
|
||||
isMonorepo: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isPathInside(candidate, root) {
|
||||
const rel = path.relative(root, candidate);
|
||||
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function resolveLocalContextDir(root) {
|
||||
if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return root;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(root, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEnvContextDir(cwd) {
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (!envDir || !envDir.trim()) return null;
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
function findMonorepoRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === homeDir) return null;
|
||||
// isMonorepoRoot is checked before hasGitBoundary on purpose: a workspace
|
||||
// root that also carries its own .git is still recognized. The trade-off is
|
||||
// deliberate — a directory with a monorepo *marker* but no workspace patterns
|
||||
// and no apps/packages children is not a monorepo root, so its .git stops
|
||||
// traversal and a further-up root is not searched. The nested .git is treated
|
||||
// as an independent project boundary, which is the intended isolation.
|
||||
if (isMonorepoRoot(dir)) return dir;
|
||||
if (hasGitBoundary(dir)) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return hasFallbackWorkspaceChildren(dir);
|
||||
}
|
||||
|
||||
function hasGitBoundary(dir) {
|
||||
return fs.existsSync(path.join(dir, '.git'));
|
||||
}
|
||||
|
||||
function hasFallbackWorkspaceChildren(dir) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(dir, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function discoverTargetCandidates(repoRoot) {
|
||||
const roots = new Map();
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
for (const pattern of patterns) {
|
||||
for (const root of discoverRootsForPattern(repoRoot, pattern)) {
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(repoRoot, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const root = path.join(base, entry.name);
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots.entries()]
|
||||
.filter(([rel]) => rel && !rel.startsWith('..'))
|
||||
// Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot
|
||||
// sends an excluded package back to the repo root, so an excluded folder must not appear as a
|
||||
// selectable target — choosing it would silently resolve to the root instead.
|
||||
.filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([rel, root]) => {
|
||||
const targetExample = findTargetExample(repoRoot, root);
|
||||
return {
|
||||
name: path.basename(root),
|
||||
path: rel,
|
||||
targetExample,
|
||||
...resolveCandidateContextSummary(repoRoot, root, targetExample),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
|
||||
const ctx = resolveContext(repoRoot, { targetPath });
|
||||
return {
|
||||
productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
|
||||
productPath: contextSourcePath(ctx.productPath, repoRoot),
|
||||
designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
|
||||
designPath: contextSourcePath(ctx.designPath, repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
// Selection candidates surface one of four statuses: 'child' (a canonical
|
||||
// PRODUCT.md/DESIGN.md directly in the app root), 'inherited' (resolved from the
|
||||
// repo root in a monorepo), 'missing' (no file found), and 'fallback'. 'fallback'
|
||||
// intentionally covers two non-canonical locations: a file inside the project
|
||||
// root but in a subdirectory (FALLBACK_DIRS, e.g. `.agents/context/`), and a file
|
||||
// outside both the project and repo roots (IMPECCABLE_CONTEXT_DIR override).
|
||||
function contextSourceStatus(filePath, repoRoot, projectRoot) {
|
||||
if (!filePath) return 'missing';
|
||||
const absPath = path.resolve(filePath);
|
||||
const absProjectRoot = path.resolve(projectRoot);
|
||||
const absRepoRoot = path.resolve(repoRoot);
|
||||
if (isPathInsideOrEqual(absPath, absProjectRoot)) {
|
||||
return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
|
||||
}
|
||||
if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
|
||||
return 'inherited';
|
||||
}
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
function contextSourcePath(filePath, repoRoot) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(repoRoot, filePath);
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
return rel.split(path.sep).join('/');
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function discoverRootsForPattern(repoRoot, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return [];
|
||||
const segments = pattern.split('/').filter(Boolean);
|
||||
if (!segments.length) return [];
|
||||
const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
|
||||
const base = path.join(repoRoot, ...literalPrefix);
|
||||
if (!fs.existsSync(base)) return [];
|
||||
if (segments.includes('**')) {
|
||||
const packageRoots = [];
|
||||
walkDirs(base, (dir) => {
|
||||
if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
|
||||
});
|
||||
if (packageRoots.length) return packageRoots;
|
||||
return directChildDirs(base);
|
||||
}
|
||||
return expandSimplePattern(repoRoot, segments);
|
||||
}
|
||||
|
||||
function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
|
||||
if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
|
||||
const segment = patternSegments[index];
|
||||
if (!segment.includes('*')) {
|
||||
return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const roots = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
if (!segmentMatches(segment, entry.name)) continue;
|
||||
roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function directChildDirs(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function walkDirs(root, visit) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const dir = path.join(root, entry.name);
|
||||
visit(dir);
|
||||
walkDirs(dir, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function isCandidateProjectRoot(dir) {
|
||||
return !!(
|
||||
fs.existsSync(path.join(dir, 'package.json'))
|
||||
|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'src'))
|
||||
|| fs.existsSync(path.join(dir, 'app'))
|
||||
|| fs.existsSync(path.join(dir, 'pages'))
|
||||
|| fs.existsSync(path.join(dir, 'public'))
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnoredWorkspaceDiscoveryDir(name) {
|
||||
return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
|
||||
}
|
||||
|
||||
function findTargetExample(repoRoot, projectRoot) {
|
||||
const examples = [
|
||||
'src/App.jsx',
|
||||
'src/App.tsx',
|
||||
'src/main.jsx',
|
||||
'src/main.tsx',
|
||||
'src/index.jsx',
|
||||
'src/index.ts',
|
||||
'app/page.tsx',
|
||||
'pages/index.tsx',
|
||||
'public/index.html',
|
||||
];
|
||||
for (const rel of examples) {
|
||||
const abs = path.join(projectRoot, rel);
|
||||
if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
|
||||
}
|
||||
return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
|
||||
const rel = path.relative(repoRoot, targetDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
|
||||
if (!excluded) {
|
||||
for (const pattern of patterns) {
|
||||
const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
|
||||
if (projectRoot) return projectRoot;
|
||||
}
|
||||
}
|
||||
if (excluded) return repoRoot;
|
||||
if (
|
||||
relSegments.length >= 2
|
||||
&& MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
|
||||
) {
|
||||
return path.join(repoRoot, relSegments[0], relSegments[1]);
|
||||
}
|
||||
const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
|
||||
if (nearest) return nearest;
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function isExcludedByWorkspacePattern(relSegments, patterns) {
|
||||
return patterns.some((rawPattern) => {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern.startsWith('!')) return false;
|
||||
return workspacePatternMatchesRel(pattern.slice(1), relSegments);
|
||||
});
|
||||
}
|
||||
|
||||
function nearestProjectLikeRoot(repoRoot, targetDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop) {
|
||||
if (
|
||||
firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'package.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(stopDir || repoRoot);
|
||||
const root = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInsideOrEqual(candidate, root) {
|
||||
return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
|
||||
}
|
||||
|
||||
function workspacePatternMatchesRel(pattern, relSegments) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return false;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(repoRoot) {
|
||||
return [
|
||||
...readPackageWorkspaces(repoRoot),
|
||||
...readPnpmWorkspaces(repoRoot),
|
||||
...readLernaWorkspaces(repoRoot),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function readPackageWorkspaces(repoRoot) {
|
||||
const pkg = readJson(path.join(repoRoot, 'package.json'));
|
||||
const workspaces = pkg?.workspaces;
|
||||
if (Array.isArray(workspaces)) return workspaces;
|
||||
if (Array.isArray(workspaces?.packages)) return workspaces.packages;
|
||||
return [];
|
||||
}
|
||||
|
||||
function readLernaWorkspaces(repoRoot) {
|
||||
const lerna = readJson(path.join(repoRoot, 'lerna.json'));
|
||||
return Array.isArray(lerna?.packages) ? lerna.packages : [];
|
||||
}
|
||||
|
||||
function readPnpmWorkspaces(repoRoot) {
|
||||
try {
|
||||
const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
|
||||
const patterns = [];
|
||||
let inPackages = false;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const trimmed = stripYamlInlineComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flowMatch) {
|
||||
patterns.push(...parseYamlFlowList(flowMatch[1]));
|
||||
inPackages = false;
|
||||
continue;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
if (inPackages) {
|
||||
const match = trimmed.match(/^-\s*(.+)$/);
|
||||
if (match) patterns.push(unquoteYamlValue(match[1]));
|
||||
}
|
||||
}
|
||||
return patterns;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stripYamlInlineComment(line) {
|
||||
let quote = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#' && !quote) return line.slice(0, i);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseYamlFlowList(body) {
|
||||
const items = [];
|
||||
let quote = null;
|
||||
let current = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',' && !quote) {
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
return items;
|
||||
}
|
||||
|
||||
function unquoteYamlValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return null;
|
||||
const patternSegments = pattern.split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return null;
|
||||
if (patternSegments.includes('**')) {
|
||||
return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return null;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
|
||||
}
|
||||
return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
|
||||
}
|
||||
|
||||
function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return null;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
|
||||
}
|
||||
const prefixDir = path.join(repoRoot, ...literalPrefix);
|
||||
const targetDir = path.join(repoRoot, ...relSegments);
|
||||
const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
|
||||
if (packageRoot) return packageRoot;
|
||||
return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
|
||||
}
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
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 firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
@@ -89,6 +687,10 @@ function safeRead(p) {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
|
||||
* for a `## Register` section and reading the first non-empty line that
|
||||
@@ -233,7 +835,24 @@ async function computeUpdateDirective(now = Date.now()) {
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
let cliOptions;
|
||||
try {
|
||||
cliOptions = parseCliOptions(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -244,6 +863,10 @@ async function cli() {
|
||||
'Stop the current task, load reference/init.md, and follow its ' +
|
||||
'instructions to write PRODUCT.md before resuming.',
|
||||
];
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
@@ -252,6 +875,10 @@ async function cli() {
|
||||
if (ctx.hasDesign) {
|
||||
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
|
||||
}
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
const register = extractRegister(ctx.product);
|
||||
const next = register
|
||||
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
|
||||
@@ -261,6 +888,60 @@ async function cli() {
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
return parseTargetOptions(args, { strict: true });
|
||||
}
|
||||
|
||||
function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
|
||||
const targetPath = hasTargetOption(options) ? options.targetPath : null;
|
||||
return `RESOLVED_CONTEXT:\n${JSON.stringify({
|
||||
targetPath,
|
||||
...(targetPath ? { targetExists } : {}),
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2)}`;
|
||||
}
|
||||
|
||||
function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
|
||||
if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
|
||||
return !!(
|
||||
ctx.isMonorepo
|
||||
&& (!targetProvided || targetExists === false)
|
||||
&& ctx.projectRoot
|
||||
&& ctx.repoRoot
|
||||
&& path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
|
||||
);
|
||||
}
|
||||
|
||||
function buildMissingTargetDirective() {
|
||||
const script = process.argv[1] || 'context.mjs';
|
||||
return (
|
||||
'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
|
||||
'If the user named a file, route, or child app, do not answer from this output. ' +
|
||||
`Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
|
||||
);
|
||||
}
|
||||
|
||||
function buildTargetSelectionDirective(selection) {
|
||||
return (
|
||||
`TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
|
||||
'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' +
|
||||
'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
|
||||
'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
|
||||
);
|
||||
}
|
||||
|
||||
// Run cli() only when this module is the entry point. Compare realpaths
|
||||
// rather than endsWith(): a loose suffix match also fires for unrelated
|
||||
// scripts like `load-context.mjs`, and realpath tolerates symlinked
|
||||
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
// Output formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatFindingSummary(count) {
|
||||
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
|
||||
}
|
||||
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
|
||||
@@ -39,7 +43,7 @@ function formatFindings(findings, jsonMode) {
|
||||
out.push(` → ${item.description}`);
|
||||
}
|
||||
}
|
||||
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
|
||||
out.push(`\n${formatFindingSummary(findings.length)}`);
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
@@ -86,9 +90,12 @@ 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
|
||||
--gpt Also report GPT-specific provider tells (off by default)
|
||||
--gemini Also report Gemini-specific provider tells (off by default)
|
||||
--no-config Do not apply project config, detector ignores, or DESIGN.md
|
||||
--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
|
||||
--help Show this help message
|
||||
|
||||
@@ -97,6 +104,14 @@ Project config:
|
||||
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:
|
||||
<!-- impeccable-disable overused-font -- exported brand doc -->
|
||||
.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.)
|
||||
@@ -118,6 +133,7 @@ async function detectCli() {
|
||||
});
|
||||
if (args[0] === 'detect') args = args.slice(1);
|
||||
const jsonMode = args.includes('--json');
|
||||
const quietMode = args.includes('--quiet');
|
||||
const helpMode = args.includes('--help');
|
||||
// --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
|
||||
@@ -137,7 +153,12 @@ async function detectCli() {
|
||||
if (args.includes('--gemini')) providers.push('gemini');
|
||||
const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false;
|
||||
const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null;
|
||||
const scanOptions = designSystem ? { providers, designSystem } : { providers };
|
||||
// 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 scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled };
|
||||
if (designSystem) scanOptions.designSystem = designSystem;
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
@@ -169,8 +190,8 @@ async function detectCli() {
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
|
||||
if (!jsonMode) {
|
||||
// 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);
|
||||
@@ -200,7 +221,7 @@ async function detectCli() {
|
||||
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) {
|
||||
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` +
|
||||
@@ -258,6 +279,7 @@ async function detectCli() {
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { GENERIC_FONTS } from '../../shared/constants.mjs';
|
||||
import { isNeutralColor } from '../../shared/color.mjs';
|
||||
import { checkSourceDesignSystem } from '../../design-system.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { filterByProviders } from '../../registry/antipatterns.mjs';
|
||||
import { profileFindings, profileStep } from '../../profile/profiler.mjs';
|
||||
@@ -37,10 +39,11 @@ function shouldRunPageAnalyzers(content, filePath) {
|
||||
}
|
||||
|
||||
function isNeutralBorderColor(str) {
|
||||
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
|
||||
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;
|
||||
const c = m[1].toLowerCase();
|
||||
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
|
||||
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
|
||||
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
|
||||
if (hex) {
|
||||
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
|
||||
@@ -57,10 +60,10 @@ function isNeutralBorderColor(str) {
|
||||
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 >= 1 : n >= 4; },
|
||||
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 >= 1 : n >= 3; },
|
||||
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,
|
||||
@@ -547,7 +550,10 @@ function detectText(content, filePath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return filterByProviders(deduped, options?.providers);
|
||||
const byProvider = filterByProviders(deduped, options?.providers);
|
||||
// Inline `impeccable-disable*` waivers travel with the file; honor them unless
|
||||
// explicitly bypassed (`--no-config` / `--no-inline-ignores`).
|
||||
return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content);
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
mergeDesignSystemFindings,
|
||||
} from '../../design-system.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
||||
import {
|
||||
@@ -223,7 +224,11 @@ async function detectHtml(filePath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return filterByProviders(findings, options.providers);
|
||||
const byProvider = filterByProviders(findings, options.providers);
|
||||
// Static-HTML findings carry no line number, so only whole-file
|
||||
// `impeccable-disable` directives apply here — exactly the standalone-document
|
||||
// waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`.
|
||||
return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html);
|
||||
}
|
||||
|
||||
export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml };
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Inline, in-file ignore directives — eslint-disable-style waivers that live at
|
||||
* the point they apply and travel with the artifact instead of (or alongside)
|
||||
* an ignore in `.impeccable/config.json`.
|
||||
*
|
||||
* A config ignore is the right default for repo-wide policy. This complements it
|
||||
* for the one case config can't cover: a waiver that belongs to a single file and
|
||||
* needs to follow that file when it leaves the repo — a generated/exported
|
||||
* standalone document, an emailed HTML file, a snippet scanned out of context.
|
||||
*
|
||||
* Comment-syntax-agnostic: the directive is a raw token matched anywhere on a
|
||||
* line, so the same marker works across every comment style impeccable scans —
|
||||
* `//`, `/* *\/`, `<!-- -->`, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers
|
||||
* are stripped before the rule list is parsed.
|
||||
*
|
||||
* Syntax (reason optional; eslint `--` or biome `:` separator):
|
||||
*
|
||||
* impeccable-disable <rule>[, <rule>...] [-- reason] whole file
|
||||
* impeccable-disable-line <rule>... [-- reason] the same line
|
||||
* impeccable-disable-next-line <rule>... [-- reason] the following line
|
||||
* impeccable-disable bare / `*` = every rule
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* <!-- impeccable-disable overused-font -- exported brand doc, font is first-party -->
|
||||
* .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/
|
||||
* // impeccable-disable-next-line bounce-easing: intentional playful affordance
|
||||
*
|
||||
* Behavior is suppression, for parity with config ignores: a matched directive
|
||||
* drops the finding. The inline reason is self-documenting in the diff; it is not
|
||||
* required and is discarded at scan time (only used here to keep reason words out
|
||||
* of the parsed rule list).
|
||||
*/
|
||||
|
||||
const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi;
|
||||
|
||||
// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't
|
||||
// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the
|
||||
// space before the closer. `--+>` covers `-->` and any longer dash run.
|
||||
const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/;
|
||||
|
||||
function normalizeRule(token) {
|
||||
return String(token || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
// Split the directive remainder into rule tokens, dropping any human reason that
|
||||
// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever
|
||||
// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:`
|
||||
// are unambiguous separators.
|
||||
function parseRuleList(remainder) {
|
||||
let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim();
|
||||
// Cut off a human reason at the first `--` (eslint) or `:` (biome) separator.
|
||||
const reasonSep = text.match(/\s*(?:--+|:)\s*/);
|
||||
if (reasonSep) text = text.slice(0, reasonSep.index);
|
||||
const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean);
|
||||
if (tokens.length === 0 || tokens.includes('*')) return ['*'];
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function addRules(set, rules) {
|
||||
for (const rule of rules) set.add(rule);
|
||||
}
|
||||
|
||||
function getSet(map, key) {
|
||||
let set = map.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
map.set(key, set);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse every inline ignore directive in a file's raw text.
|
||||
*
|
||||
* Returns sets keyed by the 1-based line the directive *targets* so matching is a
|
||||
* direct lookup:
|
||||
* - file: rules disabled for the whole file
|
||||
* - line: line -> rules disabled on that exact line (disable-line)
|
||||
* - nextLine: line -> rules disabled on that line (disable-next-line on line-1)
|
||||
*
|
||||
* `*` in any set means "every rule".
|
||||
*/
|
||||
function parseInlineIgnores(content) {
|
||||
const result = { file: new Set(), line: new Map(), nextLine: new Map() };
|
||||
const text = typeof content === 'string' ? content : '';
|
||||
// Cheap bail-out: the substring must be present for any directive to exist.
|
||||
// Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`).
|
||||
if (!/impeccable-disable/i.test(text)) return result;
|
||||
|
||||
// Split on `\n` only, exactly as detectText numbers lines, so directive line
|
||||
// keys line up with finding `line` values (incl. on `\r`-only line endings).
|
||||
// The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is
|
||||
// never captured into the rule list.
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
DIRECTIVE_RE.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) {
|
||||
const variant = m[1].toLowerCase();
|
||||
const rules = parseRuleList(m[2]);
|
||||
if (variant === 'disable') {
|
||||
addRules(result.file, rules);
|
||||
} else if (variant === 'disable-line') {
|
||||
addRules(getSet(result.line, i + 1), rules);
|
||||
} else {
|
||||
// disable-next-line on line i+1 targets line i+2.
|
||||
addRules(getSet(result.nextLine, i + 2), rules);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function setMatches(set, rule) {
|
||||
return Boolean(set) && (set.has('*') || set.has(rule));
|
||||
}
|
||||
|
||||
function isInlineIgnored(finding, directives) {
|
||||
const rule = normalizeRule(finding && finding.antipattern);
|
||||
if (!rule) return false;
|
||||
if (setMatches(directives.file, rule)) return true;
|
||||
const line = Number(finding && finding.line) || 0;
|
||||
if (line > 0) {
|
||||
if (setMatches(directives.line.get(line), rule)) return true;
|
||||
if (setMatches(directives.nextLine.get(line), rule)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasDirectives(directives) {
|
||||
return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop findings waived by an inline directive in the same file's source text.
|
||||
* Findings without a usable line number (e.g. static-HTML page-level findings)
|
||||
* are only matched by whole-file directives — which is the standalone-document
|
||||
* case this primitive exists for.
|
||||
*/
|
||||
function applyInlineIgnores(findings, content) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return findings;
|
||||
const directives = parseInlineIgnores(content);
|
||||
if (!hasDirectives(directives)) return findings;
|
||||
return findings.filter((finding) => !isInlineIgnored(finding, directives));
|
||||
}
|
||||
|
||||
export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored };
|
||||
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
|
||||
// manifest is honored by the CLI (once committed to the default branch) and
|
||||
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
|
||||
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
|
||||
provider: '.github',
|
||||
skillRel: '.github/skills/impeccable',
|
||||
destRel: '.github/hooks/impeccable.json',
|
||||
manifest: () => ({
|
||||
version: 1,
|
||||
hooks: {
|
||||
postToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
matcher: 'edit|create|apply_patch',
|
||||
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
|
||||
timeoutSec: TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function readRawConfigFile(filePath) {
|
||||
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
|
||||
// `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;
|
||||
|
||||
@@ -959,13 +959,114 @@ export function resolveTargetFiles(event, projectCwd) {
|
||||
export function resolveHarness(env = {}, event = null) {
|
||||
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
|
||||
if (explicit === 'cursor') return 'cursor';
|
||||
if (explicit === 'github') return 'github';
|
||||
if (explicit === 'claude' || explicit === 'codex') return 'claude';
|
||||
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
|
||||
// has no `tool_name`/`tool_input`. That shape is the discriminator.
|
||||
if (event && typeof event === 'object'
|
||||
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
|
||||
&& event.tool_name === undefined && event.tool_input === undefined) {
|
||||
return 'github';
|
||||
}
|
||||
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
|
||||
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
|
||||
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
|
||||
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
|
||||
// normalizeGitHubEvent). The detector reads the file from disk after the tool
|
||||
// ran, so only the path (not the proposed content) is needed here.
|
||||
export function parseGitHubToolArgs(toolArgs) {
|
||||
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
|
||||
if (typeof toolArgs === 'string' && toolArgs.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolArgs);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
|
||||
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
|
||||
// *** Begin Patch
|
||||
// *** Add File: /abs/app.css
|
||||
// +body { ... }
|
||||
// *** End Patch
|
||||
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
|
||||
// JSON string with the path under `path`. Both must map onto the internal shape.
|
||||
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
|
||||
|
||||
function looksLikeApplyPatch(rawArgs) {
|
||||
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
|
||||
// Guard against an edit/create payload whose edited *content* happens to
|
||||
// contain patch markers: that payload is a JSON object string, whereas a real
|
||||
// apply_patch payload is a raw patch string that does not parse as JSON. Only
|
||||
// treat non-JSON-object strings as apply_patch so edit events still get their
|
||||
// `path` extracted.
|
||||
try {
|
||||
const parsed = JSON.parse(rawArgs);
|
||||
if (parsed && typeof parsed === 'object') return false;
|
||||
} catch { /* not JSON → genuine raw patch */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPatchText(rawArgs) {
|
||||
if (typeof rawArgs === 'string') {
|
||||
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
|
||||
// Defensive: a future Copilot build might JSON-wrap the patch.
|
||||
const parsed = parseGitHubToolArgs(rawArgs);
|
||||
return parsed.patch || parsed.input || parsed.command || '';
|
||||
}
|
||||
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
||||
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeGitHubEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const toolName = event.toolName || event.tool_name || null;
|
||||
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
|
||||
const rawArgs = event.toolArgs;
|
||||
|
||||
let normalizedToolName = toolName;
|
||||
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
|
||||
// resolveTargetFiles() reads the touched paths from tool_input.command when
|
||||
// tool_name is 'apply_patch', so normalize the name even if a future build
|
||||
// sends the patch under a different tool label.
|
||||
const patch = applyPatchText(rawArgs);
|
||||
if (patch) {
|
||||
toolInput.command = patch;
|
||||
normalizedToolName = 'apply_patch';
|
||||
}
|
||||
} else {
|
||||
const args = parseGitHubToolArgs(rawArgs);
|
||||
const filePath = args.path || args.file_path || args.filePath || args.target_file;
|
||||
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: normalizedToolName,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
|
||||
@@ -1200,12 +1301,12 @@ export function setDetectorForTesting(impl) {
|
||||
// session" so the model knows it's a re-mind, not a new finding.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const STEER_LINE = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.';
|
||||
const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.';
|
||||
|
||||
export function renderCleanAck(filePath, opts = {}) {
|
||||
const cwd = opts.cwd || process.cwd();
|
||||
const display = relativize(filePath, cwd);
|
||||
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${STEER_LINE}`;
|
||||
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`;
|
||||
}
|
||||
|
||||
export function renderPendingAck(filePath, knownFindings, opts = {}) {
|
||||
@@ -1261,7 +1362,7 @@ function directiveFooter(display, opts = {}) {
|
||||
'',
|
||||
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
|
||||
'',
|
||||
`Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
|
||||
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -1520,6 +1621,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'cursor') {
|
||||
return JSON.stringify({ additional_context: text });
|
||||
}
|
||||
// GitHub Copilot's postToolUse hook injects context via a top-level
|
||||
// `additionalContext` string (alongside an optional `modifiedResult`).
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
/**
|
||||
* CLI-side reader/writer for the unified `.impeccable` config.
|
||||
*
|
||||
* The CLI (published to npm) and the skill scripts (bundled into the install)
|
||||
* live in separate trees and cannot share runtime code, so this duplicates a
|
||||
* small slice of skill/scripts/hook-lib.mjs — the config-path layout, detector
|
||||
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
|
||||
* ignore filtering, and exclude marker in sync if either side changes.
|
||||
*
|
||||
* Schema (config.json shared / config.local.json gitignored, per-developer):
|
||||
* {
|
||||
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
|
||||
* "hook": { "consent": "accepted" | "declined", ... },
|
||||
* "updateCheck": bool
|
||||
* }
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
||||
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
|
||||
export function getConfigPath(root) {
|
||||
return join(root, '.impeccable', 'config.json');
|
||||
}
|
||||
|
||||
export function getLocalConfigPath(root) {
|
||||
return join(root, '.impeccable', 'config.local.json');
|
||||
}
|
||||
|
||||
function safeReadJson(filePath) {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hookSection(raw) {
|
||||
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
|
||||
}
|
||||
|
||||
function detectorSection(raw) {
|
||||
return raw && raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
|
||||
}
|
||||
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
|
||||
|
||||
const DEFAULT_DETECTION_CONFIG = Object.freeze({
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
designSystem: { enabled: true },
|
||||
});
|
||||
|
||||
function cloneDetectionConfig() {
|
||||
return {
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
|
||||
};
|
||||
}
|
||||
|
||||
function cloneRawDetectionConfig() {
|
||||
return {
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
};
|
||||
}
|
||||
|
||||
function applyDetectionConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
||||
config.designSystem = {
|
||||
...config.designSystem,
|
||||
enabled: raw.designSystem.enabled === false ? false : true,
|
||||
};
|
||||
}
|
||||
if (Array.isArray(raw.ignoreRules)) {
|
||||
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
|
||||
}
|
||||
if (Array.isArray(raw.ignoreFiles)) {
|
||||
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
|
||||
}
|
||||
if (Array.isArray(raw.ignoreValues)) {
|
||||
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function uniqueStrings(values) {
|
||||
return Array.from(new Set(values.map(String)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detector filters shared by `npx impeccable detect` and the design hook.
|
||||
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
|
||||
* the hook is disabled, but they honor the same ignore rules and design-system
|
||||
* toggle.
|
||||
*/
|
||||
export function readDetectionConfig(root) {
|
||||
const config = cloneDetectionConfig();
|
||||
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
|
||||
const raw = safeReadJson(filePath);
|
||||
// Back-compat: old builds stored detector filters under hook.*.
|
||||
applyDetectionConfigSource(config, hookSection(raw));
|
||||
applyDetectionConfigSource(config, detectorSection(raw));
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export function readRawDetectionConfig(root, opts = {}) {
|
||||
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
|
||||
const config = cloneRawDetectionConfig();
|
||||
applyDetectionConfigSource(config, hookSection(raw));
|
||||
applyDetectionConfigSource(config, detectorSection(raw));
|
||||
return config;
|
||||
}
|
||||
|
||||
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
|
||||
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
|
||||
if (opts.local) ensureConfigGitExclude(root);
|
||||
const existing = safeReadJson(filePath) || {};
|
||||
const existingHook = hookSection(existing);
|
||||
const nextHook = stripDetectorKeys(existingHook);
|
||||
const nextDetector = {
|
||||
...(detectorSection(existing) || {}),
|
||||
...normalizeDetectionConfigForWrite(detectorConfig),
|
||||
};
|
||||
const next = {
|
||||
...existing,
|
||||
detector: nextDetector,
|
||||
};
|
||||
if (nextHook && Object.keys(nextHook).length > 0) {
|
||||
next.hook = nextHook;
|
||||
} else {
|
||||
delete next.hook;
|
||||
}
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function normalizeDetectionConfigForWrite(config) {
|
||||
const out = {};
|
||||
if (Array.isArray(config?.ignoreRules)) {
|
||||
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
|
||||
}
|
||||
if (Array.isArray(config?.ignoreFiles)) {
|
||||
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
|
||||
}
|
||||
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
|
||||
out.designSystem = {
|
||||
enabled: config.designSystem.enabled === false ? false : true,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function stripDetectorKeys(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeIgnoreValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeIgnoreRule(rule) {
|
||||
return String(rule || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function colorIgnoreKey(value) {
|
||||
const color = parseIgnoreColor(value);
|
||||
if (!color) return '';
|
||||
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
|
||||
}
|
||||
|
||||
function parseIgnoreColor(value) {
|
||||
const text = String(value || '').trim().toLowerCase();
|
||||
if (!text) return null;
|
||||
|
||||
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
|
||||
if (hex) return parseHexIgnoreColor(hex[1]);
|
||||
|
||||
const rgb = text.match(/^rgba?\((.*)\)$/i);
|
||||
if (rgb) {
|
||||
const parts = splitColorArgs(rgb[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const r = parseRgbChannel(parts[0]);
|
||||
const g = parseRgbChannel(parts[1]);
|
||||
const b = parseRgbChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
if ([r, g, b, a].some((v) => v === null)) return null;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
const hsl = text.match(/^hsla?\((.*)\)$/i);
|
||||
if (hsl) {
|
||||
const parts = splitColorArgs(hsl[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const h = parseHueChannel(parts[0]);
|
||||
const s = parsePercentChannel(parts[1]);
|
||||
const l = parsePercentChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
if ([h, s, l, a].some((v) => v === null)) return null;
|
||||
return hslToRgb(h, s, l, a);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseHexIgnoreColor(hex) {
|
||||
if (hex.length === 3 || hex.length === 4) {
|
||||
const r = parseInt(hex[0] + hex[0], 16);
|
||||
const g = parseInt(hex[1] + hex[1], 16);
|
||||
const b = parseInt(hex[2] + hex[2], 16);
|
||||
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
function splitColorArgs(body) {
|
||||
const text = String(body || '').trim();
|
||||
if (!text) return [];
|
||||
if (text.includes(',')) {
|
||||
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
|
||||
const last = parts[parts.length - 1];
|
||||
if (last && last.includes('/')) {
|
||||
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
|
||||
return [...parts.slice(0, -1), ...split];
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
|
||||
}
|
||||
|
||||
function parseRgbChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const scaled = match[2] ? value * 2.55 : value;
|
||||
if (scaled < 0 || scaled > 255) return null;
|
||||
return Math.round(scaled);
|
||||
}
|
||||
|
||||
function parseAlphaChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const alpha = match[2] ? value / 100 : value;
|
||||
return alpha >= 0 && alpha <= 1 ? alpha : null;
|
||||
}
|
||||
|
||||
function parseHueChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const unit = match[2] || 'deg';
|
||||
if (unit === 'turn') return value * 360;
|
||||
if (unit === 'rad') return value * (180 / Math.PI);
|
||||
if (unit === 'grad') return value * 0.9;
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePercentChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)%$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return value >= 0 && value <= 100 ? value / 100 : null;
|
||||
}
|
||||
|
||||
function hslToRgb(hue, saturation, lightness, alpha) {
|
||||
const h = (((hue % 360) + 360) % 360) / 360;
|
||||
if (saturation === 0) {
|
||||
const gray = clampByte(Math.round(lightness * 255));
|
||||
return { r: gray, g: gray, b: gray, a: alpha };
|
||||
}
|
||||
const q = lightness < 0.5
|
||||
? lightness * (1 + saturation)
|
||||
: lightness + saturation - lightness * saturation;
|
||||
const p = 2 * lightness - q;
|
||||
const toRgb = (t) => {
|
||||
let channel = t;
|
||||
if (channel < 0) channel += 1;
|
||||
if (channel > 1) channel -= 1;
|
||||
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
|
||||
if (channel < 1 / 2) return q;
|
||||
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
|
||||
return p;
|
||||
};
|
||||
return {
|
||||
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
|
||||
g: clampByte(Math.round(toRgb(h) * 255)),
|
||||
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
|
||||
a: alpha,
|
||||
};
|
||||
}
|
||||
|
||||
function clampByte(value) {
|
||||
return Math.min(255, Math.max(0, value));
|
||||
}
|
||||
|
||||
function ignoreValueMatches(rule, entryValue, findingValue) {
|
||||
if (entryValue === findingValue) return true;
|
||||
if (rule !== 'design-system-color') return false;
|
||||
const entryColor = colorIgnoreKey(entryValue);
|
||||
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
|
||||
}
|
||||
|
||||
export function normalizeIgnoreValueEntries(entries) {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
const out = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const rule = normalizeIgnoreRule(entry.rule);
|
||||
const value = normalizeIgnoreValue(entry.value);
|
||||
if (!rule || !value) continue;
|
||||
const normalized = { rule, value };
|
||||
const files = uniqueStrings([
|
||||
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
|
||||
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
|
||||
]);
|
||||
if (files.length > 0) normalized.files = files;
|
||||
if (typeof entry.reason === 'string' && entry.reason.trim()) {
|
||||
normalized.reason = entry.reason.trim();
|
||||
}
|
||||
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
|
||||
normalized.createdAt = entry.createdAt.trim();
|
||||
}
|
||||
out.push(normalized);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeIgnoreValues(existing, incoming) {
|
||||
const map = new Map();
|
||||
for (const entry of normalizeIgnoreValueEntries(existing)) {
|
||||
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
||||
}
|
||||
for (const entry of normalizeIgnoreValueEntries(incoming)) {
|
||||
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
function ignoreValueFilesKey(files) {
|
||||
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
|
||||
}
|
||||
|
||||
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
|
||||
function globToRegex(glob) {
|
||||
let re = '^';
|
||||
let i = 0;
|
||||
while (i < glob.length) {
|
||||
const c = glob[i];
|
||||
if (c === '*') {
|
||||
if (glob[i + 1] === '*') {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
if (glob[i] === '/') i += 1;
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (c === '{') {
|
||||
const end = glob.indexOf('}', i);
|
||||
if (end === -1) { re += '\\{'; i += 1; continue; }
|
||||
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
|
||||
re += `(?:${parts.join('|')})`;
|
||||
i = end + 1;
|
||||
} else if (/[.+^$()|[\]\\]/.test(c)) {
|
||||
re += `\\${c}`;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
re += '$';
|
||||
return new RegExp(re);
|
||||
}
|
||||
|
||||
export function matchesAnyGlob(filePath, globs) {
|
||||
if (!Array.isArray(globs) || globs.length === 0) return false;
|
||||
const normalized = String(filePath || '').split(sep).join('/');
|
||||
for (const glob of globs) {
|
||||
try {
|
||||
const re = globToRegex(String(glob));
|
||||
if (re.test(normalized)) return true;
|
||||
const base = normalized.split('/').pop();
|
||||
if (re.test(base)) return true;
|
||||
} catch {
|
||||
/* malformed glob, skip */
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldIgnoreDetectionFile(filePath, root, config) {
|
||||
const globs = config?.ignoreFiles || [];
|
||||
if (!Array.isArray(globs) || globs.length === 0) return false;
|
||||
const raw = String(filePath || '').trim();
|
||||
if (!raw) return false;
|
||||
if (matchesAnyGlob(raw, globs)) return true;
|
||||
|
||||
try {
|
||||
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
|
||||
if (matchesAnyGlob(abs, globs)) return true;
|
||||
const rel = relative(root, abs);
|
||||
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
|
||||
return matchesAnyGlob(rel, globs);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function filterDetectionFindings(findings, config) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return [];
|
||||
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
|
||||
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
return findings.filter((finding) => {
|
||||
if (!finding || typeof finding !== 'object') return false;
|
||||
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
|
||||
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function isIgnoredFindingValue(finding, ignoreValues) {
|
||||
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
const value = extractFindingIgnoreValue(finding);
|
||||
if (!rule || !value) return false;
|
||||
return ignoreValues.some((entry) => {
|
||||
const wildcardValue = entry.value === '*';
|
||||
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
|
||||
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
|
||||
return findingMatchesScopedIgnoreFile(finding, entry.files);
|
||||
});
|
||||
}
|
||||
|
||||
function findingMatchesScopedIgnoreFile(finding, globs) {
|
||||
const filePath = String(finding?.file || '').trim();
|
||||
if (!filePath) return false;
|
||||
if (matchesAnyGlob(filePath, globs)) return true;
|
||||
|
||||
const normalized = filePath.split(sep).join('/');
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const suffix = parts.slice(i).join('/');
|
||||
if (matchesAnyGlob(suffix, globs)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function extractFindingIgnoreValue(finding) {
|
||||
if (!finding || typeof finding !== 'object') return '';
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
const directValueRules = new Set([
|
||||
'overused-font',
|
||||
'bounce-easing',
|
||||
'design-system-font',
|
||||
'design-system-color',
|
||||
'design-system-radius',
|
||||
]);
|
||||
if (!directValueRules.has(rule)) return '';
|
||||
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
|
||||
}
|
||||
|
||||
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
|
||||
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
|
||||
if (direct) return direct;
|
||||
|
||||
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
|
||||
for (const text of candidates) {
|
||||
if (rule === 'bounce-easing') {
|
||||
const motion = extractMotionIgnoreValue(text);
|
||||
if (motion) return motion;
|
||||
continue;
|
||||
}
|
||||
|
||||
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
|
||||
if (primary) return cleanIgnoreValueDisplay(primary[1]);
|
||||
|
||||
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
|
||||
if (family) return cleanIgnoreValueDisplay(family[1]);
|
||||
|
||||
const google = text.match(/[?&]family=([^&:;\n]+)/i);
|
||||
if (google) {
|
||||
try {
|
||||
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
|
||||
} catch {
|
||||
return cleanIgnoreValueDisplay(google[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractMotionIgnoreValue(text) {
|
||||
const tailwind = text.match(/\banimate-bounce\b/i);
|
||||
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
|
||||
|
||||
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
|
||||
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
|
||||
|
||||
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
|
||||
if (animation) {
|
||||
const token = animation[1]
|
||||
.split(/[,\s]+/)
|
||||
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
||||
if (token) return cleanIgnoreValueDisplay(token);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function cleanIgnoreValueDisplay(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ')
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
|
||||
* config.local.json (per-developer) overrides config.json.
|
||||
*/
|
||||
export function getHookConsent(root) {
|
||||
let consent;
|
||||
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
|
||||
const hook = hookSection(safeReadJson(filePath));
|
||||
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
|
||||
}
|
||||
return consent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the per-developer decision to config.local.json, preserving any
|
||||
* sibling keys, and ensure the file is gitignored.
|
||||
*/
|
||||
export function setHookConsent(root, value) {
|
||||
const filePath = getLocalConfigPath(root);
|
||||
const existing = safeReadJson(filePath) || {};
|
||||
const hook = hookSection(existing) || {};
|
||||
const next = { ...existing, hook: { ...hook, consent: value } };
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
|
||||
ensureConfigGitExclude(root);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
|
||||
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
|
||||
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
|
||||
|
||||
/**
|
||||
* Add config.local.json to `.git/info/exclude` so a developer's decision is
|
||||
* never committed. Idempotent via marker comments. Best-effort; returns false
|
||||
* when there is no resolvable git dir.
|
||||
*/
|
||||
export function ensureConfigGitExclude(root) {
|
||||
try {
|
||||
const gitDir = resolveGitDir(root);
|
||||
if (!gitDir) return false;
|
||||
const target = join(gitDir, 'info', 'exclude');
|
||||
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
|
||||
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
|
||||
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
|
||||
let updated;
|
||||
if (markerRe.test(existing)) {
|
||||
updated = existing.replace(markerRe, block);
|
||||
} else {
|
||||
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
|
||||
updated = `${prefix}${block}\n`;
|
||||
}
|
||||
if (updated !== existing) {
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, updated);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGitDir(root) {
|
||||
const dotGit = join(root, '.git');
|
||||
if (!existsSync(dotGit)) return null;
|
||||
try {
|
||||
if (statSync(dotGit).isDirectory()) return dotGit;
|
||||
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
|
||||
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
|
||||
if (match) {
|
||||
const resolved = match[1].trim();
|
||||
return isAbsolute(resolved) ? resolved : join(root, resolved);
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -1,50 +1,52 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
export const CRITIQUE_DIR = 'critique';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
export function getImpeccableDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
const projectRoot = resolveProjectRoot(cwd, options);
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
getDesignSidecarPath(cwd, options),
|
||||
path.join(projectRoot, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
export function getLiveDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
export function getLiveConfigPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
const primary = getLiveConfigPath(cwd, { targetPath });
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
@@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
export function getLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function readLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
|
||||
@@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) {
|
||||
const filePath = getLiveServerPath(cwd, options);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'annotations');
|
||||
}
|
||||
|
||||
export function getCritiqueDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), CRITIQUE_DIR);
|
||||
export function getCritiqueDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR);
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
class TargetArgError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'TargetArgError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTargetPath(args = [], { strict = false } = {}) {
|
||||
let targetPath = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i]);
|
||||
if (arg === '--target' || arg === '-t') {
|
||||
const next = args[i + 1];
|
||||
if (next && !String(next).startsWith('-')) {
|
||||
targetPath = String(next);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--target=')) {
|
||||
const value = arg.slice('--target='.length);
|
||||
if (value) {
|
||||
targetPath = value;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export function parseTargetOptions(args = [], options = {}) {
|
||||
const targetPath = parseTargetPath(args, options);
|
||||
return targetPath ? { targetPath } : {};
|
||||
}
|
||||
@@ -57,7 +57,7 @@
|
||||
const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
|
||||
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint
|
||||
const PREFIX = 'impeccable-live';
|
||||
const PICK_CURSOR_CLASS = PREFIX + '-pick-cursor';
|
||||
const PICK_CURSOR_STYLE_ID = PREFIX + '-pick-cursor-style';
|
||||
const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000;
|
||||
const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({
|
||||
prefix: PREFIX,
|
||||
@@ -152,6 +152,7 @@
|
||||
let scrollLockTargetY = null;
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock';
|
||||
|
||||
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
@@ -1915,45 +1916,45 @@
|
||||
syncPageInteractionCursor();
|
||||
}
|
||||
|
||||
let pageInteractionCursorActive = false;
|
||||
|
||||
function ensurePickCursorStyle() {
|
||||
if (document.getElementById(PREFIX + '-pick-cursor-style')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = PREFIX + '-pick-cursor-style';
|
||||
/**
|
||||
* Drive the page-level pick / insert cursor through the textContent of one
|
||||
* injected <style>, never by mutating <html> (className or inline style).
|
||||
* Frameworks that server-render the <html>/<body> roots (Next.js App Router)
|
||||
* report a React 19 hydration mismatch when the client adds an attribute the
|
||||
* server HTML never emitted, so a `class`/inline `style` toggled on
|
||||
* `document.documentElement` trips "a tree hydrated but some attributes ...
|
||||
* didn't match" on the next Fast-Refresh re-render. Keying the cursor off a
|
||||
* stable-id <style> keeps the effect off the hydrated host elements (same
|
||||
* shape as the scroll-anchor lock). A falsy cursor clears the rule.
|
||||
*/
|
||||
function setPageInteractionCursor(cursor) {
|
||||
let style = document.getElementById(PICK_CURSOR_STYLE_ID);
|
||||
if (!cursor) {
|
||||
if (style) style.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = PICK_CURSOR_STYLE_ID;
|
||||
// Styles the host page, not the chrome - inside the adapter's shadow UI
|
||||
// root (uiAppendStyle's target) these selectors would match nothing.
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
}
|
||||
style.textContent =
|
||||
'html.' + PICK_CURSOR_CLASS + ' * { cursor: crosshair !important; }\n'
|
||||
+ 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"],\n'
|
||||
+ 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"] * { cursor: revert !important; }';
|
||||
// Styles the host page, not the chrome - inside the adapter's shadow UI
|
||||
// root (uiAppendStyle's target) these selectors would match nothing.
|
||||
document.head.appendChild(style);
|
||||
'* { cursor: ' + cursor + ' !important; }\n'
|
||||
+ '[id^="' + PREFIX + '"],\n'
|
||||
+ '[id^="' + PREFIX + '"] * { cursor: revert !important; }';
|
||||
}
|
||||
|
||||
/** Page-level cursor while pick or insert mode is targeting page elements. */
|
||||
function syncPageInteractionCursor() {
|
||||
const pickCursor = state === 'PICKING' && pickActive && !insertActive;
|
||||
let axisCursor = '';
|
||||
if (state === 'PICKING' && insertActive) {
|
||||
axisCursor = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : '';
|
||||
}
|
||||
|
||||
if (pickCursor) {
|
||||
ensurePickCursorStyle();
|
||||
document.documentElement.classList.add(PICK_CURSOR_CLASS);
|
||||
document.documentElement.style.cursor = '';
|
||||
pageInteractionCursorActive = true;
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.classList.remove(PICK_CURSOR_CLASS);
|
||||
if (axisCursor) {
|
||||
document.documentElement.style.cursor = axisCursor;
|
||||
pageInteractionCursorActive = true;
|
||||
} else if (pageInteractionCursorActive) {
|
||||
document.documentElement.style.cursor = '';
|
||||
pageInteractionCursorActive = false;
|
||||
let cursor = '';
|
||||
if (state === 'PICKING' && pickActive && !insertActive) {
|
||||
cursor = 'crosshair';
|
||||
} else if (state === 'PICKING' && insertActive && insertHoverAnchor) {
|
||||
cursor = cursorForInsertAxis(insertHoverAxis || 'column');
|
||||
}
|
||||
setPageInteractionCursor(cursor);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5815,10 +5816,22 @@
|
||||
|
||||
try { history.scrollRestoration = 'manual'; } catch {}
|
||||
|
||||
const prevHtmlAnchor = document.documentElement.style.overflowAnchor;
|
||||
const prevBodyAnchor = document.body.style.overflowAnchor;
|
||||
document.documentElement.style.overflowAnchor = 'none';
|
||||
document.body.style.overflowAnchor = 'none';
|
||||
// Suppress the browser's scroll-anchoring on the scroll root so it can't
|
||||
// fight our manual scroll correction. Apply this as a stylesheet rule, not
|
||||
// as inline `style` on <html>/<body>: those elements are server-rendered by
|
||||
// frameworks like Next.js App Router, and mutating their inline style makes
|
||||
// React 19 report a hydration mismatch on the next Fast-Refresh re-render.
|
||||
// A <style> rule has the same computed effect without touching any hydrated
|
||||
// element's attributes. Like the inline version, it is recreated on every
|
||||
// startScrollLock call, so reload survival (driven by the persisted scroll
|
||||
// key) is unaffected.
|
||||
let anchorLockStyle = document.getElementById(SCROLL_ANCHOR_LOCK_ID);
|
||||
if (!anchorLockStyle) {
|
||||
anchorLockStyle = document.createElement('style');
|
||||
anchorLockStyle.id = SCROLL_ANCHOR_LOCK_ID;
|
||||
anchorLockStyle.textContent = 'html,body{overflow-anchor:none !important;}';
|
||||
(document.head || document.documentElement).appendChild(anchorLockStyle);
|
||||
}
|
||||
|
||||
const correct = (why) => {
|
||||
scrollLockRaf = null;
|
||||
@@ -5853,8 +5866,7 @@
|
||||
|
||||
scrollLockAbort = new AbortController();
|
||||
scrollLockAbort.signal.addEventListener('abort', () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
document.getElementById(SCROLL_ANCHOR_LOCK_ID)?.remove();
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
// Track whether the most recent scroll came from a user gesture. We
|
||||
@@ -6489,10 +6501,13 @@
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isPageEditableElement(deepActive) && !isInlineEditActive(deepActive)) {
|
||||
return;
|
||||
}
|
||||
// While a contenteditable text-leaf is focused, let the browser handle
|
||||
// all keys except Escape. Escape cancels the current edit (restores
|
||||
// original text) and blurs without saving, staying in CONFIGURING.
|
||||
if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
|
||||
if (e.target.isContentEditable && isInlineEditActive(e.target)) {
|
||||
if (e.key !== 'Escape') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -8186,7 +8201,7 @@ void main() {
|
||||
let voiceInterimBase = '';
|
||||
/** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
|
||||
let voiceCtx = null;
|
||||
const PAGE_CHAT_COLLAPSED_W = '88px';
|
||||
const PAGE_CHAT_COLLAPSED_W = '104px';
|
||||
const PAGE_CHAT_PROCESSING_W = '76px';
|
||||
const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
|
||||
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
|
||||
@@ -8197,7 +8212,7 @@ void main() {
|
||||
const GLOBAL_BAR_SECTION_GAP = 8;
|
||||
const GLOBAL_BAR_INNER_GAP = 2;
|
||||
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
|
||||
const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
|
||||
const PAGE_CHAT_EXPANDED_MAX_W = 280;
|
||||
const ICON_PAGE_CHAT =
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
||||
const ICON_PAGE_VOICE =
|
||||
@@ -8277,6 +8292,52 @@ void main() {
|
||||
return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
|
||||
}
|
||||
|
||||
function globalBarModeToggles() {
|
||||
return [
|
||||
uiGetById(PREFIX + '-pick-toggle'),
|
||||
uiGetById(PREFIX + '-insert-toggle'),
|
||||
uiGetById(PREFIX + '-detect-toggle'),
|
||||
uiGetById(PREFIX + '-design-toggle'),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function applyGlobalBarLabelState(expandInactive, forceCollapse = false) {
|
||||
globalBarModeToggles().forEach((toggle) => {
|
||||
if (forceCollapse) toggle._collapseLabel?.(true);
|
||||
else if (expandInactive || toggle.dataset.active === 'true') toggle._expandLabel?.();
|
||||
else toggle._collapseLabel?.();
|
||||
});
|
||||
}
|
||||
|
||||
function syncGlobalBarExpandedLabels(expanded = globalBarEl?.matches(':hover')) {
|
||||
const expandInactive = !!(expanded && !pageChatExpanded);
|
||||
applyGlobalBarLabelState(expandInactive, pageChatExpanded);
|
||||
|
||||
if (expandInactive && globalBarEl && globalBarEl.scrollWidth > window.innerWidth - 16) {
|
||||
applyGlobalBarLabelState(false);
|
||||
}
|
||||
}
|
||||
|
||||
function pageChatCollapsedWidthPx() {
|
||||
const parsed = parseFloat(PAGE_CHAT_COLLAPSED_W);
|
||||
return Number.isFinite(parsed) ? parsed : 104;
|
||||
}
|
||||
|
||||
function pageChatExpandedWidth() {
|
||||
if (!pageChatEl || !globalBarEl) return PAGE_CHAT_EXPANDED_MAX_W + 'px';
|
||||
const currentChatWidth = pageChatEl.getBoundingClientRect().width || pageChatCollapsedWidthPx();
|
||||
const barWidth = Math.max(globalBarEl.getBoundingClientRect().width || 0, globalBarEl.scrollWidth || 0);
|
||||
const nonChatWidth = Math.max(0, barWidth - currentChatWidth);
|
||||
const available = window.innerWidth - 16 - nonChatWidth;
|
||||
const next = Math.max(pageChatCollapsedWidthPx(), Math.min(PAGE_CHAT_EXPANDED_MAX_W, available));
|
||||
return Math.round(next) + 'px';
|
||||
}
|
||||
|
||||
function syncPageChatExpandedWidth() {
|
||||
if (!pageChatEl || !pageChatExpanded) return;
|
||||
pageChatEl.style.width = pageChatExpandedWidth();
|
||||
}
|
||||
|
||||
function syncPageChatChrome() {
|
||||
if (!pageChatEl) return;
|
||||
const P = pageChatPalette();
|
||||
@@ -8312,6 +8373,21 @@ void main() {
|
||||
&& !steerLocked;
|
||||
}
|
||||
|
||||
function isPageEditableElement(el) {
|
||||
if (!el || own(el)) return false;
|
||||
if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return true;
|
||||
return !!el.isContentEditable;
|
||||
}
|
||||
|
||||
function isInlineEditActive(el) {
|
||||
return !!el && inlineEditRows.some((r) => r.el === el);
|
||||
}
|
||||
|
||||
function isPageEditableActive() {
|
||||
const active = activeElementDeep();
|
||||
return isPageEditableElement(active) && !isInlineEditActive(active);
|
||||
}
|
||||
|
||||
function pageHasHostTextSelection() {
|
||||
const sel = window.getSelection?.();
|
||||
if (!sel || sel.isCollapsed) return false;
|
||||
@@ -8325,6 +8401,7 @@ void main() {
|
||||
function shouldSteerAutoFocus() {
|
||||
return shouldFocusSteerChat()
|
||||
&& !steerFocusSuspended
|
||||
&& !isPageEditableActive()
|
||||
&& performance.now() >= steerFocusPauseUntil;
|
||||
}
|
||||
|
||||
@@ -8562,7 +8639,8 @@ void main() {
|
||||
if (!pageChatEl || !pageChatInput) return false;
|
||||
pageChatExpanded = true;
|
||||
pageChatEl.dataset.expanded = 'true';
|
||||
pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
|
||||
syncGlobalBarExpandedLabels(false);
|
||||
pageChatEl.style.width = pageChatExpandedWidth();
|
||||
pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
|
||||
pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED;
|
||||
if (pageChatHint) {
|
||||
@@ -8657,7 +8735,7 @@ void main() {
|
||||
pageChatEl.setAttribute('aria-label', 'Steer the page');
|
||||
pageChatExpanded = keepExpanded;
|
||||
pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
|
||||
pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
|
||||
pageChatEl.style.width = keepExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
|
||||
pageChatEl.style.cursor = 'pointer';
|
||||
if (pageChatInput) {
|
||||
pageChatInput.disabled = false;
|
||||
@@ -8971,6 +9049,7 @@ void main() {
|
||||
pageChatEl.dataset.expanded = 'false';
|
||||
pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
|
||||
pageChatEl.style.cursor = 'pointer';
|
||||
syncGlobalBarExpandedLabels(globalBarEl?.matches(':hover'));
|
||||
if (blur) {
|
||||
pageChatInput.blur();
|
||||
pageChatInput.style.pointerEvents = 'none';
|
||||
@@ -9270,6 +9349,7 @@ void main() {
|
||||
zIndex: Z.bar + 5,
|
||||
display: 'flex', alignItems: 'stretch',
|
||||
gap: '0',
|
||||
width: 'max-content',
|
||||
background: P.surface,
|
||||
border: '1px solid ' + P.border,
|
||||
borderRadius: '8px',
|
||||
@@ -9277,6 +9357,8 @@ void main() {
|
||||
fontFamily: FONT, fontSize: '12px', lineHeight: '1',
|
||||
opacity: '0',
|
||||
overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
});
|
||||
globalBarEl.id = PREFIX + '-global-bar';
|
||||
@@ -9325,6 +9407,7 @@ void main() {
|
||||
const inner = el('div', {
|
||||
display: 'flex', alignItems: 'center',
|
||||
padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
|
||||
flex: '0 0 auto',
|
||||
});
|
||||
inner.id = PREFIX + '-global-bar-inner';
|
||||
globalBarEl.appendChild(inner);
|
||||
@@ -9333,7 +9416,10 @@ void main() {
|
||||
function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
|
||||
const b = el('button', {
|
||||
position: 'relative',
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
minWidth: '30px',
|
||||
padding: '6px 8px', borderRadius: '7px',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
|
||||
@@ -9352,8 +9438,8 @@ void main() {
|
||||
if (!labelEl) return;
|
||||
labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
|
||||
};
|
||||
const collapse = () => {
|
||||
if (!labelEl || b.dataset.active === 'true') return;
|
||||
const collapse = (force = false) => {
|
||||
if (!labelEl || (!force && b.dataset.active === 'true')) return;
|
||||
labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
|
||||
};
|
||||
// Per-button hover only changes color (no layout). The label expand/
|
||||
@@ -9604,6 +9690,7 @@ void main() {
|
||||
width: '1px', height: '18px',
|
||||
background: P.hairline,
|
||||
margin: '0 4px 0 2px',
|
||||
flexShrink: '0',
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
@@ -9620,6 +9707,7 @@ void main() {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0', boxSizing: 'border-box',
|
||||
width: '24px', height: '24px', borderRadius: '6px',
|
||||
flexShrink: '0',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
|
||||
@@ -9632,16 +9720,16 @@ void main() {
|
||||
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
|
||||
inner.appendChild(exitBtn);
|
||||
|
||||
// Bar-level hover: expand every toggle's label at once; collapse on leave.
|
||||
// Bar-level hover: expand mode labels unless Steer is using the space.
|
||||
// Buttons with dataset.active="true" ignore collapse (their label stays).
|
||||
const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
|
||||
globalBarEl.addEventListener('mouseenter', () => {
|
||||
toggles.forEach((t) => t._expandLabel && t._expandLabel());
|
||||
syncGlobalBarExpandedLabels(true);
|
||||
syncPageChatExpandedWidth();
|
||||
schedulePendingDockPosition();
|
||||
setTimeout(schedulePendingDockPosition, 260);
|
||||
});
|
||||
globalBarEl.addEventListener('mouseleave', () => {
|
||||
toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
|
||||
syncGlobalBarExpandedLabels(false);
|
||||
schedulePendingDockPosition();
|
||||
setTimeout(schedulePendingDockPosition, 260);
|
||||
});
|
||||
@@ -9659,6 +9747,7 @@ void main() {
|
||||
pendingDockResizeObserver.observe(globalBarEl);
|
||||
}
|
||||
window.addEventListener('resize', positionPendingDock);
|
||||
window.addEventListener('resize', syncPageChatExpandedWidth);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
globalBarEl.style.opacity = '1';
|
||||
@@ -9705,9 +9794,7 @@ void main() {
|
||||
// If the bar is currently under the cursor, keep all labels expanded -
|
||||
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
|
||||
// would collapse its label while the user's mouse is still on the bar.
|
||||
if (globalBarEl && globalBarEl.matches(':hover')) {
|
||||
[pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
|
||||
}
|
||||
syncGlobalBarExpandedLabels(globalBarEl && globalBarEl.matches(':hover'));
|
||||
|
||||
if (detectBadge) {
|
||||
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
|
||||
@@ -9896,7 +9983,7 @@ void main() {
|
||||
// Remove detection overlays
|
||||
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
|
||||
setLiveState('IDLE');
|
||||
document.getElementById(PREFIX + '-pick-cursor-style')?.remove();
|
||||
document.getElementById(PICK_CURSOR_STYLE_ID)?.remove();
|
||||
window.__IMPECCABLE_LIVE_INIT__ = false;
|
||||
console.log('[impeccable] Live mode exited.');
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* CLI client for the live variant mode poll/reply protocol.
|
||||
*
|
||||
* Usage:
|
||||
* npx impeccable poll # Block until browser event, print JSON
|
||||
* npx impeccable poll --stream # Experimental: keep polling; one JSON line per event
|
||||
* npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly
|
||||
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
|
||||
* npx impeccable poll --reply <id> error "msg" # Reply with error
|
||||
* node <scripts_path>/live-poll.mjs # Block until browser event, print JSON
|
||||
* node <scripts_path>/live-poll.mjs --stream # Experimental: keep polling; one JSON line per event
|
||||
* node <scripts_path>/live-poll.mjs --timeout=600000 # Custom timeout (ms); default is long-poll friendly
|
||||
* node <scripts_path>/live-poll.mjs --reply <id> done # Reply "done" to event <id>
|
||||
* node <scripts_path>/live-poll.mjs --reply <id> error "msg" # Reply with error
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
@@ -15,6 +15,11 @@ import { fileURLToPath } from 'node:url';
|
||||
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
|
||||
// Absolute path to a sibling script in this skill's scripts dir, so runtime
|
||||
// error hints print a directly-runnable command instead of a placeholder.
|
||||
const SELF_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const scriptCmd = (name) => `node "${path.join(SELF_DIR, name)}"`;
|
||||
|
||||
// Node's built-in fetch (undici under the hood) enforces a 300s headers
|
||||
// timeout that can't be lowered per-request. We cap each request below
|
||||
// that ceiling and loop in `pollOnce` to synthesize a long poll without
|
||||
@@ -27,7 +32,7 @@ const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_ed
|
||||
function readServerInfo() {
|
||||
const record = readLiveServerInfo(process.cwd());
|
||||
if (!record) {
|
||||
console.error('No running live server found. Start one with: npx impeccable live');
|
||||
console.error(`No running live server found. Start one with: ${scriptCmd('live.mjs')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return record.info;
|
||||
@@ -82,7 +87,7 @@ export function parseReplyArgs(args) {
|
||||
}
|
||||
|
||||
function validateReplyArgs({ id, status }) {
|
||||
const usage = "Usage: npx impeccable poll --reply <id> <status> [--file path] [--data '<json>'] [message]";
|
||||
const usage = `Usage: ${scriptCmd('live-poll.mjs')} --reply <id> <status> [--file path] [--data '<json>'] [message]`;
|
||||
if (!id || id.startsWith('--')) {
|
||||
const err = new Error(`${usage}\nMissing event id after --reply.`);
|
||||
err.code = 'INVALID_REPLY_ARGS';
|
||||
@@ -283,11 +288,11 @@ export async function runPollStream(base, token, {
|
||||
function handlePollError(err) {
|
||||
if (err.code === 'AUTH_FAILED') {
|
||||
console.error(err.message);
|
||||
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
|
||||
console.error(`Try restarting: ${scriptCmd('live-server.mjs')} stop && ${scriptCmd('live.mjs')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (err.cause?.code === 'ECONNREFUSED') {
|
||||
console.error('Live server not running. Start one with: npx impeccable live');
|
||||
console.error(`Live server not running. Start one with: ${scriptCmd('live.mjs')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (err.code === 'ACK_TIMEOUT') {
|
||||
@@ -331,7 +336,7 @@ Harness note:
|
||||
const info = readServerInfo();
|
||||
const base = `http://localhost:${info.port}`;
|
||||
|
||||
// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [--data '<json>'] [message]
|
||||
// Reply mode: node <scripts_path>/live-poll.mjs --reply <id> <status> [--file path] [--data '<json>'] [message]
|
||||
if (args.includes('--reply')) {
|
||||
let reply;
|
||||
try {
|
||||
@@ -345,7 +350,7 @@ Harness note:
|
||||
await postReply(base, info.token, reply);
|
||||
} catch (err) {
|
||||
if (err.cause?.code === 'ECONNREFUSED') {
|
||||
console.error('Live server not running. Start one with: npx impeccable live');
|
||||
console.error(`Live server not running. Start one with: ${scriptCmd('live.mjs')}`);
|
||||
} else {
|
||||
console.error('Reply failed:', err.message);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './lib/design-parser.mjs';
|
||||
import { resolveContextDir } from './context.mjs';
|
||||
import { loadContext } from './context.mjs';
|
||||
import {
|
||||
assembleLiveBrowserScript,
|
||||
assertLiveBrowserScriptParts,
|
||||
@@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const PROJECT_CONTEXT = loadContext(process.cwd());
|
||||
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
|
||||
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
|
||||
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
|
||||
: null;
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
|
||||
@@ -371,10 +375,7 @@ function hasProjectContext() {
|
||||
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
||||
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
||||
// concern, surfaced by the design panel's own empty state.
|
||||
try {
|
||||
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
return !!PROJECT_CONTEXT.hasProduct;
|
||||
}
|
||||
|
||||
function statOrNull(filePath) {
|
||||
@@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdPath = DESIGN_MD_PATH;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from './context.mjs';
|
||||
import { parseTargetPath } from './lib/target-args.mjs';
|
||||
|
||||
export function resolveLiveTarget(cwd = process.cwd(), args = []) {
|
||||
const originalCwd = path.resolve(cwd);
|
||||
let targetPath = null;
|
||||
try {
|
||||
targetPath = parseTargetPath(args, { strict: true });
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const absoluteTargetPath = targetPath
|
||||
? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath)
|
||||
: null;
|
||||
const projectRoot = targetPath
|
||||
? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath })
|
||||
: originalCwd;
|
||||
return {
|
||||
originalCwd,
|
||||
projectRoot,
|
||||
targetPath,
|
||||
absoluteTargetPath,
|
||||
targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {},
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* CLI helper: find an element in source and wrap it in a variant container.
|
||||
*
|
||||
* Usage:
|
||||
* npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
|
||||
* node <scripts_path>/live-wrap.mjs --id SESSION_ID --count N --query "hero-combined-left" [--file path]
|
||||
*
|
||||
* Searches project files for the element matching the query (class name, ID, or
|
||||
* text snippet), wraps it with the variant scaffolding, and prints the file path
|
||||
|
||||
@@ -21,14 +21,16 @@ import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './context.mjs';
|
||||
import { loadContext, resolveTargetSelection } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { resolveLiveTarget } from './live-target.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
const liveTarget = resolveLiveTarget(process.cwd(), args);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: node live.mjs
|
||||
@@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command:
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
{ ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }
|
||||
|
||||
On target_selection_required, prints:
|
||||
{ ok: false, error: "target_selection_required", targetCandidates }
|
||||
|
||||
On config_missing, prints:
|
||||
{ ok: false, error: "config_missing", configPath, hint }
|
||||
|
||||
The agent should then:
|
||||
1. If config_missing, create the config and re-run this script
|
||||
2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
3. Enter the poll loop: node live-poll.mjs`);
|
||||
1. If target_selection_required, ask which app to use and rerun from that child cwd
|
||||
2. If config_missing, create the config and re-run this script
|
||||
3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
4. Enter the poll loop: node live-poll.mjs`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
if (targetSelection) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'target_selection_required',
|
||||
...targetSelection,
|
||||
hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.',
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
const activeCwd = ctx.projectRoot;
|
||||
const outputTargetPath = liveTarget.targetPath || null;
|
||||
|
||||
const missingContext = missingLiveContext(ctx);
|
||||
if (missingContext.length > 0) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'context_missing',
|
||||
missing: missingContext,
|
||||
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 1. Check config (fail fast if missing — no point starting anything else)
|
||||
const checkOut = runScript('live-inject.mjs', ['--check']);
|
||||
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
|
||||
const checkResult = safeParse(checkOut);
|
||||
if (!checkResult || !checkResult.ok) {
|
||||
console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut }));
|
||||
console.log(JSON.stringify({
|
||||
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 2. Start server (or reuse existing)
|
||||
const serverInfo = ensureServerRunning();
|
||||
const serverInfo = ensureServerRunning(activeCwd);
|
||||
if (!serverInfo) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Inject the script tag at the current port
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]);
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd });
|
||||
const injectResult = safeParse(injectOut);
|
||||
if (!injectResult || !injectResult.ok) {
|
||||
console.log(JSON.stringify({
|
||||
@@ -80,22 +123,23 @@ The agent should then:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Load PRODUCT.md + DESIGN.md context.
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// 4. Compute drift-heal: compare resolved inject targets against the
|
||||
// project's HTML files. Orphans are HTML files not covered by config.
|
||||
// Warning only — the agent decides whether to act.
|
||||
const resolvedFiles = resolveFiles(process.cwd(), checkResult.config);
|
||||
const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config);
|
||||
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
|
||||
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
// 5. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: resolvedFiles,
|
||||
liveConfigPath: checkResult.path,
|
||||
configDrift: drift,
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -105,6 +149,13 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function missingLiveContext(ctx) {
|
||||
const missing = [];
|
||||
if (!ctx.hasProduct) missing.push('PRODUCT.md');
|
||||
if (!ctx.hasDesign) missing.push('DESIGN.md');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
@@ -201,11 +252,11 @@ function globToRegex(pattern) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runScript(name, args) {
|
||||
function runScript(name, args, options = {}) {
|
||||
const scriptPath = path.join(__dirname, name);
|
||||
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
||||
try {
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 });
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
|
||||
} catch (err) {
|
||||
// execSync throws on non-zero exit; return stdout if any
|
||||
return err.stdout || err.message || '';
|
||||
@@ -219,10 +270,10 @@ function safeParse(out) {
|
||||
/**
|
||||
* Return { pid, port, token } for the running live server, starting one if needed.
|
||||
*/
|
||||
function ensureServerRunning() {
|
||||
function ensureServerRunning(cwd = process.cwd()) {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
const existing = readLiveServerInfo(cwd)?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
@@ -232,7 +283,7 @@ function ensureServerRunning() {
|
||||
} catch { /* no PID file */ }
|
||||
|
||||
// Start a new server
|
||||
const out = runScript('live-server.mjs', ['--background']);
|
||||
const out = runScript('live-server.mjs', ['--background'], { cwd });
|
||||
return safeParse(out);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
{
|
||||
"name": "impeccable",
|
||||
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
|
||||
"version": "3.7.0",
|
||||
"version": "3.8.0",
|
||||
"author": {
|
||||
"name": "Paul Bakaus",
|
||||
"email": "paul@paulbakaus.com"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "impeccable",
|
||||
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
|
||||
"version": "3.7.0",
|
||||
"version": "3.8.0",
|
||||
"author": {
|
||||
"name": "Paul Bakaus",
|
||||
"email": "paul@paulbakaus.com"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: impeccable
|
||||
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
|
||||
version: 3.7.0
|
||||
version: 3.8.0
|
||||
user-invocable: true
|
||||
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
|
||||
license: Apache 2.0
|
||||
@@ -15,7 +15,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
|
||||
|
||||
You MUST do these steps before proceeding:
|
||||
|
||||
1. Run `node .claude/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
1. Run `node .claude/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .claude/skills/impeccable/scripts/context.mjs --target <path>` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code and Codex use `PostToolUse` and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project).
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
|
||||
On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands.
|
||||
|
||||
@@ -51,7 +51,7 @@ Prefer the narrowest exception:
|
||||
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
|
||||
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
|
||||
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
|
||||
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
|
||||
- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable <rule>` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it.
|
||||
|
||||
Example value-specific exception:
|
||||
|
||||
@@ -81,8 +81,8 @@ node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
|
||||
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
|
||||
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code and Codex do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR
|
||||
|
||||
Execute in order. No step skipped, no step reordered.
|
||||
|
||||
1. `live.mjs`: boot.
|
||||
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .claude/skills/impeccable/scripts/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
|
||||
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* init flow.
|
||||
*
|
||||
* Path resolution (first match wins):
|
||||
* 1. cwd, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. .agents/context/ then docs/
|
||||
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. Active project .agents/context/ then docs/
|
||||
* 3. Monorepo root context, using the same order, as a per-file fallback
|
||||
* 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* escape hatch, only consulted when defaults are empty
|
||||
* 4. cwd as a "nothing found" default
|
||||
* 5. Active project root as a "nothing found" default
|
||||
*
|
||||
* `resolveContextDir()` and `loadContext()` are also exported for the
|
||||
* server-side scripts (live.mjs, live-server.mjs) that need the structured
|
||||
@@ -19,10 +20,25 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.svelte-kit',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'coverage',
|
||||
]);
|
||||
|
||||
// ─── Update check ──────────────────────────────────────────────────────────
|
||||
// Piggyback a lightweight skill-version check on the once-per-session boot.
|
||||
@@ -38,41 +54,623 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o
|
||||
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
|
||||
const FETCH_TIMEOUT_MS = 1200;
|
||||
|
||||
export function resolveContextDir(cwd = process.cwd()) {
|
||||
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return cwd;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(cwd, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (envDir && envDir.trim()) {
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
return cwd;
|
||||
export function resolveContextDir(cwd = process.cwd(), options = {}) {
|
||||
return resolveContext(cwd, options).contextDir;
|
||||
}
|
||||
|
||||
export function loadContext(cwd = process.cwd()) {
|
||||
const contextDir = resolveContextDir(cwd);
|
||||
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
|
||||
const designPath = firstExisting(contextDir, DESIGN_NAMES);
|
||||
export function loadContext(cwd = process.cwd(), options = {}) {
|
||||
const resolved = resolveContext(cwd, options);
|
||||
const absCwd = path.resolve(cwd);
|
||||
const productPath = resolved.productPath;
|
||||
const designPath = resolved.designPath;
|
||||
const product = productPath ? safeRead(productPath) : null;
|
||||
const design = designPath ? safeRead(designPath) : null;
|
||||
return {
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
productPath: productPath ? path.relative(cwd, productPath) : null,
|
||||
productPath: productPath ? path.relative(absCwd, productPath) : null,
|
||||
hasDesign: !!design,
|
||||
design,
|
||||
designPath: designPath ? path.relative(cwd, designPath) : null,
|
||||
contextDir,
|
||||
designPath: designPath ? path.relative(absCwd, designPath) : null,
|
||||
contextDir: resolved.contextDir,
|
||||
productContextDir: productPath ? path.dirname(productPath) : null,
|
||||
designContextDir: designPath ? path.dirname(designPath) : null,
|
||||
projectRoot: resolved.projectRoot,
|
||||
repoRoot: resolved.repoRoot,
|
||||
isMonorepo: resolved.isMonorepo,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveContext(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const project = resolveProject(absCwd, options);
|
||||
const projectContextDir = resolveLocalContextDir(project.projectRoot);
|
||||
const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
|
||||
? resolveLocalContextDir(project.repoRoot)
|
||||
: null;
|
||||
|
||||
let productPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
|
||||
let designPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
|
||||
|
||||
let envContextDir = null;
|
||||
if (!productPath && !designPath) {
|
||||
envContextDir = resolveEnvContextDir(absCwd);
|
||||
if (envContextDir) {
|
||||
productPath = firstExisting(envContextDir, PRODUCT_NAMES);
|
||||
designPath = firstExisting(envContextDir, DESIGN_NAMES);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contextDir: productPath
|
||||
? path.dirname(productPath)
|
||||
: designPath
|
||||
? path.dirname(designPath)
|
||||
: envContextDir || project.projectRoot,
|
||||
productPath,
|
||||
designPath,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
isMonorepo: project.isMonorepo,
|
||||
targetDir: project.targetDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
|
||||
return resolveProject(cwd, options).projectRoot;
|
||||
}
|
||||
|
||||
export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
|
||||
if (hasTargetOption(options)) return null;
|
||||
const project = resolveProject(cwd);
|
||||
if (
|
||||
!project.isMonorepo
|
||||
|| !project.projectRoot
|
||||
|| !project.repoRoot
|
||||
|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const targetCandidates = discoverTargetCandidates(project.repoRoot);
|
||||
// No discoverable child apps (e.g. `workspaces: ["."]`, a root-only workspace,
|
||||
// or a marker file with no apps/packages children): there is nothing to choose,
|
||||
// so treat the repo root as the active project rather than blocking on an empty
|
||||
// selection prompt that the user cannot answer.
|
||||
if (targetCandidates.length === 0) return null;
|
||||
return {
|
||||
targetPath: null,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
targetCandidates,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProject(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const targetDir = resolveTargetDir(absCwd, options);
|
||||
let repoRoot = findMonorepoRoot(targetDir);
|
||||
if (!repoRoot && targetDir !== absCwd) {
|
||||
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
||||
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
||||
repoRoot = cwdRepoRoot;
|
||||
}
|
||||
}
|
||||
if (!repoRoot) {
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: absCwd,
|
||||
repoRoot: absCwd,
|
||||
isMonorepo: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
|
||||
repoRoot,
|
||||
isMonorepo: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isPathInside(candidate, root) {
|
||||
const rel = path.relative(root, candidate);
|
||||
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function resolveLocalContextDir(root) {
|
||||
if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return root;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(root, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEnvContextDir(cwd) {
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (!envDir || !envDir.trim()) return null;
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
function findMonorepoRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === homeDir) return null;
|
||||
// isMonorepoRoot is checked before hasGitBoundary on purpose: a workspace
|
||||
// root that also carries its own .git is still recognized. The trade-off is
|
||||
// deliberate — a directory with a monorepo *marker* but no workspace patterns
|
||||
// and no apps/packages children is not a monorepo root, so its .git stops
|
||||
// traversal and a further-up root is not searched. The nested .git is treated
|
||||
// as an independent project boundary, which is the intended isolation.
|
||||
if (isMonorepoRoot(dir)) return dir;
|
||||
if (hasGitBoundary(dir)) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return hasFallbackWorkspaceChildren(dir);
|
||||
}
|
||||
|
||||
function hasGitBoundary(dir) {
|
||||
return fs.existsSync(path.join(dir, '.git'));
|
||||
}
|
||||
|
||||
function hasFallbackWorkspaceChildren(dir) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(dir, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function discoverTargetCandidates(repoRoot) {
|
||||
const roots = new Map();
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
for (const pattern of patterns) {
|
||||
for (const root of discoverRootsForPattern(repoRoot, pattern)) {
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(repoRoot, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const root = path.join(base, entry.name);
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots.entries()]
|
||||
.filter(([rel]) => rel && !rel.startsWith('..'))
|
||||
// Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot
|
||||
// sends an excluded package back to the repo root, so an excluded folder must not appear as a
|
||||
// selectable target — choosing it would silently resolve to the root instead.
|
||||
.filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([rel, root]) => {
|
||||
const targetExample = findTargetExample(repoRoot, root);
|
||||
return {
|
||||
name: path.basename(root),
|
||||
path: rel,
|
||||
targetExample,
|
||||
...resolveCandidateContextSummary(repoRoot, root, targetExample),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
|
||||
const ctx = resolveContext(repoRoot, { targetPath });
|
||||
return {
|
||||
productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
|
||||
productPath: contextSourcePath(ctx.productPath, repoRoot),
|
||||
designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
|
||||
designPath: contextSourcePath(ctx.designPath, repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
// Selection candidates surface one of four statuses: 'child' (a canonical
|
||||
// PRODUCT.md/DESIGN.md directly in the app root), 'inherited' (resolved from the
|
||||
// repo root in a monorepo), 'missing' (no file found), and 'fallback'. 'fallback'
|
||||
// intentionally covers two non-canonical locations: a file inside the project
|
||||
// root but in a subdirectory (FALLBACK_DIRS, e.g. `.agents/context/`), and a file
|
||||
// outside both the project and repo roots (IMPECCABLE_CONTEXT_DIR override).
|
||||
function contextSourceStatus(filePath, repoRoot, projectRoot) {
|
||||
if (!filePath) return 'missing';
|
||||
const absPath = path.resolve(filePath);
|
||||
const absProjectRoot = path.resolve(projectRoot);
|
||||
const absRepoRoot = path.resolve(repoRoot);
|
||||
if (isPathInsideOrEqual(absPath, absProjectRoot)) {
|
||||
return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
|
||||
}
|
||||
if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
|
||||
return 'inherited';
|
||||
}
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
function contextSourcePath(filePath, repoRoot) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(repoRoot, filePath);
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
return rel.split(path.sep).join('/');
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function discoverRootsForPattern(repoRoot, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return [];
|
||||
const segments = pattern.split('/').filter(Boolean);
|
||||
if (!segments.length) return [];
|
||||
const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
|
||||
const base = path.join(repoRoot, ...literalPrefix);
|
||||
if (!fs.existsSync(base)) return [];
|
||||
if (segments.includes('**')) {
|
||||
const packageRoots = [];
|
||||
walkDirs(base, (dir) => {
|
||||
if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
|
||||
});
|
||||
if (packageRoots.length) return packageRoots;
|
||||
return directChildDirs(base);
|
||||
}
|
||||
return expandSimplePattern(repoRoot, segments);
|
||||
}
|
||||
|
||||
function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
|
||||
if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
|
||||
const segment = patternSegments[index];
|
||||
if (!segment.includes('*')) {
|
||||
return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const roots = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
if (!segmentMatches(segment, entry.name)) continue;
|
||||
roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function directChildDirs(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function walkDirs(root, visit) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const dir = path.join(root, entry.name);
|
||||
visit(dir);
|
||||
walkDirs(dir, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function isCandidateProjectRoot(dir) {
|
||||
return !!(
|
||||
fs.existsSync(path.join(dir, 'package.json'))
|
||||
|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'src'))
|
||||
|| fs.existsSync(path.join(dir, 'app'))
|
||||
|| fs.existsSync(path.join(dir, 'pages'))
|
||||
|| fs.existsSync(path.join(dir, 'public'))
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnoredWorkspaceDiscoveryDir(name) {
|
||||
return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
|
||||
}
|
||||
|
||||
function findTargetExample(repoRoot, projectRoot) {
|
||||
const examples = [
|
||||
'src/App.jsx',
|
||||
'src/App.tsx',
|
||||
'src/main.jsx',
|
||||
'src/main.tsx',
|
||||
'src/index.jsx',
|
||||
'src/index.ts',
|
||||
'app/page.tsx',
|
||||
'pages/index.tsx',
|
||||
'public/index.html',
|
||||
];
|
||||
for (const rel of examples) {
|
||||
const abs = path.join(projectRoot, rel);
|
||||
if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
|
||||
}
|
||||
return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
|
||||
const rel = path.relative(repoRoot, targetDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
|
||||
if (!excluded) {
|
||||
for (const pattern of patterns) {
|
||||
const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
|
||||
if (projectRoot) return projectRoot;
|
||||
}
|
||||
}
|
||||
if (excluded) return repoRoot;
|
||||
if (
|
||||
relSegments.length >= 2
|
||||
&& MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
|
||||
) {
|
||||
return path.join(repoRoot, relSegments[0], relSegments[1]);
|
||||
}
|
||||
const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
|
||||
if (nearest) return nearest;
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function isExcludedByWorkspacePattern(relSegments, patterns) {
|
||||
return patterns.some((rawPattern) => {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern.startsWith('!')) return false;
|
||||
return workspacePatternMatchesRel(pattern.slice(1), relSegments);
|
||||
});
|
||||
}
|
||||
|
||||
function nearestProjectLikeRoot(repoRoot, targetDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop) {
|
||||
if (
|
||||
firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'package.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(stopDir || repoRoot);
|
||||
const root = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInsideOrEqual(candidate, root) {
|
||||
return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
|
||||
}
|
||||
|
||||
function workspacePatternMatchesRel(pattern, relSegments) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return false;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(repoRoot) {
|
||||
return [
|
||||
...readPackageWorkspaces(repoRoot),
|
||||
...readPnpmWorkspaces(repoRoot),
|
||||
...readLernaWorkspaces(repoRoot),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function readPackageWorkspaces(repoRoot) {
|
||||
const pkg = readJson(path.join(repoRoot, 'package.json'));
|
||||
const workspaces = pkg?.workspaces;
|
||||
if (Array.isArray(workspaces)) return workspaces;
|
||||
if (Array.isArray(workspaces?.packages)) return workspaces.packages;
|
||||
return [];
|
||||
}
|
||||
|
||||
function readLernaWorkspaces(repoRoot) {
|
||||
const lerna = readJson(path.join(repoRoot, 'lerna.json'));
|
||||
return Array.isArray(lerna?.packages) ? lerna.packages : [];
|
||||
}
|
||||
|
||||
function readPnpmWorkspaces(repoRoot) {
|
||||
try {
|
||||
const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
|
||||
const patterns = [];
|
||||
let inPackages = false;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const trimmed = stripYamlInlineComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flowMatch) {
|
||||
patterns.push(...parseYamlFlowList(flowMatch[1]));
|
||||
inPackages = false;
|
||||
continue;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
if (inPackages) {
|
||||
const match = trimmed.match(/^-\s*(.+)$/);
|
||||
if (match) patterns.push(unquoteYamlValue(match[1]));
|
||||
}
|
||||
}
|
||||
return patterns;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stripYamlInlineComment(line) {
|
||||
let quote = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#' && !quote) return line.slice(0, i);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseYamlFlowList(body) {
|
||||
const items = [];
|
||||
let quote = null;
|
||||
let current = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',' && !quote) {
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
return items;
|
||||
}
|
||||
|
||||
function unquoteYamlValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return null;
|
||||
const patternSegments = pattern.split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return null;
|
||||
if (patternSegments.includes('**')) {
|
||||
return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return null;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
|
||||
}
|
||||
return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
|
||||
}
|
||||
|
||||
function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return null;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
|
||||
}
|
||||
const prefixDir = path.join(repoRoot, ...literalPrefix);
|
||||
const targetDir = path.join(repoRoot, ...relSegments);
|
||||
const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
|
||||
if (packageRoot) return packageRoot;
|
||||
return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
|
||||
}
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
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 firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
@@ -89,6 +687,10 @@ function safeRead(p) {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
|
||||
* for a `## Register` section and reading the first non-empty line that
|
||||
@@ -233,7 +835,24 @@ async function computeUpdateDirective(now = Date.now()) {
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
let cliOptions;
|
||||
try {
|
||||
cliOptions = parseCliOptions(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -244,6 +863,10 @@ async function cli() {
|
||||
'Stop the current task, load reference/init.md, and follow its ' +
|
||||
'instructions to write PRODUCT.md before resuming.',
|
||||
];
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
@@ -252,6 +875,10 @@ async function cli() {
|
||||
if (ctx.hasDesign) {
|
||||
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
|
||||
}
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
const register = extractRegister(ctx.product);
|
||||
const next = register
|
||||
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
|
||||
@@ -261,6 +888,60 @@ async function cli() {
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
return parseTargetOptions(args, { strict: true });
|
||||
}
|
||||
|
||||
function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
|
||||
const targetPath = hasTargetOption(options) ? options.targetPath : null;
|
||||
return `RESOLVED_CONTEXT:\n${JSON.stringify({
|
||||
targetPath,
|
||||
...(targetPath ? { targetExists } : {}),
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2)}`;
|
||||
}
|
||||
|
||||
function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
|
||||
if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
|
||||
return !!(
|
||||
ctx.isMonorepo
|
||||
&& (!targetProvided || targetExists === false)
|
||||
&& ctx.projectRoot
|
||||
&& ctx.repoRoot
|
||||
&& path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
|
||||
);
|
||||
}
|
||||
|
||||
function buildMissingTargetDirective() {
|
||||
const script = process.argv[1] || 'context.mjs';
|
||||
return (
|
||||
'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
|
||||
'If the user named a file, route, or child app, do not answer from this output. ' +
|
||||
`Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
|
||||
);
|
||||
}
|
||||
|
||||
function buildTargetSelectionDirective(selection) {
|
||||
return (
|
||||
`TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
|
||||
'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' +
|
||||
'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
|
||||
'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
|
||||
);
|
||||
}
|
||||
|
||||
// Run cli() only when this module is the entry point. Compare realpaths
|
||||
// rather than endsWith(): a loose suffix match also fires for unrelated
|
||||
// scripts like `load-context.mjs`, and realpath tolerates symlinked
|
||||
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
// Output formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatFindingSummary(count) {
|
||||
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
|
||||
}
|
||||
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
|
||||
@@ -39,7 +43,7 @@ function formatFindings(findings, jsonMode) {
|
||||
out.push(` → ${item.description}`);
|
||||
}
|
||||
}
|
||||
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
|
||||
out.push(`\n${formatFindingSummary(findings.length)}`);
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
@@ -86,9 +90,12 @@ 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
|
||||
--gpt Also report GPT-specific provider tells (off by default)
|
||||
--gemini Also report Gemini-specific provider tells (off by default)
|
||||
--no-config Do not apply project config, detector ignores, or DESIGN.md
|
||||
--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
|
||||
--help Show this help message
|
||||
|
||||
@@ -97,6 +104,14 @@ Project config:
|
||||
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:
|
||||
<!-- impeccable-disable overused-font -- exported brand doc -->
|
||||
.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.)
|
||||
@@ -118,6 +133,7 @@ async function detectCli() {
|
||||
});
|
||||
if (args[0] === 'detect') args = args.slice(1);
|
||||
const jsonMode = args.includes('--json');
|
||||
const quietMode = args.includes('--quiet');
|
||||
const helpMode = args.includes('--help');
|
||||
// --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
|
||||
@@ -137,7 +153,12 @@ async function detectCli() {
|
||||
if (args.includes('--gemini')) providers.push('gemini');
|
||||
const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false;
|
||||
const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null;
|
||||
const scanOptions = designSystem ? { providers, designSystem } : { providers };
|
||||
// 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 scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled };
|
||||
if (designSystem) scanOptions.designSystem = designSystem;
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
@@ -169,8 +190,8 @@ async function detectCli() {
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
|
||||
if (!jsonMode) {
|
||||
// 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);
|
||||
@@ -200,7 +221,7 @@ async function detectCli() {
|
||||
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) {
|
||||
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` +
|
||||
@@ -258,6 +279,7 @@ async function detectCli() {
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { GENERIC_FONTS } from '../../shared/constants.mjs';
|
||||
import { isNeutralColor } from '../../shared/color.mjs';
|
||||
import { checkSourceDesignSystem } from '../../design-system.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { filterByProviders } from '../../registry/antipatterns.mjs';
|
||||
import { profileFindings, profileStep } from '../../profile/profiler.mjs';
|
||||
@@ -37,10 +39,11 @@ function shouldRunPageAnalyzers(content, filePath) {
|
||||
}
|
||||
|
||||
function isNeutralBorderColor(str) {
|
||||
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
|
||||
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;
|
||||
const c = m[1].toLowerCase();
|
||||
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
|
||||
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
|
||||
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
|
||||
if (hex) {
|
||||
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
|
||||
@@ -57,10 +60,10 @@ function isNeutralBorderColor(str) {
|
||||
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 >= 1 : n >= 4; },
|
||||
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 >= 1 : n >= 3; },
|
||||
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,
|
||||
@@ -547,7 +550,10 @@ function detectText(content, filePath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return filterByProviders(deduped, options?.providers);
|
||||
const byProvider = filterByProviders(deduped, options?.providers);
|
||||
// Inline `impeccable-disable*` waivers travel with the file; honor them unless
|
||||
// explicitly bypassed (`--no-config` / `--no-inline-ignores`).
|
||||
return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content);
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
mergeDesignSystemFindings,
|
||||
} from '../../design-system.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
||||
import {
|
||||
@@ -223,7 +224,11 @@ async function detectHtml(filePath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return filterByProviders(findings, options.providers);
|
||||
const byProvider = filterByProviders(findings, options.providers);
|
||||
// Static-HTML findings carry no line number, so only whole-file
|
||||
// `impeccable-disable` directives apply here — exactly the standalone-document
|
||||
// waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`.
|
||||
return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html);
|
||||
}
|
||||
|
||||
export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml };
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Inline, in-file ignore directives — eslint-disable-style waivers that live at
|
||||
* the point they apply and travel with the artifact instead of (or alongside)
|
||||
* an ignore in `.impeccable/config.json`.
|
||||
*
|
||||
* A config ignore is the right default for repo-wide policy. This complements it
|
||||
* for the one case config can't cover: a waiver that belongs to a single file and
|
||||
* needs to follow that file when it leaves the repo — a generated/exported
|
||||
* standalone document, an emailed HTML file, a snippet scanned out of context.
|
||||
*
|
||||
* Comment-syntax-agnostic: the directive is a raw token matched anywhere on a
|
||||
* line, so the same marker works across every comment style impeccable scans —
|
||||
* `//`, `/* *\/`, `<!-- -->`, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers
|
||||
* are stripped before the rule list is parsed.
|
||||
*
|
||||
* Syntax (reason optional; eslint `--` or biome `:` separator):
|
||||
*
|
||||
* impeccable-disable <rule>[, <rule>...] [-- reason] whole file
|
||||
* impeccable-disable-line <rule>... [-- reason] the same line
|
||||
* impeccable-disable-next-line <rule>... [-- reason] the following line
|
||||
* impeccable-disable bare / `*` = every rule
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* <!-- impeccable-disable overused-font -- exported brand doc, font is first-party -->
|
||||
* .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/
|
||||
* // impeccable-disable-next-line bounce-easing: intentional playful affordance
|
||||
*
|
||||
* Behavior is suppression, for parity with config ignores: a matched directive
|
||||
* drops the finding. The inline reason is self-documenting in the diff; it is not
|
||||
* required and is discarded at scan time (only used here to keep reason words out
|
||||
* of the parsed rule list).
|
||||
*/
|
||||
|
||||
const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi;
|
||||
|
||||
// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't
|
||||
// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the
|
||||
// space before the closer. `--+>` covers `-->` and any longer dash run.
|
||||
const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/;
|
||||
|
||||
function normalizeRule(token) {
|
||||
return String(token || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
// Split the directive remainder into rule tokens, dropping any human reason that
|
||||
// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever
|
||||
// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:`
|
||||
// are unambiguous separators.
|
||||
function parseRuleList(remainder) {
|
||||
let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim();
|
||||
// Cut off a human reason at the first `--` (eslint) or `:` (biome) separator.
|
||||
const reasonSep = text.match(/\s*(?:--+|:)\s*/);
|
||||
if (reasonSep) text = text.slice(0, reasonSep.index);
|
||||
const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean);
|
||||
if (tokens.length === 0 || tokens.includes('*')) return ['*'];
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function addRules(set, rules) {
|
||||
for (const rule of rules) set.add(rule);
|
||||
}
|
||||
|
||||
function getSet(map, key) {
|
||||
let set = map.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
map.set(key, set);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse every inline ignore directive in a file's raw text.
|
||||
*
|
||||
* Returns sets keyed by the 1-based line the directive *targets* so matching is a
|
||||
* direct lookup:
|
||||
* - file: rules disabled for the whole file
|
||||
* - line: line -> rules disabled on that exact line (disable-line)
|
||||
* - nextLine: line -> rules disabled on that line (disable-next-line on line-1)
|
||||
*
|
||||
* `*` in any set means "every rule".
|
||||
*/
|
||||
function parseInlineIgnores(content) {
|
||||
const result = { file: new Set(), line: new Map(), nextLine: new Map() };
|
||||
const text = typeof content === 'string' ? content : '';
|
||||
// Cheap bail-out: the substring must be present for any directive to exist.
|
||||
// Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`).
|
||||
if (!/impeccable-disable/i.test(text)) return result;
|
||||
|
||||
// Split on `\n` only, exactly as detectText numbers lines, so directive line
|
||||
// keys line up with finding `line` values (incl. on `\r`-only line endings).
|
||||
// The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is
|
||||
// never captured into the rule list.
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
DIRECTIVE_RE.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) {
|
||||
const variant = m[1].toLowerCase();
|
||||
const rules = parseRuleList(m[2]);
|
||||
if (variant === 'disable') {
|
||||
addRules(result.file, rules);
|
||||
} else if (variant === 'disable-line') {
|
||||
addRules(getSet(result.line, i + 1), rules);
|
||||
} else {
|
||||
// disable-next-line on line i+1 targets line i+2.
|
||||
addRules(getSet(result.nextLine, i + 2), rules);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function setMatches(set, rule) {
|
||||
return Boolean(set) && (set.has('*') || set.has(rule));
|
||||
}
|
||||
|
||||
function isInlineIgnored(finding, directives) {
|
||||
const rule = normalizeRule(finding && finding.antipattern);
|
||||
if (!rule) return false;
|
||||
if (setMatches(directives.file, rule)) return true;
|
||||
const line = Number(finding && finding.line) || 0;
|
||||
if (line > 0) {
|
||||
if (setMatches(directives.line.get(line), rule)) return true;
|
||||
if (setMatches(directives.nextLine.get(line), rule)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasDirectives(directives) {
|
||||
return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop findings waived by an inline directive in the same file's source text.
|
||||
* Findings without a usable line number (e.g. static-HTML page-level findings)
|
||||
* are only matched by whole-file directives — which is the standalone-document
|
||||
* case this primitive exists for.
|
||||
*/
|
||||
function applyInlineIgnores(findings, content) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return findings;
|
||||
const directives = parseInlineIgnores(content);
|
||||
if (!hasDirectives(directives)) return findings;
|
||||
return findings.filter((finding) => !isInlineIgnored(finding, directives));
|
||||
}
|
||||
|
||||
export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored };
|
||||
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
|
||||
// manifest is honored by the CLI (once committed to the default branch) and
|
||||
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
|
||||
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
|
||||
provider: '.github',
|
||||
skillRel: '.github/skills/impeccable',
|
||||
destRel: '.github/hooks/impeccable.json',
|
||||
manifest: () => ({
|
||||
version: 1,
|
||||
hooks: {
|
||||
postToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
matcher: 'edit|create|apply_patch',
|
||||
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
|
||||
timeoutSec: TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function readRawConfigFile(filePath) {
|
||||
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
|
||||
// `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;
|
||||
|
||||
@@ -959,13 +959,114 @@ export function resolveTargetFiles(event, projectCwd) {
|
||||
export function resolveHarness(env = {}, event = null) {
|
||||
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
|
||||
if (explicit === 'cursor') return 'cursor';
|
||||
if (explicit === 'github') return 'github';
|
||||
if (explicit === 'claude' || explicit === 'codex') return 'claude';
|
||||
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
|
||||
// has no `tool_name`/`tool_input`. That shape is the discriminator.
|
||||
if (event && typeof event === 'object'
|
||||
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
|
||||
&& event.tool_name === undefined && event.tool_input === undefined) {
|
||||
return 'github';
|
||||
}
|
||||
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
|
||||
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
|
||||
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
|
||||
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
|
||||
// normalizeGitHubEvent). The detector reads the file from disk after the tool
|
||||
// ran, so only the path (not the proposed content) is needed here.
|
||||
export function parseGitHubToolArgs(toolArgs) {
|
||||
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
|
||||
if (typeof toolArgs === 'string' && toolArgs.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolArgs);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
|
||||
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
|
||||
// *** Begin Patch
|
||||
// *** Add File: /abs/app.css
|
||||
// +body { ... }
|
||||
// *** End Patch
|
||||
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
|
||||
// JSON string with the path under `path`. Both must map onto the internal shape.
|
||||
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
|
||||
|
||||
function looksLikeApplyPatch(rawArgs) {
|
||||
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
|
||||
// Guard against an edit/create payload whose edited *content* happens to
|
||||
// contain patch markers: that payload is a JSON object string, whereas a real
|
||||
// apply_patch payload is a raw patch string that does not parse as JSON. Only
|
||||
// treat non-JSON-object strings as apply_patch so edit events still get their
|
||||
// `path` extracted.
|
||||
try {
|
||||
const parsed = JSON.parse(rawArgs);
|
||||
if (parsed && typeof parsed === 'object') return false;
|
||||
} catch { /* not JSON → genuine raw patch */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPatchText(rawArgs) {
|
||||
if (typeof rawArgs === 'string') {
|
||||
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
|
||||
// Defensive: a future Copilot build might JSON-wrap the patch.
|
||||
const parsed = parseGitHubToolArgs(rawArgs);
|
||||
return parsed.patch || parsed.input || parsed.command || '';
|
||||
}
|
||||
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
||||
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeGitHubEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const toolName = event.toolName || event.tool_name || null;
|
||||
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
|
||||
const rawArgs = event.toolArgs;
|
||||
|
||||
let normalizedToolName = toolName;
|
||||
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
|
||||
// resolveTargetFiles() reads the touched paths from tool_input.command when
|
||||
// tool_name is 'apply_patch', so normalize the name even if a future build
|
||||
// sends the patch under a different tool label.
|
||||
const patch = applyPatchText(rawArgs);
|
||||
if (patch) {
|
||||
toolInput.command = patch;
|
||||
normalizedToolName = 'apply_patch';
|
||||
}
|
||||
} else {
|
||||
const args = parseGitHubToolArgs(rawArgs);
|
||||
const filePath = args.path || args.file_path || args.filePath || args.target_file;
|
||||
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: normalizedToolName,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
|
||||
@@ -1200,12 +1301,12 @@ export function setDetectorForTesting(impl) {
|
||||
// session" so the model knows it's a re-mind, not a new finding.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const STEER_LINE = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.';
|
||||
const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.';
|
||||
|
||||
export function renderCleanAck(filePath, opts = {}) {
|
||||
const cwd = opts.cwd || process.cwd();
|
||||
const display = relativize(filePath, cwd);
|
||||
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${STEER_LINE}`;
|
||||
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`;
|
||||
}
|
||||
|
||||
export function renderPendingAck(filePath, knownFindings, opts = {}) {
|
||||
@@ -1261,7 +1362,7 @@ function directiveFooter(display, opts = {}) {
|
||||
'',
|
||||
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
|
||||
'',
|
||||
`Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
|
||||
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -1520,6 +1621,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'cursor') {
|
||||
return JSON.stringify({ additional_context: text });
|
||||
}
|
||||
// GitHub Copilot's postToolUse hook injects context via a top-level
|
||||
// `additionalContext` string (alongside an optional `modifiedResult`).
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
/**
|
||||
* CLI-side reader/writer for the unified `.impeccable` config.
|
||||
*
|
||||
* The CLI (published to npm) and the skill scripts (bundled into the install)
|
||||
* live in separate trees and cannot share runtime code, so this duplicates a
|
||||
* small slice of skill/scripts/hook-lib.mjs — the config-path layout, detector
|
||||
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
|
||||
* ignore filtering, and exclude marker in sync if either side changes.
|
||||
*
|
||||
* Schema (config.json shared / config.local.json gitignored, per-developer):
|
||||
* {
|
||||
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
|
||||
* "hook": { "consent": "accepted" | "declined", ... },
|
||||
* "updateCheck": bool
|
||||
* }
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
||||
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
|
||||
export function getConfigPath(root) {
|
||||
return join(root, '.impeccable', 'config.json');
|
||||
}
|
||||
|
||||
export function getLocalConfigPath(root) {
|
||||
return join(root, '.impeccable', 'config.local.json');
|
||||
}
|
||||
|
||||
function safeReadJson(filePath) {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hookSection(raw) {
|
||||
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
|
||||
}
|
||||
|
||||
function detectorSection(raw) {
|
||||
return raw && raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
|
||||
}
|
||||
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
|
||||
|
||||
const DEFAULT_DETECTION_CONFIG = Object.freeze({
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
designSystem: { enabled: true },
|
||||
});
|
||||
|
||||
function cloneDetectionConfig() {
|
||||
return {
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
|
||||
};
|
||||
}
|
||||
|
||||
function cloneRawDetectionConfig() {
|
||||
return {
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
};
|
||||
}
|
||||
|
||||
function applyDetectionConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
||||
config.designSystem = {
|
||||
...config.designSystem,
|
||||
enabled: raw.designSystem.enabled === false ? false : true,
|
||||
};
|
||||
}
|
||||
if (Array.isArray(raw.ignoreRules)) {
|
||||
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
|
||||
}
|
||||
if (Array.isArray(raw.ignoreFiles)) {
|
||||
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
|
||||
}
|
||||
if (Array.isArray(raw.ignoreValues)) {
|
||||
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function uniqueStrings(values) {
|
||||
return Array.from(new Set(values.map(String)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detector filters shared by `npx impeccable detect` and the design hook.
|
||||
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
|
||||
* the hook is disabled, but they honor the same ignore rules and design-system
|
||||
* toggle.
|
||||
*/
|
||||
export function readDetectionConfig(root) {
|
||||
const config = cloneDetectionConfig();
|
||||
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
|
||||
const raw = safeReadJson(filePath);
|
||||
// Back-compat: old builds stored detector filters under hook.*.
|
||||
applyDetectionConfigSource(config, hookSection(raw));
|
||||
applyDetectionConfigSource(config, detectorSection(raw));
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export function readRawDetectionConfig(root, opts = {}) {
|
||||
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
|
||||
const config = cloneRawDetectionConfig();
|
||||
applyDetectionConfigSource(config, hookSection(raw));
|
||||
applyDetectionConfigSource(config, detectorSection(raw));
|
||||
return config;
|
||||
}
|
||||
|
||||
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
|
||||
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
|
||||
if (opts.local) ensureConfigGitExclude(root);
|
||||
const existing = safeReadJson(filePath) || {};
|
||||
const existingHook = hookSection(existing);
|
||||
const nextHook = stripDetectorKeys(existingHook);
|
||||
const nextDetector = {
|
||||
...(detectorSection(existing) || {}),
|
||||
...normalizeDetectionConfigForWrite(detectorConfig),
|
||||
};
|
||||
const next = {
|
||||
...existing,
|
||||
detector: nextDetector,
|
||||
};
|
||||
if (nextHook && Object.keys(nextHook).length > 0) {
|
||||
next.hook = nextHook;
|
||||
} else {
|
||||
delete next.hook;
|
||||
}
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function normalizeDetectionConfigForWrite(config) {
|
||||
const out = {};
|
||||
if (Array.isArray(config?.ignoreRules)) {
|
||||
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
|
||||
}
|
||||
if (Array.isArray(config?.ignoreFiles)) {
|
||||
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
|
||||
}
|
||||
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
|
||||
out.designSystem = {
|
||||
enabled: config.designSystem.enabled === false ? false : true,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function stripDetectorKeys(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeIgnoreValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeIgnoreRule(rule) {
|
||||
return String(rule || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function colorIgnoreKey(value) {
|
||||
const color = parseIgnoreColor(value);
|
||||
if (!color) return '';
|
||||
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
|
||||
}
|
||||
|
||||
function parseIgnoreColor(value) {
|
||||
const text = String(value || '').trim().toLowerCase();
|
||||
if (!text) return null;
|
||||
|
||||
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
|
||||
if (hex) return parseHexIgnoreColor(hex[1]);
|
||||
|
||||
const rgb = text.match(/^rgba?\((.*)\)$/i);
|
||||
if (rgb) {
|
||||
const parts = splitColorArgs(rgb[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const r = parseRgbChannel(parts[0]);
|
||||
const g = parseRgbChannel(parts[1]);
|
||||
const b = parseRgbChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
if ([r, g, b, a].some((v) => v === null)) return null;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
const hsl = text.match(/^hsla?\((.*)\)$/i);
|
||||
if (hsl) {
|
||||
const parts = splitColorArgs(hsl[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const h = parseHueChannel(parts[0]);
|
||||
const s = parsePercentChannel(parts[1]);
|
||||
const l = parsePercentChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
if ([h, s, l, a].some((v) => v === null)) return null;
|
||||
return hslToRgb(h, s, l, a);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseHexIgnoreColor(hex) {
|
||||
if (hex.length === 3 || hex.length === 4) {
|
||||
const r = parseInt(hex[0] + hex[0], 16);
|
||||
const g = parseInt(hex[1] + hex[1], 16);
|
||||
const b = parseInt(hex[2] + hex[2], 16);
|
||||
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
function splitColorArgs(body) {
|
||||
const text = String(body || '').trim();
|
||||
if (!text) return [];
|
||||
if (text.includes(',')) {
|
||||
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
|
||||
const last = parts[parts.length - 1];
|
||||
if (last && last.includes('/')) {
|
||||
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
|
||||
return [...parts.slice(0, -1), ...split];
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
|
||||
}
|
||||
|
||||
function parseRgbChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const scaled = match[2] ? value * 2.55 : value;
|
||||
if (scaled < 0 || scaled > 255) return null;
|
||||
return Math.round(scaled);
|
||||
}
|
||||
|
||||
function parseAlphaChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const alpha = match[2] ? value / 100 : value;
|
||||
return alpha >= 0 && alpha <= 1 ? alpha : null;
|
||||
}
|
||||
|
||||
function parseHueChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const unit = match[2] || 'deg';
|
||||
if (unit === 'turn') return value * 360;
|
||||
if (unit === 'rad') return value * (180 / Math.PI);
|
||||
if (unit === 'grad') return value * 0.9;
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePercentChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)%$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return value >= 0 && value <= 100 ? value / 100 : null;
|
||||
}
|
||||
|
||||
function hslToRgb(hue, saturation, lightness, alpha) {
|
||||
const h = (((hue % 360) + 360) % 360) / 360;
|
||||
if (saturation === 0) {
|
||||
const gray = clampByte(Math.round(lightness * 255));
|
||||
return { r: gray, g: gray, b: gray, a: alpha };
|
||||
}
|
||||
const q = lightness < 0.5
|
||||
? lightness * (1 + saturation)
|
||||
: lightness + saturation - lightness * saturation;
|
||||
const p = 2 * lightness - q;
|
||||
const toRgb = (t) => {
|
||||
let channel = t;
|
||||
if (channel < 0) channel += 1;
|
||||
if (channel > 1) channel -= 1;
|
||||
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
|
||||
if (channel < 1 / 2) return q;
|
||||
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
|
||||
return p;
|
||||
};
|
||||
return {
|
||||
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
|
||||
g: clampByte(Math.round(toRgb(h) * 255)),
|
||||
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
|
||||
a: alpha,
|
||||
};
|
||||
}
|
||||
|
||||
function clampByte(value) {
|
||||
return Math.min(255, Math.max(0, value));
|
||||
}
|
||||
|
||||
function ignoreValueMatches(rule, entryValue, findingValue) {
|
||||
if (entryValue === findingValue) return true;
|
||||
if (rule !== 'design-system-color') return false;
|
||||
const entryColor = colorIgnoreKey(entryValue);
|
||||
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
|
||||
}
|
||||
|
||||
export function normalizeIgnoreValueEntries(entries) {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
const out = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const rule = normalizeIgnoreRule(entry.rule);
|
||||
const value = normalizeIgnoreValue(entry.value);
|
||||
if (!rule || !value) continue;
|
||||
const normalized = { rule, value };
|
||||
const files = uniqueStrings([
|
||||
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
|
||||
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
|
||||
]);
|
||||
if (files.length > 0) normalized.files = files;
|
||||
if (typeof entry.reason === 'string' && entry.reason.trim()) {
|
||||
normalized.reason = entry.reason.trim();
|
||||
}
|
||||
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
|
||||
normalized.createdAt = entry.createdAt.trim();
|
||||
}
|
||||
out.push(normalized);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeIgnoreValues(existing, incoming) {
|
||||
const map = new Map();
|
||||
for (const entry of normalizeIgnoreValueEntries(existing)) {
|
||||
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
||||
}
|
||||
for (const entry of normalizeIgnoreValueEntries(incoming)) {
|
||||
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
function ignoreValueFilesKey(files) {
|
||||
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
|
||||
}
|
||||
|
||||
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
|
||||
function globToRegex(glob) {
|
||||
let re = '^';
|
||||
let i = 0;
|
||||
while (i < glob.length) {
|
||||
const c = glob[i];
|
||||
if (c === '*') {
|
||||
if (glob[i + 1] === '*') {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
if (glob[i] === '/') i += 1;
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (c === '{') {
|
||||
const end = glob.indexOf('}', i);
|
||||
if (end === -1) { re += '\\{'; i += 1; continue; }
|
||||
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
|
||||
re += `(?:${parts.join('|')})`;
|
||||
i = end + 1;
|
||||
} else if (/[.+^$()|[\]\\]/.test(c)) {
|
||||
re += `\\${c}`;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
re += '$';
|
||||
return new RegExp(re);
|
||||
}
|
||||
|
||||
export function matchesAnyGlob(filePath, globs) {
|
||||
if (!Array.isArray(globs) || globs.length === 0) return false;
|
||||
const normalized = String(filePath || '').split(sep).join('/');
|
||||
for (const glob of globs) {
|
||||
try {
|
||||
const re = globToRegex(String(glob));
|
||||
if (re.test(normalized)) return true;
|
||||
const base = normalized.split('/').pop();
|
||||
if (re.test(base)) return true;
|
||||
} catch {
|
||||
/* malformed glob, skip */
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldIgnoreDetectionFile(filePath, root, config) {
|
||||
const globs = config?.ignoreFiles || [];
|
||||
if (!Array.isArray(globs) || globs.length === 0) return false;
|
||||
const raw = String(filePath || '').trim();
|
||||
if (!raw) return false;
|
||||
if (matchesAnyGlob(raw, globs)) return true;
|
||||
|
||||
try {
|
||||
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
|
||||
if (matchesAnyGlob(abs, globs)) return true;
|
||||
const rel = relative(root, abs);
|
||||
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
|
||||
return matchesAnyGlob(rel, globs);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function filterDetectionFindings(findings, config) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return [];
|
||||
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
|
||||
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
return findings.filter((finding) => {
|
||||
if (!finding || typeof finding !== 'object') return false;
|
||||
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
|
||||
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function isIgnoredFindingValue(finding, ignoreValues) {
|
||||
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
const value = extractFindingIgnoreValue(finding);
|
||||
if (!rule || !value) return false;
|
||||
return ignoreValues.some((entry) => {
|
||||
const wildcardValue = entry.value === '*';
|
||||
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
|
||||
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
|
||||
return findingMatchesScopedIgnoreFile(finding, entry.files);
|
||||
});
|
||||
}
|
||||
|
||||
function findingMatchesScopedIgnoreFile(finding, globs) {
|
||||
const filePath = String(finding?.file || '').trim();
|
||||
if (!filePath) return false;
|
||||
if (matchesAnyGlob(filePath, globs)) return true;
|
||||
|
||||
const normalized = filePath.split(sep).join('/');
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const suffix = parts.slice(i).join('/');
|
||||
if (matchesAnyGlob(suffix, globs)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function extractFindingIgnoreValue(finding) {
|
||||
if (!finding || typeof finding !== 'object') return '';
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
const directValueRules = new Set([
|
||||
'overused-font',
|
||||
'bounce-easing',
|
||||
'design-system-font',
|
||||
'design-system-color',
|
||||
'design-system-radius',
|
||||
]);
|
||||
if (!directValueRules.has(rule)) return '';
|
||||
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
|
||||
}
|
||||
|
||||
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
|
||||
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
|
||||
if (direct) return direct;
|
||||
|
||||
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
|
||||
for (const text of candidates) {
|
||||
if (rule === 'bounce-easing') {
|
||||
const motion = extractMotionIgnoreValue(text);
|
||||
if (motion) return motion;
|
||||
continue;
|
||||
}
|
||||
|
||||
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
|
||||
if (primary) return cleanIgnoreValueDisplay(primary[1]);
|
||||
|
||||
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
|
||||
if (family) return cleanIgnoreValueDisplay(family[1]);
|
||||
|
||||
const google = text.match(/[?&]family=([^&:;\n]+)/i);
|
||||
if (google) {
|
||||
try {
|
||||
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
|
||||
} catch {
|
||||
return cleanIgnoreValueDisplay(google[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractMotionIgnoreValue(text) {
|
||||
const tailwind = text.match(/\banimate-bounce\b/i);
|
||||
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
|
||||
|
||||
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
|
||||
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
|
||||
|
||||
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
|
||||
if (animation) {
|
||||
const token = animation[1]
|
||||
.split(/[,\s]+/)
|
||||
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
||||
if (token) return cleanIgnoreValueDisplay(token);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function cleanIgnoreValueDisplay(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ')
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
|
||||
* config.local.json (per-developer) overrides config.json.
|
||||
*/
|
||||
export function getHookConsent(root) {
|
||||
let consent;
|
||||
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
|
||||
const hook = hookSection(safeReadJson(filePath));
|
||||
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
|
||||
}
|
||||
return consent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the per-developer decision to config.local.json, preserving any
|
||||
* sibling keys, and ensure the file is gitignored.
|
||||
*/
|
||||
export function setHookConsent(root, value) {
|
||||
const filePath = getLocalConfigPath(root);
|
||||
const existing = safeReadJson(filePath) || {};
|
||||
const hook = hookSection(existing) || {};
|
||||
const next = { ...existing, hook: { ...hook, consent: value } };
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
|
||||
ensureConfigGitExclude(root);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
|
||||
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
|
||||
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
|
||||
|
||||
/**
|
||||
* Add config.local.json to `.git/info/exclude` so a developer's decision is
|
||||
* never committed. Idempotent via marker comments. Best-effort; returns false
|
||||
* when there is no resolvable git dir.
|
||||
*/
|
||||
export function ensureConfigGitExclude(root) {
|
||||
try {
|
||||
const gitDir = resolveGitDir(root);
|
||||
if (!gitDir) return false;
|
||||
const target = join(gitDir, 'info', 'exclude');
|
||||
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
|
||||
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
|
||||
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
|
||||
let updated;
|
||||
if (markerRe.test(existing)) {
|
||||
updated = existing.replace(markerRe, block);
|
||||
} else {
|
||||
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
|
||||
updated = `${prefix}${block}\n`;
|
||||
}
|
||||
if (updated !== existing) {
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, updated);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGitDir(root) {
|
||||
const dotGit = join(root, '.git');
|
||||
if (!existsSync(dotGit)) return null;
|
||||
try {
|
||||
if (statSync(dotGit).isDirectory()) return dotGit;
|
||||
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
|
||||
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
|
||||
if (match) {
|
||||
const resolved = match[1].trim();
|
||||
return isAbsolute(resolved) ? resolved : join(root, resolved);
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -1,50 +1,52 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
export const CRITIQUE_DIR = 'critique';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
export function getImpeccableDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
const projectRoot = resolveProjectRoot(cwd, options);
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
getDesignSidecarPath(cwd, options),
|
||||
path.join(projectRoot, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
export function getLiveDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
export function getLiveConfigPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
const primary = getLiveConfigPath(cwd, { targetPath });
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
@@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
export function getLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function readLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
|
||||
@@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) {
|
||||
const filePath = getLiveServerPath(cwd, options);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'annotations');
|
||||
}
|
||||
|
||||
export function getCritiqueDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), CRITIQUE_DIR);
|
||||
export function getCritiqueDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR);
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
class TargetArgError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'TargetArgError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTargetPath(args = [], { strict = false } = {}) {
|
||||
let targetPath = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i]);
|
||||
if (arg === '--target' || arg === '-t') {
|
||||
const next = args[i + 1];
|
||||
if (next && !String(next).startsWith('-')) {
|
||||
targetPath = String(next);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--target=')) {
|
||||
const value = arg.slice('--target='.length);
|
||||
if (value) {
|
||||
targetPath = value;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export function parseTargetOptions(args = [], options = {}) {
|
||||
const targetPath = parseTargetPath(args, options);
|
||||
return targetPath ? { targetPath } : {};
|
||||
}
|
||||
@@ -57,7 +57,7 @@
|
||||
const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
|
||||
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint
|
||||
const PREFIX = 'impeccable-live';
|
||||
const PICK_CURSOR_CLASS = PREFIX + '-pick-cursor';
|
||||
const PICK_CURSOR_STYLE_ID = PREFIX + '-pick-cursor-style';
|
||||
const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000;
|
||||
const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({
|
||||
prefix: PREFIX,
|
||||
@@ -152,6 +152,7 @@
|
||||
let scrollLockTargetY = null;
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock';
|
||||
|
||||
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
@@ -1915,45 +1916,45 @@
|
||||
syncPageInteractionCursor();
|
||||
}
|
||||
|
||||
let pageInteractionCursorActive = false;
|
||||
|
||||
function ensurePickCursorStyle() {
|
||||
if (document.getElementById(PREFIX + '-pick-cursor-style')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = PREFIX + '-pick-cursor-style';
|
||||
/**
|
||||
* Drive the page-level pick / insert cursor through the textContent of one
|
||||
* injected <style>, never by mutating <html> (className or inline style).
|
||||
* Frameworks that server-render the <html>/<body> roots (Next.js App Router)
|
||||
* report a React 19 hydration mismatch when the client adds an attribute the
|
||||
* server HTML never emitted, so a `class`/inline `style` toggled on
|
||||
* `document.documentElement` trips "a tree hydrated but some attributes ...
|
||||
* didn't match" on the next Fast-Refresh re-render. Keying the cursor off a
|
||||
* stable-id <style> keeps the effect off the hydrated host elements (same
|
||||
* shape as the scroll-anchor lock). A falsy cursor clears the rule.
|
||||
*/
|
||||
function setPageInteractionCursor(cursor) {
|
||||
let style = document.getElementById(PICK_CURSOR_STYLE_ID);
|
||||
if (!cursor) {
|
||||
if (style) style.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = PICK_CURSOR_STYLE_ID;
|
||||
// Styles the host page, not the chrome - inside the adapter's shadow UI
|
||||
// root (uiAppendStyle's target) these selectors would match nothing.
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
}
|
||||
style.textContent =
|
||||
'html.' + PICK_CURSOR_CLASS + ' * { cursor: crosshair !important; }\n'
|
||||
+ 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"],\n'
|
||||
+ 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"] * { cursor: revert !important; }';
|
||||
// Styles the host page, not the chrome - inside the adapter's shadow UI
|
||||
// root (uiAppendStyle's target) these selectors would match nothing.
|
||||
document.head.appendChild(style);
|
||||
'* { cursor: ' + cursor + ' !important; }\n'
|
||||
+ '[id^="' + PREFIX + '"],\n'
|
||||
+ '[id^="' + PREFIX + '"] * { cursor: revert !important; }';
|
||||
}
|
||||
|
||||
/** Page-level cursor while pick or insert mode is targeting page elements. */
|
||||
function syncPageInteractionCursor() {
|
||||
const pickCursor = state === 'PICKING' && pickActive && !insertActive;
|
||||
let axisCursor = '';
|
||||
if (state === 'PICKING' && insertActive) {
|
||||
axisCursor = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : '';
|
||||
}
|
||||
|
||||
if (pickCursor) {
|
||||
ensurePickCursorStyle();
|
||||
document.documentElement.classList.add(PICK_CURSOR_CLASS);
|
||||
document.documentElement.style.cursor = '';
|
||||
pageInteractionCursorActive = true;
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.classList.remove(PICK_CURSOR_CLASS);
|
||||
if (axisCursor) {
|
||||
document.documentElement.style.cursor = axisCursor;
|
||||
pageInteractionCursorActive = true;
|
||||
} else if (pageInteractionCursorActive) {
|
||||
document.documentElement.style.cursor = '';
|
||||
pageInteractionCursorActive = false;
|
||||
let cursor = '';
|
||||
if (state === 'PICKING' && pickActive && !insertActive) {
|
||||
cursor = 'crosshair';
|
||||
} else if (state === 'PICKING' && insertActive && insertHoverAnchor) {
|
||||
cursor = cursorForInsertAxis(insertHoverAxis || 'column');
|
||||
}
|
||||
setPageInteractionCursor(cursor);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5815,10 +5816,22 @@
|
||||
|
||||
try { history.scrollRestoration = 'manual'; } catch {}
|
||||
|
||||
const prevHtmlAnchor = document.documentElement.style.overflowAnchor;
|
||||
const prevBodyAnchor = document.body.style.overflowAnchor;
|
||||
document.documentElement.style.overflowAnchor = 'none';
|
||||
document.body.style.overflowAnchor = 'none';
|
||||
// Suppress the browser's scroll-anchoring on the scroll root so it can't
|
||||
// fight our manual scroll correction. Apply this as a stylesheet rule, not
|
||||
// as inline `style` on <html>/<body>: those elements are server-rendered by
|
||||
// frameworks like Next.js App Router, and mutating their inline style makes
|
||||
// React 19 report a hydration mismatch on the next Fast-Refresh re-render.
|
||||
// A <style> rule has the same computed effect without touching any hydrated
|
||||
// element's attributes. Like the inline version, it is recreated on every
|
||||
// startScrollLock call, so reload survival (driven by the persisted scroll
|
||||
// key) is unaffected.
|
||||
let anchorLockStyle = document.getElementById(SCROLL_ANCHOR_LOCK_ID);
|
||||
if (!anchorLockStyle) {
|
||||
anchorLockStyle = document.createElement('style');
|
||||
anchorLockStyle.id = SCROLL_ANCHOR_LOCK_ID;
|
||||
anchorLockStyle.textContent = 'html,body{overflow-anchor:none !important;}';
|
||||
(document.head || document.documentElement).appendChild(anchorLockStyle);
|
||||
}
|
||||
|
||||
const correct = (why) => {
|
||||
scrollLockRaf = null;
|
||||
@@ -5853,8 +5866,7 @@
|
||||
|
||||
scrollLockAbort = new AbortController();
|
||||
scrollLockAbort.signal.addEventListener('abort', () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
document.getElementById(SCROLL_ANCHOR_LOCK_ID)?.remove();
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
// Track whether the most recent scroll came from a user gesture. We
|
||||
@@ -6489,10 +6501,13 @@
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isPageEditableElement(deepActive) && !isInlineEditActive(deepActive)) {
|
||||
return;
|
||||
}
|
||||
// While a contenteditable text-leaf is focused, let the browser handle
|
||||
// all keys except Escape. Escape cancels the current edit (restores
|
||||
// original text) and blurs without saving, staying in CONFIGURING.
|
||||
if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
|
||||
if (e.target.isContentEditable && isInlineEditActive(e.target)) {
|
||||
if (e.key !== 'Escape') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -8186,7 +8201,7 @@ void main() {
|
||||
let voiceInterimBase = '';
|
||||
/** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
|
||||
let voiceCtx = null;
|
||||
const PAGE_CHAT_COLLAPSED_W = '88px';
|
||||
const PAGE_CHAT_COLLAPSED_W = '104px';
|
||||
const PAGE_CHAT_PROCESSING_W = '76px';
|
||||
const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
|
||||
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
|
||||
@@ -8197,7 +8212,7 @@ void main() {
|
||||
const GLOBAL_BAR_SECTION_GAP = 8;
|
||||
const GLOBAL_BAR_INNER_GAP = 2;
|
||||
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
|
||||
const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
|
||||
const PAGE_CHAT_EXPANDED_MAX_W = 280;
|
||||
const ICON_PAGE_CHAT =
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
||||
const ICON_PAGE_VOICE =
|
||||
@@ -8277,6 +8292,52 @@ void main() {
|
||||
return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
|
||||
}
|
||||
|
||||
function globalBarModeToggles() {
|
||||
return [
|
||||
uiGetById(PREFIX + '-pick-toggle'),
|
||||
uiGetById(PREFIX + '-insert-toggle'),
|
||||
uiGetById(PREFIX + '-detect-toggle'),
|
||||
uiGetById(PREFIX + '-design-toggle'),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function applyGlobalBarLabelState(expandInactive, forceCollapse = false) {
|
||||
globalBarModeToggles().forEach((toggle) => {
|
||||
if (forceCollapse) toggle._collapseLabel?.(true);
|
||||
else if (expandInactive || toggle.dataset.active === 'true') toggle._expandLabel?.();
|
||||
else toggle._collapseLabel?.();
|
||||
});
|
||||
}
|
||||
|
||||
function syncGlobalBarExpandedLabels(expanded = globalBarEl?.matches(':hover')) {
|
||||
const expandInactive = !!(expanded && !pageChatExpanded);
|
||||
applyGlobalBarLabelState(expandInactive, pageChatExpanded);
|
||||
|
||||
if (expandInactive && globalBarEl && globalBarEl.scrollWidth > window.innerWidth - 16) {
|
||||
applyGlobalBarLabelState(false);
|
||||
}
|
||||
}
|
||||
|
||||
function pageChatCollapsedWidthPx() {
|
||||
const parsed = parseFloat(PAGE_CHAT_COLLAPSED_W);
|
||||
return Number.isFinite(parsed) ? parsed : 104;
|
||||
}
|
||||
|
||||
function pageChatExpandedWidth() {
|
||||
if (!pageChatEl || !globalBarEl) return PAGE_CHAT_EXPANDED_MAX_W + 'px';
|
||||
const currentChatWidth = pageChatEl.getBoundingClientRect().width || pageChatCollapsedWidthPx();
|
||||
const barWidth = Math.max(globalBarEl.getBoundingClientRect().width || 0, globalBarEl.scrollWidth || 0);
|
||||
const nonChatWidth = Math.max(0, barWidth - currentChatWidth);
|
||||
const available = window.innerWidth - 16 - nonChatWidth;
|
||||
const next = Math.max(pageChatCollapsedWidthPx(), Math.min(PAGE_CHAT_EXPANDED_MAX_W, available));
|
||||
return Math.round(next) + 'px';
|
||||
}
|
||||
|
||||
function syncPageChatExpandedWidth() {
|
||||
if (!pageChatEl || !pageChatExpanded) return;
|
||||
pageChatEl.style.width = pageChatExpandedWidth();
|
||||
}
|
||||
|
||||
function syncPageChatChrome() {
|
||||
if (!pageChatEl) return;
|
||||
const P = pageChatPalette();
|
||||
@@ -8312,6 +8373,21 @@ void main() {
|
||||
&& !steerLocked;
|
||||
}
|
||||
|
||||
function isPageEditableElement(el) {
|
||||
if (!el || own(el)) return false;
|
||||
if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return true;
|
||||
return !!el.isContentEditable;
|
||||
}
|
||||
|
||||
function isInlineEditActive(el) {
|
||||
return !!el && inlineEditRows.some((r) => r.el === el);
|
||||
}
|
||||
|
||||
function isPageEditableActive() {
|
||||
const active = activeElementDeep();
|
||||
return isPageEditableElement(active) && !isInlineEditActive(active);
|
||||
}
|
||||
|
||||
function pageHasHostTextSelection() {
|
||||
const sel = window.getSelection?.();
|
||||
if (!sel || sel.isCollapsed) return false;
|
||||
@@ -8325,6 +8401,7 @@ void main() {
|
||||
function shouldSteerAutoFocus() {
|
||||
return shouldFocusSteerChat()
|
||||
&& !steerFocusSuspended
|
||||
&& !isPageEditableActive()
|
||||
&& performance.now() >= steerFocusPauseUntil;
|
||||
}
|
||||
|
||||
@@ -8562,7 +8639,8 @@ void main() {
|
||||
if (!pageChatEl || !pageChatInput) return false;
|
||||
pageChatExpanded = true;
|
||||
pageChatEl.dataset.expanded = 'true';
|
||||
pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
|
||||
syncGlobalBarExpandedLabels(false);
|
||||
pageChatEl.style.width = pageChatExpandedWidth();
|
||||
pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
|
||||
pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED;
|
||||
if (pageChatHint) {
|
||||
@@ -8657,7 +8735,7 @@ void main() {
|
||||
pageChatEl.setAttribute('aria-label', 'Steer the page');
|
||||
pageChatExpanded = keepExpanded;
|
||||
pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
|
||||
pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
|
||||
pageChatEl.style.width = keepExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
|
||||
pageChatEl.style.cursor = 'pointer';
|
||||
if (pageChatInput) {
|
||||
pageChatInput.disabled = false;
|
||||
@@ -8971,6 +9049,7 @@ void main() {
|
||||
pageChatEl.dataset.expanded = 'false';
|
||||
pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
|
||||
pageChatEl.style.cursor = 'pointer';
|
||||
syncGlobalBarExpandedLabels(globalBarEl?.matches(':hover'));
|
||||
if (blur) {
|
||||
pageChatInput.blur();
|
||||
pageChatInput.style.pointerEvents = 'none';
|
||||
@@ -9270,6 +9349,7 @@ void main() {
|
||||
zIndex: Z.bar + 5,
|
||||
display: 'flex', alignItems: 'stretch',
|
||||
gap: '0',
|
||||
width: 'max-content',
|
||||
background: P.surface,
|
||||
border: '1px solid ' + P.border,
|
||||
borderRadius: '8px',
|
||||
@@ -9277,6 +9357,8 @@ void main() {
|
||||
fontFamily: FONT, fontSize: '12px', lineHeight: '1',
|
||||
opacity: '0',
|
||||
overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
});
|
||||
globalBarEl.id = PREFIX + '-global-bar';
|
||||
@@ -9325,6 +9407,7 @@ void main() {
|
||||
const inner = el('div', {
|
||||
display: 'flex', alignItems: 'center',
|
||||
padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
|
||||
flex: '0 0 auto',
|
||||
});
|
||||
inner.id = PREFIX + '-global-bar-inner';
|
||||
globalBarEl.appendChild(inner);
|
||||
@@ -9333,7 +9416,10 @@ void main() {
|
||||
function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
|
||||
const b = el('button', {
|
||||
position: 'relative',
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
minWidth: '30px',
|
||||
padding: '6px 8px', borderRadius: '7px',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
|
||||
@@ -9352,8 +9438,8 @@ void main() {
|
||||
if (!labelEl) return;
|
||||
labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
|
||||
};
|
||||
const collapse = () => {
|
||||
if (!labelEl || b.dataset.active === 'true') return;
|
||||
const collapse = (force = false) => {
|
||||
if (!labelEl || (!force && b.dataset.active === 'true')) return;
|
||||
labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
|
||||
};
|
||||
// Per-button hover only changes color (no layout). The label expand/
|
||||
@@ -9604,6 +9690,7 @@ void main() {
|
||||
width: '1px', height: '18px',
|
||||
background: P.hairline,
|
||||
margin: '0 4px 0 2px',
|
||||
flexShrink: '0',
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
@@ -9620,6 +9707,7 @@ void main() {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0', boxSizing: 'border-box',
|
||||
width: '24px', height: '24px', borderRadius: '6px',
|
||||
flexShrink: '0',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
|
||||
@@ -9632,16 +9720,16 @@ void main() {
|
||||
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
|
||||
inner.appendChild(exitBtn);
|
||||
|
||||
// Bar-level hover: expand every toggle's label at once; collapse on leave.
|
||||
// Bar-level hover: expand mode labels unless Steer is using the space.
|
||||
// Buttons with dataset.active="true" ignore collapse (their label stays).
|
||||
const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
|
||||
globalBarEl.addEventListener('mouseenter', () => {
|
||||
toggles.forEach((t) => t._expandLabel && t._expandLabel());
|
||||
syncGlobalBarExpandedLabels(true);
|
||||
syncPageChatExpandedWidth();
|
||||
schedulePendingDockPosition();
|
||||
setTimeout(schedulePendingDockPosition, 260);
|
||||
});
|
||||
globalBarEl.addEventListener('mouseleave', () => {
|
||||
toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
|
||||
syncGlobalBarExpandedLabels(false);
|
||||
schedulePendingDockPosition();
|
||||
setTimeout(schedulePendingDockPosition, 260);
|
||||
});
|
||||
@@ -9659,6 +9747,7 @@ void main() {
|
||||
pendingDockResizeObserver.observe(globalBarEl);
|
||||
}
|
||||
window.addEventListener('resize', positionPendingDock);
|
||||
window.addEventListener('resize', syncPageChatExpandedWidth);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
globalBarEl.style.opacity = '1';
|
||||
@@ -9705,9 +9794,7 @@ void main() {
|
||||
// If the bar is currently under the cursor, keep all labels expanded -
|
||||
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
|
||||
// would collapse its label while the user's mouse is still on the bar.
|
||||
if (globalBarEl && globalBarEl.matches(':hover')) {
|
||||
[pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
|
||||
}
|
||||
syncGlobalBarExpandedLabels(globalBarEl && globalBarEl.matches(':hover'));
|
||||
|
||||
if (detectBadge) {
|
||||
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
|
||||
@@ -9896,7 +9983,7 @@ void main() {
|
||||
// Remove detection overlays
|
||||
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
|
||||
setLiveState('IDLE');
|
||||
document.getElementById(PREFIX + '-pick-cursor-style')?.remove();
|
||||
document.getElementById(PICK_CURSOR_STYLE_ID)?.remove();
|
||||
window.__IMPECCABLE_LIVE_INIT__ = false;
|
||||
console.log('[impeccable] Live mode exited.');
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* CLI client for the live variant mode poll/reply protocol.
|
||||
*
|
||||
* Usage:
|
||||
* npx impeccable poll # Block until browser event, print JSON
|
||||
* npx impeccable poll --stream # Experimental: keep polling; one JSON line per event
|
||||
* npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly
|
||||
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
|
||||
* npx impeccable poll --reply <id> error "msg" # Reply with error
|
||||
* node <scripts_path>/live-poll.mjs # Block until browser event, print JSON
|
||||
* node <scripts_path>/live-poll.mjs --stream # Experimental: keep polling; one JSON line per event
|
||||
* node <scripts_path>/live-poll.mjs --timeout=600000 # Custom timeout (ms); default is long-poll friendly
|
||||
* node <scripts_path>/live-poll.mjs --reply <id> done # Reply "done" to event <id>
|
||||
* node <scripts_path>/live-poll.mjs --reply <id> error "msg" # Reply with error
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
@@ -15,6 +15,11 @@ import { fileURLToPath } from 'node:url';
|
||||
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
|
||||
// Absolute path to a sibling script in this skill's scripts dir, so runtime
|
||||
// error hints print a directly-runnable command instead of a placeholder.
|
||||
const SELF_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const scriptCmd = (name) => `node "${path.join(SELF_DIR, name)}"`;
|
||||
|
||||
// Node's built-in fetch (undici under the hood) enforces a 300s headers
|
||||
// timeout that can't be lowered per-request. We cap each request below
|
||||
// that ceiling and loop in `pollOnce` to synthesize a long poll without
|
||||
@@ -27,7 +32,7 @@ const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_ed
|
||||
function readServerInfo() {
|
||||
const record = readLiveServerInfo(process.cwd());
|
||||
if (!record) {
|
||||
console.error('No running live server found. Start one with: npx impeccable live');
|
||||
console.error(`No running live server found. Start one with: ${scriptCmd('live.mjs')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return record.info;
|
||||
@@ -82,7 +87,7 @@ export function parseReplyArgs(args) {
|
||||
}
|
||||
|
||||
function validateReplyArgs({ id, status }) {
|
||||
const usage = "Usage: npx impeccable poll --reply <id> <status> [--file path] [--data '<json>'] [message]";
|
||||
const usage = `Usage: ${scriptCmd('live-poll.mjs')} --reply <id> <status> [--file path] [--data '<json>'] [message]`;
|
||||
if (!id || id.startsWith('--')) {
|
||||
const err = new Error(`${usage}\nMissing event id after --reply.`);
|
||||
err.code = 'INVALID_REPLY_ARGS';
|
||||
@@ -283,11 +288,11 @@ export async function runPollStream(base, token, {
|
||||
function handlePollError(err) {
|
||||
if (err.code === 'AUTH_FAILED') {
|
||||
console.error(err.message);
|
||||
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
|
||||
console.error(`Try restarting: ${scriptCmd('live-server.mjs')} stop && ${scriptCmd('live.mjs')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (err.cause?.code === 'ECONNREFUSED') {
|
||||
console.error('Live server not running. Start one with: npx impeccable live');
|
||||
console.error(`Live server not running. Start one with: ${scriptCmd('live.mjs')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (err.code === 'ACK_TIMEOUT') {
|
||||
@@ -331,7 +336,7 @@ Harness note:
|
||||
const info = readServerInfo();
|
||||
const base = `http://localhost:${info.port}`;
|
||||
|
||||
// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [--data '<json>'] [message]
|
||||
// Reply mode: node <scripts_path>/live-poll.mjs --reply <id> <status> [--file path] [--data '<json>'] [message]
|
||||
if (args.includes('--reply')) {
|
||||
let reply;
|
||||
try {
|
||||
@@ -345,7 +350,7 @@ Harness note:
|
||||
await postReply(base, info.token, reply);
|
||||
} catch (err) {
|
||||
if (err.cause?.code === 'ECONNREFUSED') {
|
||||
console.error('Live server not running. Start one with: npx impeccable live');
|
||||
console.error(`Live server not running. Start one with: ${scriptCmd('live.mjs')}`);
|
||||
} else {
|
||||
console.error('Reply failed:', err.message);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './lib/design-parser.mjs';
|
||||
import { resolveContextDir } from './context.mjs';
|
||||
import { loadContext } from './context.mjs';
|
||||
import {
|
||||
assembleLiveBrowserScript,
|
||||
assertLiveBrowserScriptParts,
|
||||
@@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const PROJECT_CONTEXT = loadContext(process.cwd());
|
||||
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
|
||||
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
|
||||
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
|
||||
: null;
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
|
||||
@@ -371,10 +375,7 @@ function hasProjectContext() {
|
||||
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
||||
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
||||
// concern, surfaced by the design panel's own empty state.
|
||||
try {
|
||||
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
return !!PROJECT_CONTEXT.hasProduct;
|
||||
}
|
||||
|
||||
function statOrNull(filePath) {
|
||||
@@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdPath = DESIGN_MD_PATH;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from './context.mjs';
|
||||
import { parseTargetPath } from './lib/target-args.mjs';
|
||||
|
||||
export function resolveLiveTarget(cwd = process.cwd(), args = []) {
|
||||
const originalCwd = path.resolve(cwd);
|
||||
let targetPath = null;
|
||||
try {
|
||||
targetPath = parseTargetPath(args, { strict: true });
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const absoluteTargetPath = targetPath
|
||||
? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath)
|
||||
: null;
|
||||
const projectRoot = targetPath
|
||||
? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath })
|
||||
: originalCwd;
|
||||
return {
|
||||
originalCwd,
|
||||
projectRoot,
|
||||
targetPath,
|
||||
absoluteTargetPath,
|
||||
targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {},
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* CLI helper: find an element in source and wrap it in a variant container.
|
||||
*
|
||||
* Usage:
|
||||
* npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
|
||||
* node <scripts_path>/live-wrap.mjs --id SESSION_ID --count N --query "hero-combined-left" [--file path]
|
||||
*
|
||||
* Searches project files for the element matching the query (class name, ID, or
|
||||
* text snippet), wraps it with the variant scaffolding, and prints the file path
|
||||
|
||||
@@ -21,14 +21,16 @@ import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './context.mjs';
|
||||
import { loadContext, resolveTargetSelection } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { resolveLiveTarget } from './live-target.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
const liveTarget = resolveLiveTarget(process.cwd(), args);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: node live.mjs
|
||||
@@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command:
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
{ ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }
|
||||
|
||||
On target_selection_required, prints:
|
||||
{ ok: false, error: "target_selection_required", targetCandidates }
|
||||
|
||||
On config_missing, prints:
|
||||
{ ok: false, error: "config_missing", configPath, hint }
|
||||
|
||||
The agent should then:
|
||||
1. If config_missing, create the config and re-run this script
|
||||
2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
3. Enter the poll loop: node live-poll.mjs`);
|
||||
1. If target_selection_required, ask which app to use and rerun from that child cwd
|
||||
2. If config_missing, create the config and re-run this script
|
||||
3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
4. Enter the poll loop: node live-poll.mjs`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
if (targetSelection) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'target_selection_required',
|
||||
...targetSelection,
|
||||
hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.',
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
const activeCwd = ctx.projectRoot;
|
||||
const outputTargetPath = liveTarget.targetPath || null;
|
||||
|
||||
const missingContext = missingLiveContext(ctx);
|
||||
if (missingContext.length > 0) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'context_missing',
|
||||
missing: missingContext,
|
||||
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 1. Check config (fail fast if missing — no point starting anything else)
|
||||
const checkOut = runScript('live-inject.mjs', ['--check']);
|
||||
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
|
||||
const checkResult = safeParse(checkOut);
|
||||
if (!checkResult || !checkResult.ok) {
|
||||
console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut }));
|
||||
console.log(JSON.stringify({
|
||||
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 2. Start server (or reuse existing)
|
||||
const serverInfo = ensureServerRunning();
|
||||
const serverInfo = ensureServerRunning(activeCwd);
|
||||
if (!serverInfo) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Inject the script tag at the current port
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]);
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd });
|
||||
const injectResult = safeParse(injectOut);
|
||||
if (!injectResult || !injectResult.ok) {
|
||||
console.log(JSON.stringify({
|
||||
@@ -80,22 +123,23 @@ The agent should then:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Load PRODUCT.md + DESIGN.md context.
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// 4. Compute drift-heal: compare resolved inject targets against the
|
||||
// project's HTML files. Orphans are HTML files not covered by config.
|
||||
// Warning only — the agent decides whether to act.
|
||||
const resolvedFiles = resolveFiles(process.cwd(), checkResult.config);
|
||||
const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config);
|
||||
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
|
||||
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
// 5. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: resolvedFiles,
|
||||
liveConfigPath: checkResult.path,
|
||||
configDrift: drift,
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -105,6 +149,13 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function missingLiveContext(ctx) {
|
||||
const missing = [];
|
||||
if (!ctx.hasProduct) missing.push('PRODUCT.md');
|
||||
if (!ctx.hasDesign) missing.push('DESIGN.md');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
@@ -201,11 +252,11 @@ function globToRegex(pattern) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runScript(name, args) {
|
||||
function runScript(name, args, options = {}) {
|
||||
const scriptPath = path.join(__dirname, name);
|
||||
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
||||
try {
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 });
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
|
||||
} catch (err) {
|
||||
// execSync throws on non-zero exit; return stdout if any
|
||||
return err.stdout || err.message || '';
|
||||
@@ -219,10 +270,10 @@ function safeParse(out) {
|
||||
/**
|
||||
* Return { pid, port, token } for the running live server, starting one if needed.
|
||||
*/
|
||||
function ensureServerRunning() {
|
||||
function ensureServerRunning(cwd = process.cwd()) {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
const existing = readLiveServerInfo(cwd)?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
@@ -232,7 +283,7 @@ function ensureServerRunning() {
|
||||
} catch { /* no PID file */ }
|
||||
|
||||
// Start a new server
|
||||
const out = runScript('live-server.mjs', ['--background']);
|
||||
const out = runScript('live-server.mjs', ['--background'], { cwd });
|
||||
return safeParse(out);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: impeccable
|
||||
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
|
||||
version: 3.7.0
|
||||
version: 3.8.0
|
||||
license: Apache 2.0
|
||||
---
|
||||
|
||||
@@ -11,7 +11,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
|
||||
|
||||
You MUST do these steps before proceeding:
|
||||
|
||||
1. Run `node .cursor/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
1. Run `node .cursor/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .cursor/skills/impeccable/scripts/context.mjs --target <path>` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code and Codex use `PostToolUse` and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project).
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
|
||||
On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands.
|
||||
|
||||
@@ -51,7 +51,7 @@ Prefer the narrowest exception:
|
||||
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
|
||||
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
|
||||
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
|
||||
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
|
||||
- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable <rule>` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it.
|
||||
|
||||
Example value-specific exception:
|
||||
|
||||
@@ -81,8 +81,8 @@ node .cursor/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
|
||||
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
|
||||
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code and Codex do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR
|
||||
|
||||
Execute in order. No step skipped, no step reordered.
|
||||
|
||||
1. `live.mjs`: boot.
|
||||
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .cursor/skills/impeccable/scripts/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
|
||||
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* init flow.
|
||||
*
|
||||
* Path resolution (first match wins):
|
||||
* 1. cwd, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. .agents/context/ then docs/
|
||||
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. Active project .agents/context/ then docs/
|
||||
* 3. Monorepo root context, using the same order, as a per-file fallback
|
||||
* 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* escape hatch, only consulted when defaults are empty
|
||||
* 4. cwd as a "nothing found" default
|
||||
* 5. Active project root as a "nothing found" default
|
||||
*
|
||||
* `resolveContextDir()` and `loadContext()` are also exported for the
|
||||
* server-side scripts (live.mjs, live-server.mjs) that need the structured
|
||||
@@ -19,10 +20,25 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.svelte-kit',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'coverage',
|
||||
]);
|
||||
|
||||
// ─── Update check ──────────────────────────────────────────────────────────
|
||||
// Piggyback a lightweight skill-version check on the once-per-session boot.
|
||||
@@ -38,41 +54,623 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o
|
||||
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
|
||||
const FETCH_TIMEOUT_MS = 1200;
|
||||
|
||||
export function resolveContextDir(cwd = process.cwd()) {
|
||||
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return cwd;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(cwd, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (envDir && envDir.trim()) {
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
return cwd;
|
||||
export function resolveContextDir(cwd = process.cwd(), options = {}) {
|
||||
return resolveContext(cwd, options).contextDir;
|
||||
}
|
||||
|
||||
export function loadContext(cwd = process.cwd()) {
|
||||
const contextDir = resolveContextDir(cwd);
|
||||
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
|
||||
const designPath = firstExisting(contextDir, DESIGN_NAMES);
|
||||
export function loadContext(cwd = process.cwd(), options = {}) {
|
||||
const resolved = resolveContext(cwd, options);
|
||||
const absCwd = path.resolve(cwd);
|
||||
const productPath = resolved.productPath;
|
||||
const designPath = resolved.designPath;
|
||||
const product = productPath ? safeRead(productPath) : null;
|
||||
const design = designPath ? safeRead(designPath) : null;
|
||||
return {
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
productPath: productPath ? path.relative(cwd, productPath) : null,
|
||||
productPath: productPath ? path.relative(absCwd, productPath) : null,
|
||||
hasDesign: !!design,
|
||||
design,
|
||||
designPath: designPath ? path.relative(cwd, designPath) : null,
|
||||
contextDir,
|
||||
designPath: designPath ? path.relative(absCwd, designPath) : null,
|
||||
contextDir: resolved.contextDir,
|
||||
productContextDir: productPath ? path.dirname(productPath) : null,
|
||||
designContextDir: designPath ? path.dirname(designPath) : null,
|
||||
projectRoot: resolved.projectRoot,
|
||||
repoRoot: resolved.repoRoot,
|
||||
isMonorepo: resolved.isMonorepo,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveContext(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const project = resolveProject(absCwd, options);
|
||||
const projectContextDir = resolveLocalContextDir(project.projectRoot);
|
||||
const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
|
||||
? resolveLocalContextDir(project.repoRoot)
|
||||
: null;
|
||||
|
||||
let productPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
|
||||
let designPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
|
||||
|
||||
let envContextDir = null;
|
||||
if (!productPath && !designPath) {
|
||||
envContextDir = resolveEnvContextDir(absCwd);
|
||||
if (envContextDir) {
|
||||
productPath = firstExisting(envContextDir, PRODUCT_NAMES);
|
||||
designPath = firstExisting(envContextDir, DESIGN_NAMES);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contextDir: productPath
|
||||
? path.dirname(productPath)
|
||||
: designPath
|
||||
? path.dirname(designPath)
|
||||
: envContextDir || project.projectRoot,
|
||||
productPath,
|
||||
designPath,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
isMonorepo: project.isMonorepo,
|
||||
targetDir: project.targetDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
|
||||
return resolveProject(cwd, options).projectRoot;
|
||||
}
|
||||
|
||||
export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
|
||||
if (hasTargetOption(options)) return null;
|
||||
const project = resolveProject(cwd);
|
||||
if (
|
||||
!project.isMonorepo
|
||||
|| !project.projectRoot
|
||||
|| !project.repoRoot
|
||||
|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const targetCandidates = discoverTargetCandidates(project.repoRoot);
|
||||
// No discoverable child apps (e.g. `workspaces: ["."]`, a root-only workspace,
|
||||
// or a marker file with no apps/packages children): there is nothing to choose,
|
||||
// so treat the repo root as the active project rather than blocking on an empty
|
||||
// selection prompt that the user cannot answer.
|
||||
if (targetCandidates.length === 0) return null;
|
||||
return {
|
||||
targetPath: null,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
targetCandidates,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProject(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const targetDir = resolveTargetDir(absCwd, options);
|
||||
let repoRoot = findMonorepoRoot(targetDir);
|
||||
if (!repoRoot && targetDir !== absCwd) {
|
||||
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
||||
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
||||
repoRoot = cwdRepoRoot;
|
||||
}
|
||||
}
|
||||
if (!repoRoot) {
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: absCwd,
|
||||
repoRoot: absCwd,
|
||||
isMonorepo: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
|
||||
repoRoot,
|
||||
isMonorepo: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isPathInside(candidate, root) {
|
||||
const rel = path.relative(root, candidate);
|
||||
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function resolveLocalContextDir(root) {
|
||||
if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return root;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(root, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEnvContextDir(cwd) {
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (!envDir || !envDir.trim()) return null;
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
function findMonorepoRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === homeDir) return null;
|
||||
// isMonorepoRoot is checked before hasGitBoundary on purpose: a workspace
|
||||
// root that also carries its own .git is still recognized. The trade-off is
|
||||
// deliberate — a directory with a monorepo *marker* but no workspace patterns
|
||||
// and no apps/packages children is not a monorepo root, so its .git stops
|
||||
// traversal and a further-up root is not searched. The nested .git is treated
|
||||
// as an independent project boundary, which is the intended isolation.
|
||||
if (isMonorepoRoot(dir)) return dir;
|
||||
if (hasGitBoundary(dir)) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return hasFallbackWorkspaceChildren(dir);
|
||||
}
|
||||
|
||||
function hasGitBoundary(dir) {
|
||||
return fs.existsSync(path.join(dir, '.git'));
|
||||
}
|
||||
|
||||
function hasFallbackWorkspaceChildren(dir) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(dir, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function discoverTargetCandidates(repoRoot) {
|
||||
const roots = new Map();
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
for (const pattern of patterns) {
|
||||
for (const root of discoverRootsForPattern(repoRoot, pattern)) {
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(repoRoot, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const root = path.join(base, entry.name);
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots.entries()]
|
||||
.filter(([rel]) => rel && !rel.startsWith('..'))
|
||||
// Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot
|
||||
// sends an excluded package back to the repo root, so an excluded folder must not appear as a
|
||||
// selectable target — choosing it would silently resolve to the root instead.
|
||||
.filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([rel, root]) => {
|
||||
const targetExample = findTargetExample(repoRoot, root);
|
||||
return {
|
||||
name: path.basename(root),
|
||||
path: rel,
|
||||
targetExample,
|
||||
...resolveCandidateContextSummary(repoRoot, root, targetExample),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
|
||||
const ctx = resolveContext(repoRoot, { targetPath });
|
||||
return {
|
||||
productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
|
||||
productPath: contextSourcePath(ctx.productPath, repoRoot),
|
||||
designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
|
||||
designPath: contextSourcePath(ctx.designPath, repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
// Selection candidates surface one of four statuses: 'child' (a canonical
|
||||
// PRODUCT.md/DESIGN.md directly in the app root), 'inherited' (resolved from the
|
||||
// repo root in a monorepo), 'missing' (no file found), and 'fallback'. 'fallback'
|
||||
// intentionally covers two non-canonical locations: a file inside the project
|
||||
// root but in a subdirectory (FALLBACK_DIRS, e.g. `.agents/context/`), and a file
|
||||
// outside both the project and repo roots (IMPECCABLE_CONTEXT_DIR override).
|
||||
function contextSourceStatus(filePath, repoRoot, projectRoot) {
|
||||
if (!filePath) return 'missing';
|
||||
const absPath = path.resolve(filePath);
|
||||
const absProjectRoot = path.resolve(projectRoot);
|
||||
const absRepoRoot = path.resolve(repoRoot);
|
||||
if (isPathInsideOrEqual(absPath, absProjectRoot)) {
|
||||
return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
|
||||
}
|
||||
if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
|
||||
return 'inherited';
|
||||
}
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
function contextSourcePath(filePath, repoRoot) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(repoRoot, filePath);
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
return rel.split(path.sep).join('/');
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function discoverRootsForPattern(repoRoot, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return [];
|
||||
const segments = pattern.split('/').filter(Boolean);
|
||||
if (!segments.length) return [];
|
||||
const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
|
||||
const base = path.join(repoRoot, ...literalPrefix);
|
||||
if (!fs.existsSync(base)) return [];
|
||||
if (segments.includes('**')) {
|
||||
const packageRoots = [];
|
||||
walkDirs(base, (dir) => {
|
||||
if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
|
||||
});
|
||||
if (packageRoots.length) return packageRoots;
|
||||
return directChildDirs(base);
|
||||
}
|
||||
return expandSimplePattern(repoRoot, segments);
|
||||
}
|
||||
|
||||
function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
|
||||
if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
|
||||
const segment = patternSegments[index];
|
||||
if (!segment.includes('*')) {
|
||||
return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const roots = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
if (!segmentMatches(segment, entry.name)) continue;
|
||||
roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function directChildDirs(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function walkDirs(root, visit) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const dir = path.join(root, entry.name);
|
||||
visit(dir);
|
||||
walkDirs(dir, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function isCandidateProjectRoot(dir) {
|
||||
return !!(
|
||||
fs.existsSync(path.join(dir, 'package.json'))
|
||||
|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'src'))
|
||||
|| fs.existsSync(path.join(dir, 'app'))
|
||||
|| fs.existsSync(path.join(dir, 'pages'))
|
||||
|| fs.existsSync(path.join(dir, 'public'))
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnoredWorkspaceDiscoveryDir(name) {
|
||||
return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
|
||||
}
|
||||
|
||||
function findTargetExample(repoRoot, projectRoot) {
|
||||
const examples = [
|
||||
'src/App.jsx',
|
||||
'src/App.tsx',
|
||||
'src/main.jsx',
|
||||
'src/main.tsx',
|
||||
'src/index.jsx',
|
||||
'src/index.ts',
|
||||
'app/page.tsx',
|
||||
'pages/index.tsx',
|
||||
'public/index.html',
|
||||
];
|
||||
for (const rel of examples) {
|
||||
const abs = path.join(projectRoot, rel);
|
||||
if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
|
||||
}
|
||||
return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
|
||||
const rel = path.relative(repoRoot, targetDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
|
||||
if (!excluded) {
|
||||
for (const pattern of patterns) {
|
||||
const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
|
||||
if (projectRoot) return projectRoot;
|
||||
}
|
||||
}
|
||||
if (excluded) return repoRoot;
|
||||
if (
|
||||
relSegments.length >= 2
|
||||
&& MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
|
||||
) {
|
||||
return path.join(repoRoot, relSegments[0], relSegments[1]);
|
||||
}
|
||||
const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
|
||||
if (nearest) return nearest;
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function isExcludedByWorkspacePattern(relSegments, patterns) {
|
||||
return patterns.some((rawPattern) => {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern.startsWith('!')) return false;
|
||||
return workspacePatternMatchesRel(pattern.slice(1), relSegments);
|
||||
});
|
||||
}
|
||||
|
||||
function nearestProjectLikeRoot(repoRoot, targetDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop) {
|
||||
if (
|
||||
firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'package.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(stopDir || repoRoot);
|
||||
const root = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInsideOrEqual(candidate, root) {
|
||||
return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
|
||||
}
|
||||
|
||||
function workspacePatternMatchesRel(pattern, relSegments) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return false;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(repoRoot) {
|
||||
return [
|
||||
...readPackageWorkspaces(repoRoot),
|
||||
...readPnpmWorkspaces(repoRoot),
|
||||
...readLernaWorkspaces(repoRoot),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function readPackageWorkspaces(repoRoot) {
|
||||
const pkg = readJson(path.join(repoRoot, 'package.json'));
|
||||
const workspaces = pkg?.workspaces;
|
||||
if (Array.isArray(workspaces)) return workspaces;
|
||||
if (Array.isArray(workspaces?.packages)) return workspaces.packages;
|
||||
return [];
|
||||
}
|
||||
|
||||
function readLernaWorkspaces(repoRoot) {
|
||||
const lerna = readJson(path.join(repoRoot, 'lerna.json'));
|
||||
return Array.isArray(lerna?.packages) ? lerna.packages : [];
|
||||
}
|
||||
|
||||
function readPnpmWorkspaces(repoRoot) {
|
||||
try {
|
||||
const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
|
||||
const patterns = [];
|
||||
let inPackages = false;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const trimmed = stripYamlInlineComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flowMatch) {
|
||||
patterns.push(...parseYamlFlowList(flowMatch[1]));
|
||||
inPackages = false;
|
||||
continue;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
if (inPackages) {
|
||||
const match = trimmed.match(/^-\s*(.+)$/);
|
||||
if (match) patterns.push(unquoteYamlValue(match[1]));
|
||||
}
|
||||
}
|
||||
return patterns;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stripYamlInlineComment(line) {
|
||||
let quote = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#' && !quote) return line.slice(0, i);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseYamlFlowList(body) {
|
||||
const items = [];
|
||||
let quote = null;
|
||||
let current = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',' && !quote) {
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
return items;
|
||||
}
|
||||
|
||||
function unquoteYamlValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return null;
|
||||
const patternSegments = pattern.split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return null;
|
||||
if (patternSegments.includes('**')) {
|
||||
return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return null;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
|
||||
}
|
||||
return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
|
||||
}
|
||||
|
||||
function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return null;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
|
||||
}
|
||||
const prefixDir = path.join(repoRoot, ...literalPrefix);
|
||||
const targetDir = path.join(repoRoot, ...relSegments);
|
||||
const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
|
||||
if (packageRoot) return packageRoot;
|
||||
return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
|
||||
}
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
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 firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
@@ -89,6 +687,10 @@ function safeRead(p) {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
|
||||
* for a `## Register` section and reading the first non-empty line that
|
||||
@@ -233,7 +835,24 @@ async function computeUpdateDirective(now = Date.now()) {
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
let cliOptions;
|
||||
try {
|
||||
cliOptions = parseCliOptions(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -244,6 +863,10 @@ async function cli() {
|
||||
'Stop the current task, load reference/init.md, and follow its ' +
|
||||
'instructions to write PRODUCT.md before resuming.',
|
||||
];
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
@@ -252,6 +875,10 @@ async function cli() {
|
||||
if (ctx.hasDesign) {
|
||||
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
|
||||
}
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
const register = extractRegister(ctx.product);
|
||||
const next = register
|
||||
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
|
||||
@@ -261,6 +888,60 @@ async function cli() {
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
return parseTargetOptions(args, { strict: true });
|
||||
}
|
||||
|
||||
function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
|
||||
const targetPath = hasTargetOption(options) ? options.targetPath : null;
|
||||
return `RESOLVED_CONTEXT:\n${JSON.stringify({
|
||||
targetPath,
|
||||
...(targetPath ? { targetExists } : {}),
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2)}`;
|
||||
}
|
||||
|
||||
function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
|
||||
if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
|
||||
return !!(
|
||||
ctx.isMonorepo
|
||||
&& (!targetProvided || targetExists === false)
|
||||
&& ctx.projectRoot
|
||||
&& ctx.repoRoot
|
||||
&& path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
|
||||
);
|
||||
}
|
||||
|
||||
function buildMissingTargetDirective() {
|
||||
const script = process.argv[1] || 'context.mjs';
|
||||
return (
|
||||
'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
|
||||
'If the user named a file, route, or child app, do not answer from this output. ' +
|
||||
`Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
|
||||
);
|
||||
}
|
||||
|
||||
function buildTargetSelectionDirective(selection) {
|
||||
return (
|
||||
`TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
|
||||
'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' +
|
||||
'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
|
||||
'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
|
||||
);
|
||||
}
|
||||
|
||||
// Run cli() only when this module is the entry point. Compare realpaths
|
||||
// rather than endsWith(): a loose suffix match also fires for unrelated
|
||||
// scripts like `load-context.mjs`, and realpath tolerates symlinked
|
||||
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
// Output formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatFindingSummary(count) {
|
||||
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
|
||||
}
|
||||
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
|
||||
@@ -39,7 +43,7 @@ function formatFindings(findings, jsonMode) {
|
||||
out.push(` → ${item.description}`);
|
||||
}
|
||||
}
|
||||
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
|
||||
out.push(`\n${formatFindingSummary(findings.length)}`);
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
@@ -86,9 +90,12 @@ 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
|
||||
--gpt Also report GPT-specific provider tells (off by default)
|
||||
--gemini Also report Gemini-specific provider tells (off by default)
|
||||
--no-config Do not apply project config, detector ignores, or DESIGN.md
|
||||
--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
|
||||
--help Show this help message
|
||||
|
||||
@@ -97,6 +104,14 @@ Project config:
|
||||
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:
|
||||
<!-- impeccable-disable overused-font -- exported brand doc -->
|
||||
.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.)
|
||||
@@ -118,6 +133,7 @@ async function detectCli() {
|
||||
});
|
||||
if (args[0] === 'detect') args = args.slice(1);
|
||||
const jsonMode = args.includes('--json');
|
||||
const quietMode = args.includes('--quiet');
|
||||
const helpMode = args.includes('--help');
|
||||
// --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
|
||||
@@ -137,7 +153,12 @@ async function detectCli() {
|
||||
if (args.includes('--gemini')) providers.push('gemini');
|
||||
const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false;
|
||||
const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null;
|
||||
const scanOptions = designSystem ? { providers, designSystem } : { providers };
|
||||
// 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 scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled };
|
||||
if (designSystem) scanOptions.designSystem = designSystem;
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
@@ -169,8 +190,8 @@ async function detectCli() {
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
|
||||
if (!jsonMode) {
|
||||
// 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);
|
||||
@@ -200,7 +221,7 @@ async function detectCli() {
|
||||
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) {
|
||||
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` +
|
||||
@@ -258,6 +279,7 @@ async function detectCli() {
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { GENERIC_FONTS } from '../../shared/constants.mjs';
|
||||
import { isNeutralColor } from '../../shared/color.mjs';
|
||||
import { checkSourceDesignSystem } from '../../design-system.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { filterByProviders } from '../../registry/antipatterns.mjs';
|
||||
import { profileFindings, profileStep } from '../../profile/profiler.mjs';
|
||||
@@ -37,10 +39,11 @@ function shouldRunPageAnalyzers(content, filePath) {
|
||||
}
|
||||
|
||||
function isNeutralBorderColor(str) {
|
||||
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
|
||||
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;
|
||||
const c = m[1].toLowerCase();
|
||||
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
|
||||
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
|
||||
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
|
||||
if (hex) {
|
||||
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
|
||||
@@ -57,10 +60,10 @@ function isNeutralBorderColor(str) {
|
||||
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 >= 1 : n >= 4; },
|
||||
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 >= 1 : n >= 3; },
|
||||
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,
|
||||
@@ -547,7 +550,10 @@ function detectText(content, filePath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return filterByProviders(deduped, options?.providers);
|
||||
const byProvider = filterByProviders(deduped, options?.providers);
|
||||
// Inline `impeccable-disable*` waivers travel with the file; honor them unless
|
||||
// explicitly bypassed (`--no-config` / `--no-inline-ignores`).
|
||||
return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content);
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
mergeDesignSystemFindings,
|
||||
} from '../../design-system.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
||||
import {
|
||||
@@ -223,7 +224,11 @@ async function detectHtml(filePath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return filterByProviders(findings, options.providers);
|
||||
const byProvider = filterByProviders(findings, options.providers);
|
||||
// Static-HTML findings carry no line number, so only whole-file
|
||||
// `impeccable-disable` directives apply here — exactly the standalone-document
|
||||
// waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`.
|
||||
return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html);
|
||||
}
|
||||
|
||||
export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml };
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Inline, in-file ignore directives — eslint-disable-style waivers that live at
|
||||
* the point they apply and travel with the artifact instead of (or alongside)
|
||||
* an ignore in `.impeccable/config.json`.
|
||||
*
|
||||
* A config ignore is the right default for repo-wide policy. This complements it
|
||||
* for the one case config can't cover: a waiver that belongs to a single file and
|
||||
* needs to follow that file when it leaves the repo — a generated/exported
|
||||
* standalone document, an emailed HTML file, a snippet scanned out of context.
|
||||
*
|
||||
* Comment-syntax-agnostic: the directive is a raw token matched anywhere on a
|
||||
* line, so the same marker works across every comment style impeccable scans —
|
||||
* `//`, `/* *\/`, `<!-- -->`, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers
|
||||
* are stripped before the rule list is parsed.
|
||||
*
|
||||
* Syntax (reason optional; eslint `--` or biome `:` separator):
|
||||
*
|
||||
* impeccable-disable <rule>[, <rule>...] [-- reason] whole file
|
||||
* impeccable-disable-line <rule>... [-- reason] the same line
|
||||
* impeccable-disable-next-line <rule>... [-- reason] the following line
|
||||
* impeccable-disable bare / `*` = every rule
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* <!-- impeccable-disable overused-font -- exported brand doc, font is first-party -->
|
||||
* .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/
|
||||
* // impeccable-disable-next-line bounce-easing: intentional playful affordance
|
||||
*
|
||||
* Behavior is suppression, for parity with config ignores: a matched directive
|
||||
* drops the finding. The inline reason is self-documenting in the diff; it is not
|
||||
* required and is discarded at scan time (only used here to keep reason words out
|
||||
* of the parsed rule list).
|
||||
*/
|
||||
|
||||
const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi;
|
||||
|
||||
// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't
|
||||
// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the
|
||||
// space before the closer. `--+>` covers `-->` and any longer dash run.
|
||||
const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/;
|
||||
|
||||
function normalizeRule(token) {
|
||||
return String(token || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
// Split the directive remainder into rule tokens, dropping any human reason that
|
||||
// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever
|
||||
// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:`
|
||||
// are unambiguous separators.
|
||||
function parseRuleList(remainder) {
|
||||
let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim();
|
||||
// Cut off a human reason at the first `--` (eslint) or `:` (biome) separator.
|
||||
const reasonSep = text.match(/\s*(?:--+|:)\s*/);
|
||||
if (reasonSep) text = text.slice(0, reasonSep.index);
|
||||
const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean);
|
||||
if (tokens.length === 0 || tokens.includes('*')) return ['*'];
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function addRules(set, rules) {
|
||||
for (const rule of rules) set.add(rule);
|
||||
}
|
||||
|
||||
function getSet(map, key) {
|
||||
let set = map.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
map.set(key, set);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse every inline ignore directive in a file's raw text.
|
||||
*
|
||||
* Returns sets keyed by the 1-based line the directive *targets* so matching is a
|
||||
* direct lookup:
|
||||
* - file: rules disabled for the whole file
|
||||
* - line: line -> rules disabled on that exact line (disable-line)
|
||||
* - nextLine: line -> rules disabled on that line (disable-next-line on line-1)
|
||||
*
|
||||
* `*` in any set means "every rule".
|
||||
*/
|
||||
function parseInlineIgnores(content) {
|
||||
const result = { file: new Set(), line: new Map(), nextLine: new Map() };
|
||||
const text = typeof content === 'string' ? content : '';
|
||||
// Cheap bail-out: the substring must be present for any directive to exist.
|
||||
// Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`).
|
||||
if (!/impeccable-disable/i.test(text)) return result;
|
||||
|
||||
// Split on `\n` only, exactly as detectText numbers lines, so directive line
|
||||
// keys line up with finding `line` values (incl. on `\r`-only line endings).
|
||||
// The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is
|
||||
// never captured into the rule list.
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
DIRECTIVE_RE.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) {
|
||||
const variant = m[1].toLowerCase();
|
||||
const rules = parseRuleList(m[2]);
|
||||
if (variant === 'disable') {
|
||||
addRules(result.file, rules);
|
||||
} else if (variant === 'disable-line') {
|
||||
addRules(getSet(result.line, i + 1), rules);
|
||||
} else {
|
||||
// disable-next-line on line i+1 targets line i+2.
|
||||
addRules(getSet(result.nextLine, i + 2), rules);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function setMatches(set, rule) {
|
||||
return Boolean(set) && (set.has('*') || set.has(rule));
|
||||
}
|
||||
|
||||
function isInlineIgnored(finding, directives) {
|
||||
const rule = normalizeRule(finding && finding.antipattern);
|
||||
if (!rule) return false;
|
||||
if (setMatches(directives.file, rule)) return true;
|
||||
const line = Number(finding && finding.line) || 0;
|
||||
if (line > 0) {
|
||||
if (setMatches(directives.line.get(line), rule)) return true;
|
||||
if (setMatches(directives.nextLine.get(line), rule)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasDirectives(directives) {
|
||||
return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop findings waived by an inline directive in the same file's source text.
|
||||
* Findings without a usable line number (e.g. static-HTML page-level findings)
|
||||
* are only matched by whole-file directives — which is the standalone-document
|
||||
* case this primitive exists for.
|
||||
*/
|
||||
function applyInlineIgnores(findings, content) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return findings;
|
||||
const directives = parseInlineIgnores(content);
|
||||
if (!hasDirectives(directives)) return findings;
|
||||
return findings.filter((finding) => !isInlineIgnored(finding, directives));
|
||||
}
|
||||
|
||||
export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored };
|
||||
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
|
||||
// manifest is honored by the CLI (once committed to the default branch) and
|
||||
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
|
||||
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
|
||||
provider: '.github',
|
||||
skillRel: '.github/skills/impeccable',
|
||||
destRel: '.github/hooks/impeccable.json',
|
||||
manifest: () => ({
|
||||
version: 1,
|
||||
hooks: {
|
||||
postToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
matcher: 'edit|create|apply_patch',
|
||||
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
|
||||
timeoutSec: TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function readRawConfigFile(filePath) {
|
||||
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
|
||||
// `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;
|
||||
|
||||
@@ -959,13 +959,114 @@ export function resolveTargetFiles(event, projectCwd) {
|
||||
export function resolveHarness(env = {}, event = null) {
|
||||
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
|
||||
if (explicit === 'cursor') return 'cursor';
|
||||
if (explicit === 'github') return 'github';
|
||||
if (explicit === 'claude' || explicit === 'codex') return 'claude';
|
||||
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
|
||||
// has no `tool_name`/`tool_input`. That shape is the discriminator.
|
||||
if (event && typeof event === 'object'
|
||||
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
|
||||
&& event.tool_name === undefined && event.tool_input === undefined) {
|
||||
return 'github';
|
||||
}
|
||||
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
|
||||
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
|
||||
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
|
||||
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
|
||||
// normalizeGitHubEvent). The detector reads the file from disk after the tool
|
||||
// ran, so only the path (not the proposed content) is needed here.
|
||||
export function parseGitHubToolArgs(toolArgs) {
|
||||
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
|
||||
if (typeof toolArgs === 'string' && toolArgs.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolArgs);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
|
||||
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
|
||||
// *** Begin Patch
|
||||
// *** Add File: /abs/app.css
|
||||
// +body { ... }
|
||||
// *** End Patch
|
||||
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
|
||||
// JSON string with the path under `path`. Both must map onto the internal shape.
|
||||
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
|
||||
|
||||
function looksLikeApplyPatch(rawArgs) {
|
||||
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
|
||||
// Guard against an edit/create payload whose edited *content* happens to
|
||||
// contain patch markers: that payload is a JSON object string, whereas a real
|
||||
// apply_patch payload is a raw patch string that does not parse as JSON. Only
|
||||
// treat non-JSON-object strings as apply_patch so edit events still get their
|
||||
// `path` extracted.
|
||||
try {
|
||||
const parsed = JSON.parse(rawArgs);
|
||||
if (parsed && typeof parsed === 'object') return false;
|
||||
} catch { /* not JSON → genuine raw patch */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPatchText(rawArgs) {
|
||||
if (typeof rawArgs === 'string') {
|
||||
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
|
||||
// Defensive: a future Copilot build might JSON-wrap the patch.
|
||||
const parsed = parseGitHubToolArgs(rawArgs);
|
||||
return parsed.patch || parsed.input || parsed.command || '';
|
||||
}
|
||||
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
||||
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeGitHubEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const toolName = event.toolName || event.tool_name || null;
|
||||
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
|
||||
const rawArgs = event.toolArgs;
|
||||
|
||||
let normalizedToolName = toolName;
|
||||
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
|
||||
// resolveTargetFiles() reads the touched paths from tool_input.command when
|
||||
// tool_name is 'apply_patch', so normalize the name even if a future build
|
||||
// sends the patch under a different tool label.
|
||||
const patch = applyPatchText(rawArgs);
|
||||
if (patch) {
|
||||
toolInput.command = patch;
|
||||
normalizedToolName = 'apply_patch';
|
||||
}
|
||||
} else {
|
||||
const args = parseGitHubToolArgs(rawArgs);
|
||||
const filePath = args.path || args.file_path || args.filePath || args.target_file;
|
||||
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: normalizedToolName,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
|
||||
@@ -1200,12 +1301,12 @@ export function setDetectorForTesting(impl) {
|
||||
// session" so the model knows it's a re-mind, not a new finding.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const STEER_LINE = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.';
|
||||
const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.';
|
||||
|
||||
export function renderCleanAck(filePath, opts = {}) {
|
||||
const cwd = opts.cwd || process.cwd();
|
||||
const display = relativize(filePath, cwd);
|
||||
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${STEER_LINE}`;
|
||||
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`;
|
||||
}
|
||||
|
||||
export function renderPendingAck(filePath, knownFindings, opts = {}) {
|
||||
@@ -1261,7 +1362,7 @@ function directiveFooter(display, opts = {}) {
|
||||
'',
|
||||
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
|
||||
'',
|
||||
`Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
|
||||
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -1520,6 +1621,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'cursor') {
|
||||
return JSON.stringify({ additional_context: text });
|
||||
}
|
||||
// GitHub Copilot's postToolUse hook injects context via a top-level
|
||||
// `additionalContext` string (alongside an optional `modifiedResult`).
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
/**
|
||||
* CLI-side reader/writer for the unified `.impeccable` config.
|
||||
*
|
||||
* The CLI (published to npm) and the skill scripts (bundled into the install)
|
||||
* live in separate trees and cannot share runtime code, so this duplicates a
|
||||
* small slice of skill/scripts/hook-lib.mjs — the config-path layout, detector
|
||||
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
|
||||
* ignore filtering, and exclude marker in sync if either side changes.
|
||||
*
|
||||
* Schema (config.json shared / config.local.json gitignored, per-developer):
|
||||
* {
|
||||
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
|
||||
* "hook": { "consent": "accepted" | "declined", ... },
|
||||
* "updateCheck": bool
|
||||
* }
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
||||
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
|
||||
export function getConfigPath(root) {
|
||||
return join(root, '.impeccable', 'config.json');
|
||||
}
|
||||
|
||||
export function getLocalConfigPath(root) {
|
||||
return join(root, '.impeccable', 'config.local.json');
|
||||
}
|
||||
|
||||
function safeReadJson(filePath) {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hookSection(raw) {
|
||||
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
|
||||
}
|
||||
|
||||
function detectorSection(raw) {
|
||||
return raw && raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
|
||||
}
|
||||
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
|
||||
|
||||
const DEFAULT_DETECTION_CONFIG = Object.freeze({
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
designSystem: { enabled: true },
|
||||
});
|
||||
|
||||
function cloneDetectionConfig() {
|
||||
return {
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
|
||||
};
|
||||
}
|
||||
|
||||
function cloneRawDetectionConfig() {
|
||||
return {
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
};
|
||||
}
|
||||
|
||||
function applyDetectionConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
||||
config.designSystem = {
|
||||
...config.designSystem,
|
||||
enabled: raw.designSystem.enabled === false ? false : true,
|
||||
};
|
||||
}
|
||||
if (Array.isArray(raw.ignoreRules)) {
|
||||
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
|
||||
}
|
||||
if (Array.isArray(raw.ignoreFiles)) {
|
||||
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
|
||||
}
|
||||
if (Array.isArray(raw.ignoreValues)) {
|
||||
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function uniqueStrings(values) {
|
||||
return Array.from(new Set(values.map(String)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detector filters shared by `npx impeccable detect` and the design hook.
|
||||
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
|
||||
* the hook is disabled, but they honor the same ignore rules and design-system
|
||||
* toggle.
|
||||
*/
|
||||
export function readDetectionConfig(root) {
|
||||
const config = cloneDetectionConfig();
|
||||
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
|
||||
const raw = safeReadJson(filePath);
|
||||
// Back-compat: old builds stored detector filters under hook.*.
|
||||
applyDetectionConfigSource(config, hookSection(raw));
|
||||
applyDetectionConfigSource(config, detectorSection(raw));
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export function readRawDetectionConfig(root, opts = {}) {
|
||||
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
|
||||
const config = cloneRawDetectionConfig();
|
||||
applyDetectionConfigSource(config, hookSection(raw));
|
||||
applyDetectionConfigSource(config, detectorSection(raw));
|
||||
return config;
|
||||
}
|
||||
|
||||
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
|
||||
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
|
||||
if (opts.local) ensureConfigGitExclude(root);
|
||||
const existing = safeReadJson(filePath) || {};
|
||||
const existingHook = hookSection(existing);
|
||||
const nextHook = stripDetectorKeys(existingHook);
|
||||
const nextDetector = {
|
||||
...(detectorSection(existing) || {}),
|
||||
...normalizeDetectionConfigForWrite(detectorConfig),
|
||||
};
|
||||
const next = {
|
||||
...existing,
|
||||
detector: nextDetector,
|
||||
};
|
||||
if (nextHook && Object.keys(nextHook).length > 0) {
|
||||
next.hook = nextHook;
|
||||
} else {
|
||||
delete next.hook;
|
||||
}
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function normalizeDetectionConfigForWrite(config) {
|
||||
const out = {};
|
||||
if (Array.isArray(config?.ignoreRules)) {
|
||||
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
|
||||
}
|
||||
if (Array.isArray(config?.ignoreFiles)) {
|
||||
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
|
||||
}
|
||||
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
|
||||
out.designSystem = {
|
||||
enabled: config.designSystem.enabled === false ? false : true,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function stripDetectorKeys(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeIgnoreValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeIgnoreRule(rule) {
|
||||
return String(rule || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function colorIgnoreKey(value) {
|
||||
const color = parseIgnoreColor(value);
|
||||
if (!color) return '';
|
||||
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
|
||||
}
|
||||
|
||||
function parseIgnoreColor(value) {
|
||||
const text = String(value || '').trim().toLowerCase();
|
||||
if (!text) return null;
|
||||
|
||||
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
|
||||
if (hex) return parseHexIgnoreColor(hex[1]);
|
||||
|
||||
const rgb = text.match(/^rgba?\((.*)\)$/i);
|
||||
if (rgb) {
|
||||
const parts = splitColorArgs(rgb[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const r = parseRgbChannel(parts[0]);
|
||||
const g = parseRgbChannel(parts[1]);
|
||||
const b = parseRgbChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
if ([r, g, b, a].some((v) => v === null)) return null;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
const hsl = text.match(/^hsla?\((.*)\)$/i);
|
||||
if (hsl) {
|
||||
const parts = splitColorArgs(hsl[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const h = parseHueChannel(parts[0]);
|
||||
const s = parsePercentChannel(parts[1]);
|
||||
const l = parsePercentChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
if ([h, s, l, a].some((v) => v === null)) return null;
|
||||
return hslToRgb(h, s, l, a);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseHexIgnoreColor(hex) {
|
||||
if (hex.length === 3 || hex.length === 4) {
|
||||
const r = parseInt(hex[0] + hex[0], 16);
|
||||
const g = parseInt(hex[1] + hex[1], 16);
|
||||
const b = parseInt(hex[2] + hex[2], 16);
|
||||
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
function splitColorArgs(body) {
|
||||
const text = String(body || '').trim();
|
||||
if (!text) return [];
|
||||
if (text.includes(',')) {
|
||||
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
|
||||
const last = parts[parts.length - 1];
|
||||
if (last && last.includes('/')) {
|
||||
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
|
||||
return [...parts.slice(0, -1), ...split];
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
|
||||
}
|
||||
|
||||
function parseRgbChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const scaled = match[2] ? value * 2.55 : value;
|
||||
if (scaled < 0 || scaled > 255) return null;
|
||||
return Math.round(scaled);
|
||||
}
|
||||
|
||||
function parseAlphaChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const alpha = match[2] ? value / 100 : value;
|
||||
return alpha >= 0 && alpha <= 1 ? alpha : null;
|
||||
}
|
||||
|
||||
function parseHueChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const unit = match[2] || 'deg';
|
||||
if (unit === 'turn') return value * 360;
|
||||
if (unit === 'rad') return value * (180 / Math.PI);
|
||||
if (unit === 'grad') return value * 0.9;
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePercentChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)%$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return value >= 0 && value <= 100 ? value / 100 : null;
|
||||
}
|
||||
|
||||
function hslToRgb(hue, saturation, lightness, alpha) {
|
||||
const h = (((hue % 360) + 360) % 360) / 360;
|
||||
if (saturation === 0) {
|
||||
const gray = clampByte(Math.round(lightness * 255));
|
||||
return { r: gray, g: gray, b: gray, a: alpha };
|
||||
}
|
||||
const q = lightness < 0.5
|
||||
? lightness * (1 + saturation)
|
||||
: lightness + saturation - lightness * saturation;
|
||||
const p = 2 * lightness - q;
|
||||
const toRgb = (t) => {
|
||||
let channel = t;
|
||||
if (channel < 0) channel += 1;
|
||||
if (channel > 1) channel -= 1;
|
||||
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
|
||||
if (channel < 1 / 2) return q;
|
||||
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
|
||||
return p;
|
||||
};
|
||||
return {
|
||||
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
|
||||
g: clampByte(Math.round(toRgb(h) * 255)),
|
||||
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
|
||||
a: alpha,
|
||||
};
|
||||
}
|
||||
|
||||
function clampByte(value) {
|
||||
return Math.min(255, Math.max(0, value));
|
||||
}
|
||||
|
||||
function ignoreValueMatches(rule, entryValue, findingValue) {
|
||||
if (entryValue === findingValue) return true;
|
||||
if (rule !== 'design-system-color') return false;
|
||||
const entryColor = colorIgnoreKey(entryValue);
|
||||
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
|
||||
}
|
||||
|
||||
export function normalizeIgnoreValueEntries(entries) {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
const out = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const rule = normalizeIgnoreRule(entry.rule);
|
||||
const value = normalizeIgnoreValue(entry.value);
|
||||
if (!rule || !value) continue;
|
||||
const normalized = { rule, value };
|
||||
const files = uniqueStrings([
|
||||
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
|
||||
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
|
||||
]);
|
||||
if (files.length > 0) normalized.files = files;
|
||||
if (typeof entry.reason === 'string' && entry.reason.trim()) {
|
||||
normalized.reason = entry.reason.trim();
|
||||
}
|
||||
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
|
||||
normalized.createdAt = entry.createdAt.trim();
|
||||
}
|
||||
out.push(normalized);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeIgnoreValues(existing, incoming) {
|
||||
const map = new Map();
|
||||
for (const entry of normalizeIgnoreValueEntries(existing)) {
|
||||
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
||||
}
|
||||
for (const entry of normalizeIgnoreValueEntries(incoming)) {
|
||||
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
function ignoreValueFilesKey(files) {
|
||||
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
|
||||
}
|
||||
|
||||
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
|
||||
function globToRegex(glob) {
|
||||
let re = '^';
|
||||
let i = 0;
|
||||
while (i < glob.length) {
|
||||
const c = glob[i];
|
||||
if (c === '*') {
|
||||
if (glob[i + 1] === '*') {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
if (glob[i] === '/') i += 1;
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (c === '{') {
|
||||
const end = glob.indexOf('}', i);
|
||||
if (end === -1) { re += '\\{'; i += 1; continue; }
|
||||
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
|
||||
re += `(?:${parts.join('|')})`;
|
||||
i = end + 1;
|
||||
} else if (/[.+^$()|[\]\\]/.test(c)) {
|
||||
re += `\\${c}`;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
re += '$';
|
||||
return new RegExp(re);
|
||||
}
|
||||
|
||||
export function matchesAnyGlob(filePath, globs) {
|
||||
if (!Array.isArray(globs) || globs.length === 0) return false;
|
||||
const normalized = String(filePath || '').split(sep).join('/');
|
||||
for (const glob of globs) {
|
||||
try {
|
||||
const re = globToRegex(String(glob));
|
||||
if (re.test(normalized)) return true;
|
||||
const base = normalized.split('/').pop();
|
||||
if (re.test(base)) return true;
|
||||
} catch {
|
||||
/* malformed glob, skip */
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldIgnoreDetectionFile(filePath, root, config) {
|
||||
const globs = config?.ignoreFiles || [];
|
||||
if (!Array.isArray(globs) || globs.length === 0) return false;
|
||||
const raw = String(filePath || '').trim();
|
||||
if (!raw) return false;
|
||||
if (matchesAnyGlob(raw, globs)) return true;
|
||||
|
||||
try {
|
||||
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
|
||||
if (matchesAnyGlob(abs, globs)) return true;
|
||||
const rel = relative(root, abs);
|
||||
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
|
||||
return matchesAnyGlob(rel, globs);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function filterDetectionFindings(findings, config) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return [];
|
||||
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
|
||||
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
return findings.filter((finding) => {
|
||||
if (!finding || typeof finding !== 'object') return false;
|
||||
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
|
||||
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function isIgnoredFindingValue(finding, ignoreValues) {
|
||||
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
const value = extractFindingIgnoreValue(finding);
|
||||
if (!rule || !value) return false;
|
||||
return ignoreValues.some((entry) => {
|
||||
const wildcardValue = entry.value === '*';
|
||||
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
|
||||
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
|
||||
return findingMatchesScopedIgnoreFile(finding, entry.files);
|
||||
});
|
||||
}
|
||||
|
||||
function findingMatchesScopedIgnoreFile(finding, globs) {
|
||||
const filePath = String(finding?.file || '').trim();
|
||||
if (!filePath) return false;
|
||||
if (matchesAnyGlob(filePath, globs)) return true;
|
||||
|
||||
const normalized = filePath.split(sep).join('/');
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const suffix = parts.slice(i).join('/');
|
||||
if (matchesAnyGlob(suffix, globs)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function extractFindingIgnoreValue(finding) {
|
||||
if (!finding || typeof finding !== 'object') return '';
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
const directValueRules = new Set([
|
||||
'overused-font',
|
||||
'bounce-easing',
|
||||
'design-system-font',
|
||||
'design-system-color',
|
||||
'design-system-radius',
|
||||
]);
|
||||
if (!directValueRules.has(rule)) return '';
|
||||
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
|
||||
}
|
||||
|
||||
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
|
||||
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
|
||||
if (direct) return direct;
|
||||
|
||||
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
|
||||
for (const text of candidates) {
|
||||
if (rule === 'bounce-easing') {
|
||||
const motion = extractMotionIgnoreValue(text);
|
||||
if (motion) return motion;
|
||||
continue;
|
||||
}
|
||||
|
||||
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
|
||||
if (primary) return cleanIgnoreValueDisplay(primary[1]);
|
||||
|
||||
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
|
||||
if (family) return cleanIgnoreValueDisplay(family[1]);
|
||||
|
||||
const google = text.match(/[?&]family=([^&:;\n]+)/i);
|
||||
if (google) {
|
||||
try {
|
||||
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
|
||||
} catch {
|
||||
return cleanIgnoreValueDisplay(google[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractMotionIgnoreValue(text) {
|
||||
const tailwind = text.match(/\banimate-bounce\b/i);
|
||||
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
|
||||
|
||||
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
|
||||
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
|
||||
|
||||
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
|
||||
if (animation) {
|
||||
const token = animation[1]
|
||||
.split(/[,\s]+/)
|
||||
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
||||
if (token) return cleanIgnoreValueDisplay(token);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function cleanIgnoreValueDisplay(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ')
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
|
||||
* config.local.json (per-developer) overrides config.json.
|
||||
*/
|
||||
export function getHookConsent(root) {
|
||||
let consent;
|
||||
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
|
||||
const hook = hookSection(safeReadJson(filePath));
|
||||
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
|
||||
}
|
||||
return consent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the per-developer decision to config.local.json, preserving any
|
||||
* sibling keys, and ensure the file is gitignored.
|
||||
*/
|
||||
export function setHookConsent(root, value) {
|
||||
const filePath = getLocalConfigPath(root);
|
||||
const existing = safeReadJson(filePath) || {};
|
||||
const hook = hookSection(existing) || {};
|
||||
const next = { ...existing, hook: { ...hook, consent: value } };
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
|
||||
ensureConfigGitExclude(root);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
|
||||
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
|
||||
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
|
||||
|
||||
/**
|
||||
* Add config.local.json to `.git/info/exclude` so a developer's decision is
|
||||
* never committed. Idempotent via marker comments. Best-effort; returns false
|
||||
* when there is no resolvable git dir.
|
||||
*/
|
||||
export function ensureConfigGitExclude(root) {
|
||||
try {
|
||||
const gitDir = resolveGitDir(root);
|
||||
if (!gitDir) return false;
|
||||
const target = join(gitDir, 'info', 'exclude');
|
||||
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
|
||||
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
|
||||
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
|
||||
let updated;
|
||||
if (markerRe.test(existing)) {
|
||||
updated = existing.replace(markerRe, block);
|
||||
} else {
|
||||
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
|
||||
updated = `${prefix}${block}\n`;
|
||||
}
|
||||
if (updated !== existing) {
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, updated);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGitDir(root) {
|
||||
const dotGit = join(root, '.git');
|
||||
if (!existsSync(dotGit)) return null;
|
||||
try {
|
||||
if (statSync(dotGit).isDirectory()) return dotGit;
|
||||
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
|
||||
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
|
||||
if (match) {
|
||||
const resolved = match[1].trim();
|
||||
return isAbsolute(resolved) ? resolved : join(root, resolved);
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -1,50 +1,52 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
export const CRITIQUE_DIR = 'critique';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
export function getImpeccableDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
const projectRoot = resolveProjectRoot(cwd, options);
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
getDesignSidecarPath(cwd, options),
|
||||
path.join(projectRoot, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
export function getLiveDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
export function getLiveConfigPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
const primary = getLiveConfigPath(cwd, { targetPath });
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
@@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
export function getLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function readLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
|
||||
@@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) {
|
||||
const filePath = getLiveServerPath(cwd, options);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'annotations');
|
||||
}
|
||||
|
||||
export function getCritiqueDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), CRITIQUE_DIR);
|
||||
export function getCritiqueDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR);
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
class TargetArgError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'TargetArgError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTargetPath(args = [], { strict = false } = {}) {
|
||||
let targetPath = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i]);
|
||||
if (arg === '--target' || arg === '-t') {
|
||||
const next = args[i + 1];
|
||||
if (next && !String(next).startsWith('-')) {
|
||||
targetPath = String(next);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--target=')) {
|
||||
const value = arg.slice('--target='.length);
|
||||
if (value) {
|
||||
targetPath = value;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export function parseTargetOptions(args = [], options = {}) {
|
||||
const targetPath = parseTargetPath(args, options);
|
||||
return targetPath ? { targetPath } : {};
|
||||
}
|
||||
@@ -57,7 +57,7 @@
|
||||
const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
|
||||
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint
|
||||
const PREFIX = 'impeccable-live';
|
||||
const PICK_CURSOR_CLASS = PREFIX + '-pick-cursor';
|
||||
const PICK_CURSOR_STYLE_ID = PREFIX + '-pick-cursor-style';
|
||||
const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000;
|
||||
const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({
|
||||
prefix: PREFIX,
|
||||
@@ -152,6 +152,7 @@
|
||||
let scrollLockTargetY = null;
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock';
|
||||
|
||||
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
@@ -1915,45 +1916,45 @@
|
||||
syncPageInteractionCursor();
|
||||
}
|
||||
|
||||
let pageInteractionCursorActive = false;
|
||||
|
||||
function ensurePickCursorStyle() {
|
||||
if (document.getElementById(PREFIX + '-pick-cursor-style')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = PREFIX + '-pick-cursor-style';
|
||||
/**
|
||||
* Drive the page-level pick / insert cursor through the textContent of one
|
||||
* injected <style>, never by mutating <html> (className or inline style).
|
||||
* Frameworks that server-render the <html>/<body> roots (Next.js App Router)
|
||||
* report a React 19 hydration mismatch when the client adds an attribute the
|
||||
* server HTML never emitted, so a `class`/inline `style` toggled on
|
||||
* `document.documentElement` trips "a tree hydrated but some attributes ...
|
||||
* didn't match" on the next Fast-Refresh re-render. Keying the cursor off a
|
||||
* stable-id <style> keeps the effect off the hydrated host elements (same
|
||||
* shape as the scroll-anchor lock). A falsy cursor clears the rule.
|
||||
*/
|
||||
function setPageInteractionCursor(cursor) {
|
||||
let style = document.getElementById(PICK_CURSOR_STYLE_ID);
|
||||
if (!cursor) {
|
||||
if (style) style.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = PICK_CURSOR_STYLE_ID;
|
||||
// Styles the host page, not the chrome - inside the adapter's shadow UI
|
||||
// root (uiAppendStyle's target) these selectors would match nothing.
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
}
|
||||
style.textContent =
|
||||
'html.' + PICK_CURSOR_CLASS + ' * { cursor: crosshair !important; }\n'
|
||||
+ 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"],\n'
|
||||
+ 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"] * { cursor: revert !important; }';
|
||||
// Styles the host page, not the chrome - inside the adapter's shadow UI
|
||||
// root (uiAppendStyle's target) these selectors would match nothing.
|
||||
document.head.appendChild(style);
|
||||
'* { cursor: ' + cursor + ' !important; }\n'
|
||||
+ '[id^="' + PREFIX + '"],\n'
|
||||
+ '[id^="' + PREFIX + '"] * { cursor: revert !important; }';
|
||||
}
|
||||
|
||||
/** Page-level cursor while pick or insert mode is targeting page elements. */
|
||||
function syncPageInteractionCursor() {
|
||||
const pickCursor = state === 'PICKING' && pickActive && !insertActive;
|
||||
let axisCursor = '';
|
||||
if (state === 'PICKING' && insertActive) {
|
||||
axisCursor = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : '';
|
||||
}
|
||||
|
||||
if (pickCursor) {
|
||||
ensurePickCursorStyle();
|
||||
document.documentElement.classList.add(PICK_CURSOR_CLASS);
|
||||
document.documentElement.style.cursor = '';
|
||||
pageInteractionCursorActive = true;
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.classList.remove(PICK_CURSOR_CLASS);
|
||||
if (axisCursor) {
|
||||
document.documentElement.style.cursor = axisCursor;
|
||||
pageInteractionCursorActive = true;
|
||||
} else if (pageInteractionCursorActive) {
|
||||
document.documentElement.style.cursor = '';
|
||||
pageInteractionCursorActive = false;
|
||||
let cursor = '';
|
||||
if (state === 'PICKING' && pickActive && !insertActive) {
|
||||
cursor = 'crosshair';
|
||||
} else if (state === 'PICKING' && insertActive && insertHoverAnchor) {
|
||||
cursor = cursorForInsertAxis(insertHoverAxis || 'column');
|
||||
}
|
||||
setPageInteractionCursor(cursor);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5815,10 +5816,22 @@
|
||||
|
||||
try { history.scrollRestoration = 'manual'; } catch {}
|
||||
|
||||
const prevHtmlAnchor = document.documentElement.style.overflowAnchor;
|
||||
const prevBodyAnchor = document.body.style.overflowAnchor;
|
||||
document.documentElement.style.overflowAnchor = 'none';
|
||||
document.body.style.overflowAnchor = 'none';
|
||||
// Suppress the browser's scroll-anchoring on the scroll root so it can't
|
||||
// fight our manual scroll correction. Apply this as a stylesheet rule, not
|
||||
// as inline `style` on <html>/<body>: those elements are server-rendered by
|
||||
// frameworks like Next.js App Router, and mutating their inline style makes
|
||||
// React 19 report a hydration mismatch on the next Fast-Refresh re-render.
|
||||
// A <style> rule has the same computed effect without touching any hydrated
|
||||
// element's attributes. Like the inline version, it is recreated on every
|
||||
// startScrollLock call, so reload survival (driven by the persisted scroll
|
||||
// key) is unaffected.
|
||||
let anchorLockStyle = document.getElementById(SCROLL_ANCHOR_LOCK_ID);
|
||||
if (!anchorLockStyle) {
|
||||
anchorLockStyle = document.createElement('style');
|
||||
anchorLockStyle.id = SCROLL_ANCHOR_LOCK_ID;
|
||||
anchorLockStyle.textContent = 'html,body{overflow-anchor:none !important;}';
|
||||
(document.head || document.documentElement).appendChild(anchorLockStyle);
|
||||
}
|
||||
|
||||
const correct = (why) => {
|
||||
scrollLockRaf = null;
|
||||
@@ -5853,8 +5866,7 @@
|
||||
|
||||
scrollLockAbort = new AbortController();
|
||||
scrollLockAbort.signal.addEventListener('abort', () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
document.getElementById(SCROLL_ANCHOR_LOCK_ID)?.remove();
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
// Track whether the most recent scroll came from a user gesture. We
|
||||
@@ -6489,10 +6501,13 @@
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isPageEditableElement(deepActive) && !isInlineEditActive(deepActive)) {
|
||||
return;
|
||||
}
|
||||
// While a contenteditable text-leaf is focused, let the browser handle
|
||||
// all keys except Escape. Escape cancels the current edit (restores
|
||||
// original text) and blurs without saving, staying in CONFIGURING.
|
||||
if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
|
||||
if (e.target.isContentEditable && isInlineEditActive(e.target)) {
|
||||
if (e.key !== 'Escape') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -8186,7 +8201,7 @@ void main() {
|
||||
let voiceInterimBase = '';
|
||||
/** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
|
||||
let voiceCtx = null;
|
||||
const PAGE_CHAT_COLLAPSED_W = '88px';
|
||||
const PAGE_CHAT_COLLAPSED_W = '104px';
|
||||
const PAGE_CHAT_PROCESSING_W = '76px';
|
||||
const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
|
||||
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
|
||||
@@ -8197,7 +8212,7 @@ void main() {
|
||||
const GLOBAL_BAR_SECTION_GAP = 8;
|
||||
const GLOBAL_BAR_INNER_GAP = 2;
|
||||
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
|
||||
const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
|
||||
const PAGE_CHAT_EXPANDED_MAX_W = 280;
|
||||
const ICON_PAGE_CHAT =
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
||||
const ICON_PAGE_VOICE =
|
||||
@@ -8277,6 +8292,52 @@ void main() {
|
||||
return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
|
||||
}
|
||||
|
||||
function globalBarModeToggles() {
|
||||
return [
|
||||
uiGetById(PREFIX + '-pick-toggle'),
|
||||
uiGetById(PREFIX + '-insert-toggle'),
|
||||
uiGetById(PREFIX + '-detect-toggle'),
|
||||
uiGetById(PREFIX + '-design-toggle'),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function applyGlobalBarLabelState(expandInactive, forceCollapse = false) {
|
||||
globalBarModeToggles().forEach((toggle) => {
|
||||
if (forceCollapse) toggle._collapseLabel?.(true);
|
||||
else if (expandInactive || toggle.dataset.active === 'true') toggle._expandLabel?.();
|
||||
else toggle._collapseLabel?.();
|
||||
});
|
||||
}
|
||||
|
||||
function syncGlobalBarExpandedLabels(expanded = globalBarEl?.matches(':hover')) {
|
||||
const expandInactive = !!(expanded && !pageChatExpanded);
|
||||
applyGlobalBarLabelState(expandInactive, pageChatExpanded);
|
||||
|
||||
if (expandInactive && globalBarEl && globalBarEl.scrollWidth > window.innerWidth - 16) {
|
||||
applyGlobalBarLabelState(false);
|
||||
}
|
||||
}
|
||||
|
||||
function pageChatCollapsedWidthPx() {
|
||||
const parsed = parseFloat(PAGE_CHAT_COLLAPSED_W);
|
||||
return Number.isFinite(parsed) ? parsed : 104;
|
||||
}
|
||||
|
||||
function pageChatExpandedWidth() {
|
||||
if (!pageChatEl || !globalBarEl) return PAGE_CHAT_EXPANDED_MAX_W + 'px';
|
||||
const currentChatWidth = pageChatEl.getBoundingClientRect().width || pageChatCollapsedWidthPx();
|
||||
const barWidth = Math.max(globalBarEl.getBoundingClientRect().width || 0, globalBarEl.scrollWidth || 0);
|
||||
const nonChatWidth = Math.max(0, barWidth - currentChatWidth);
|
||||
const available = window.innerWidth - 16 - nonChatWidth;
|
||||
const next = Math.max(pageChatCollapsedWidthPx(), Math.min(PAGE_CHAT_EXPANDED_MAX_W, available));
|
||||
return Math.round(next) + 'px';
|
||||
}
|
||||
|
||||
function syncPageChatExpandedWidth() {
|
||||
if (!pageChatEl || !pageChatExpanded) return;
|
||||
pageChatEl.style.width = pageChatExpandedWidth();
|
||||
}
|
||||
|
||||
function syncPageChatChrome() {
|
||||
if (!pageChatEl) return;
|
||||
const P = pageChatPalette();
|
||||
@@ -8312,6 +8373,21 @@ void main() {
|
||||
&& !steerLocked;
|
||||
}
|
||||
|
||||
function isPageEditableElement(el) {
|
||||
if (!el || own(el)) return false;
|
||||
if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return true;
|
||||
return !!el.isContentEditable;
|
||||
}
|
||||
|
||||
function isInlineEditActive(el) {
|
||||
return !!el && inlineEditRows.some((r) => r.el === el);
|
||||
}
|
||||
|
||||
function isPageEditableActive() {
|
||||
const active = activeElementDeep();
|
||||
return isPageEditableElement(active) && !isInlineEditActive(active);
|
||||
}
|
||||
|
||||
function pageHasHostTextSelection() {
|
||||
const sel = window.getSelection?.();
|
||||
if (!sel || sel.isCollapsed) return false;
|
||||
@@ -8325,6 +8401,7 @@ void main() {
|
||||
function shouldSteerAutoFocus() {
|
||||
return shouldFocusSteerChat()
|
||||
&& !steerFocusSuspended
|
||||
&& !isPageEditableActive()
|
||||
&& performance.now() >= steerFocusPauseUntil;
|
||||
}
|
||||
|
||||
@@ -8562,7 +8639,8 @@ void main() {
|
||||
if (!pageChatEl || !pageChatInput) return false;
|
||||
pageChatExpanded = true;
|
||||
pageChatEl.dataset.expanded = 'true';
|
||||
pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
|
||||
syncGlobalBarExpandedLabels(false);
|
||||
pageChatEl.style.width = pageChatExpandedWidth();
|
||||
pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
|
||||
pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED;
|
||||
if (pageChatHint) {
|
||||
@@ -8657,7 +8735,7 @@ void main() {
|
||||
pageChatEl.setAttribute('aria-label', 'Steer the page');
|
||||
pageChatExpanded = keepExpanded;
|
||||
pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
|
||||
pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
|
||||
pageChatEl.style.width = keepExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
|
||||
pageChatEl.style.cursor = 'pointer';
|
||||
if (pageChatInput) {
|
||||
pageChatInput.disabled = false;
|
||||
@@ -8971,6 +9049,7 @@ void main() {
|
||||
pageChatEl.dataset.expanded = 'false';
|
||||
pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
|
||||
pageChatEl.style.cursor = 'pointer';
|
||||
syncGlobalBarExpandedLabels(globalBarEl?.matches(':hover'));
|
||||
if (blur) {
|
||||
pageChatInput.blur();
|
||||
pageChatInput.style.pointerEvents = 'none';
|
||||
@@ -9270,6 +9349,7 @@ void main() {
|
||||
zIndex: Z.bar + 5,
|
||||
display: 'flex', alignItems: 'stretch',
|
||||
gap: '0',
|
||||
width: 'max-content',
|
||||
background: P.surface,
|
||||
border: '1px solid ' + P.border,
|
||||
borderRadius: '8px',
|
||||
@@ -9277,6 +9357,8 @@ void main() {
|
||||
fontFamily: FONT, fontSize: '12px', lineHeight: '1',
|
||||
opacity: '0',
|
||||
overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
});
|
||||
globalBarEl.id = PREFIX + '-global-bar';
|
||||
@@ -9325,6 +9407,7 @@ void main() {
|
||||
const inner = el('div', {
|
||||
display: 'flex', alignItems: 'center',
|
||||
padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
|
||||
flex: '0 0 auto',
|
||||
});
|
||||
inner.id = PREFIX + '-global-bar-inner';
|
||||
globalBarEl.appendChild(inner);
|
||||
@@ -9333,7 +9416,10 @@ void main() {
|
||||
function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
|
||||
const b = el('button', {
|
||||
position: 'relative',
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
minWidth: '30px',
|
||||
padding: '6px 8px', borderRadius: '7px',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
|
||||
@@ -9352,8 +9438,8 @@ void main() {
|
||||
if (!labelEl) return;
|
||||
labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
|
||||
};
|
||||
const collapse = () => {
|
||||
if (!labelEl || b.dataset.active === 'true') return;
|
||||
const collapse = (force = false) => {
|
||||
if (!labelEl || (!force && b.dataset.active === 'true')) return;
|
||||
labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
|
||||
};
|
||||
// Per-button hover only changes color (no layout). The label expand/
|
||||
@@ -9604,6 +9690,7 @@ void main() {
|
||||
width: '1px', height: '18px',
|
||||
background: P.hairline,
|
||||
margin: '0 4px 0 2px',
|
||||
flexShrink: '0',
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
@@ -9620,6 +9707,7 @@ void main() {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0', boxSizing: 'border-box',
|
||||
width: '24px', height: '24px', borderRadius: '6px',
|
||||
flexShrink: '0',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
|
||||
@@ -9632,16 +9720,16 @@ void main() {
|
||||
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
|
||||
inner.appendChild(exitBtn);
|
||||
|
||||
// Bar-level hover: expand every toggle's label at once; collapse on leave.
|
||||
// Bar-level hover: expand mode labels unless Steer is using the space.
|
||||
// Buttons with dataset.active="true" ignore collapse (their label stays).
|
||||
const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
|
||||
globalBarEl.addEventListener('mouseenter', () => {
|
||||
toggles.forEach((t) => t._expandLabel && t._expandLabel());
|
||||
syncGlobalBarExpandedLabels(true);
|
||||
syncPageChatExpandedWidth();
|
||||
schedulePendingDockPosition();
|
||||
setTimeout(schedulePendingDockPosition, 260);
|
||||
});
|
||||
globalBarEl.addEventListener('mouseleave', () => {
|
||||
toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
|
||||
syncGlobalBarExpandedLabels(false);
|
||||
schedulePendingDockPosition();
|
||||
setTimeout(schedulePendingDockPosition, 260);
|
||||
});
|
||||
@@ -9659,6 +9747,7 @@ void main() {
|
||||
pendingDockResizeObserver.observe(globalBarEl);
|
||||
}
|
||||
window.addEventListener('resize', positionPendingDock);
|
||||
window.addEventListener('resize', syncPageChatExpandedWidth);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
globalBarEl.style.opacity = '1';
|
||||
@@ -9705,9 +9794,7 @@ void main() {
|
||||
// If the bar is currently under the cursor, keep all labels expanded -
|
||||
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
|
||||
// would collapse its label while the user's mouse is still on the bar.
|
||||
if (globalBarEl && globalBarEl.matches(':hover')) {
|
||||
[pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
|
||||
}
|
||||
syncGlobalBarExpandedLabels(globalBarEl && globalBarEl.matches(':hover'));
|
||||
|
||||
if (detectBadge) {
|
||||
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
|
||||
@@ -9896,7 +9983,7 @@ void main() {
|
||||
// Remove detection overlays
|
||||
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
|
||||
setLiveState('IDLE');
|
||||
document.getElementById(PREFIX + '-pick-cursor-style')?.remove();
|
||||
document.getElementById(PICK_CURSOR_STYLE_ID)?.remove();
|
||||
window.__IMPECCABLE_LIVE_INIT__ = false;
|
||||
console.log('[impeccable] Live mode exited.');
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* CLI client for the live variant mode poll/reply protocol.
|
||||
*
|
||||
* Usage:
|
||||
* npx impeccable poll # Block until browser event, print JSON
|
||||
* npx impeccable poll --stream # Experimental: keep polling; one JSON line per event
|
||||
* npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly
|
||||
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
|
||||
* npx impeccable poll --reply <id> error "msg" # Reply with error
|
||||
* node <scripts_path>/live-poll.mjs # Block until browser event, print JSON
|
||||
* node <scripts_path>/live-poll.mjs --stream # Experimental: keep polling; one JSON line per event
|
||||
* node <scripts_path>/live-poll.mjs --timeout=600000 # Custom timeout (ms); default is long-poll friendly
|
||||
* node <scripts_path>/live-poll.mjs --reply <id> done # Reply "done" to event <id>
|
||||
* node <scripts_path>/live-poll.mjs --reply <id> error "msg" # Reply with error
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
@@ -15,6 +15,11 @@ import { fileURLToPath } from 'node:url';
|
||||
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
|
||||
// Absolute path to a sibling script in this skill's scripts dir, so runtime
|
||||
// error hints print a directly-runnable command instead of a placeholder.
|
||||
const SELF_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const scriptCmd = (name) => `node "${path.join(SELF_DIR, name)}"`;
|
||||
|
||||
// Node's built-in fetch (undici under the hood) enforces a 300s headers
|
||||
// timeout that can't be lowered per-request. We cap each request below
|
||||
// that ceiling and loop in `pollOnce` to synthesize a long poll without
|
||||
@@ -27,7 +32,7 @@ const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_ed
|
||||
function readServerInfo() {
|
||||
const record = readLiveServerInfo(process.cwd());
|
||||
if (!record) {
|
||||
console.error('No running live server found. Start one with: npx impeccable live');
|
||||
console.error(`No running live server found. Start one with: ${scriptCmd('live.mjs')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return record.info;
|
||||
@@ -82,7 +87,7 @@ export function parseReplyArgs(args) {
|
||||
}
|
||||
|
||||
function validateReplyArgs({ id, status }) {
|
||||
const usage = "Usage: npx impeccable poll --reply <id> <status> [--file path] [--data '<json>'] [message]";
|
||||
const usage = `Usage: ${scriptCmd('live-poll.mjs')} --reply <id> <status> [--file path] [--data '<json>'] [message]`;
|
||||
if (!id || id.startsWith('--')) {
|
||||
const err = new Error(`${usage}\nMissing event id after --reply.`);
|
||||
err.code = 'INVALID_REPLY_ARGS';
|
||||
@@ -283,11 +288,11 @@ export async function runPollStream(base, token, {
|
||||
function handlePollError(err) {
|
||||
if (err.code === 'AUTH_FAILED') {
|
||||
console.error(err.message);
|
||||
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
|
||||
console.error(`Try restarting: ${scriptCmd('live-server.mjs')} stop && ${scriptCmd('live.mjs')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (err.cause?.code === 'ECONNREFUSED') {
|
||||
console.error('Live server not running. Start one with: npx impeccable live');
|
||||
console.error(`Live server not running. Start one with: ${scriptCmd('live.mjs')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (err.code === 'ACK_TIMEOUT') {
|
||||
@@ -331,7 +336,7 @@ Harness note:
|
||||
const info = readServerInfo();
|
||||
const base = `http://localhost:${info.port}`;
|
||||
|
||||
// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [--data '<json>'] [message]
|
||||
// Reply mode: node <scripts_path>/live-poll.mjs --reply <id> <status> [--file path] [--data '<json>'] [message]
|
||||
if (args.includes('--reply')) {
|
||||
let reply;
|
||||
try {
|
||||
@@ -345,7 +350,7 @@ Harness note:
|
||||
await postReply(base, info.token, reply);
|
||||
} catch (err) {
|
||||
if (err.cause?.code === 'ECONNREFUSED') {
|
||||
console.error('Live server not running. Start one with: npx impeccable live');
|
||||
console.error(`Live server not running. Start one with: ${scriptCmd('live.mjs')}`);
|
||||
} else {
|
||||
console.error('Reply failed:', err.message);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './lib/design-parser.mjs';
|
||||
import { resolveContextDir } from './context.mjs';
|
||||
import { loadContext } from './context.mjs';
|
||||
import {
|
||||
assembleLiveBrowserScript,
|
||||
assertLiveBrowserScriptParts,
|
||||
@@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const PROJECT_CONTEXT = loadContext(process.cwd());
|
||||
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
|
||||
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
|
||||
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
|
||||
: null;
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
|
||||
@@ -371,10 +375,7 @@ function hasProjectContext() {
|
||||
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
||||
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
||||
// concern, surfaced by the design panel's own empty state.
|
||||
try {
|
||||
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
return !!PROJECT_CONTEXT.hasProduct;
|
||||
}
|
||||
|
||||
function statOrNull(filePath) {
|
||||
@@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdPath = DESIGN_MD_PATH;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from './context.mjs';
|
||||
import { parseTargetPath } from './lib/target-args.mjs';
|
||||
|
||||
export function resolveLiveTarget(cwd = process.cwd(), args = []) {
|
||||
const originalCwd = path.resolve(cwd);
|
||||
let targetPath = null;
|
||||
try {
|
||||
targetPath = parseTargetPath(args, { strict: true });
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const absoluteTargetPath = targetPath
|
||||
? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath)
|
||||
: null;
|
||||
const projectRoot = targetPath
|
||||
? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath })
|
||||
: originalCwd;
|
||||
return {
|
||||
originalCwd,
|
||||
projectRoot,
|
||||
targetPath,
|
||||
absoluteTargetPath,
|
||||
targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {},
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* CLI helper: find an element in source and wrap it in a variant container.
|
||||
*
|
||||
* Usage:
|
||||
* npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
|
||||
* node <scripts_path>/live-wrap.mjs --id SESSION_ID --count N --query "hero-combined-left" [--file path]
|
||||
*
|
||||
* Searches project files for the element matching the query (class name, ID, or
|
||||
* text snippet), wraps it with the variant scaffolding, and prints the file path
|
||||
|
||||
@@ -21,14 +21,16 @@ import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './context.mjs';
|
||||
import { loadContext, resolveTargetSelection } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { resolveLiveTarget } from './live-target.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
const liveTarget = resolveLiveTarget(process.cwd(), args);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: node live.mjs
|
||||
@@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command:
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
{ ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }
|
||||
|
||||
On target_selection_required, prints:
|
||||
{ ok: false, error: "target_selection_required", targetCandidates }
|
||||
|
||||
On config_missing, prints:
|
||||
{ ok: false, error: "config_missing", configPath, hint }
|
||||
|
||||
The agent should then:
|
||||
1. If config_missing, create the config and re-run this script
|
||||
2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
3. Enter the poll loop: node live-poll.mjs`);
|
||||
1. If target_selection_required, ask which app to use and rerun from that child cwd
|
||||
2. If config_missing, create the config and re-run this script
|
||||
3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
4. Enter the poll loop: node live-poll.mjs`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
if (targetSelection) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'target_selection_required',
|
||||
...targetSelection,
|
||||
hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.',
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
const activeCwd = ctx.projectRoot;
|
||||
const outputTargetPath = liveTarget.targetPath || null;
|
||||
|
||||
const missingContext = missingLiveContext(ctx);
|
||||
if (missingContext.length > 0) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'context_missing',
|
||||
missing: missingContext,
|
||||
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 1. Check config (fail fast if missing — no point starting anything else)
|
||||
const checkOut = runScript('live-inject.mjs', ['--check']);
|
||||
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
|
||||
const checkResult = safeParse(checkOut);
|
||||
if (!checkResult || !checkResult.ok) {
|
||||
console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut }));
|
||||
console.log(JSON.stringify({
|
||||
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 2. Start server (or reuse existing)
|
||||
const serverInfo = ensureServerRunning();
|
||||
const serverInfo = ensureServerRunning(activeCwd);
|
||||
if (!serverInfo) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Inject the script tag at the current port
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]);
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd });
|
||||
const injectResult = safeParse(injectOut);
|
||||
if (!injectResult || !injectResult.ok) {
|
||||
console.log(JSON.stringify({
|
||||
@@ -80,22 +123,23 @@ The agent should then:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Load PRODUCT.md + DESIGN.md context.
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// 4. Compute drift-heal: compare resolved inject targets against the
|
||||
// project's HTML files. Orphans are HTML files not covered by config.
|
||||
// Warning only — the agent decides whether to act.
|
||||
const resolvedFiles = resolveFiles(process.cwd(), checkResult.config);
|
||||
const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config);
|
||||
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
|
||||
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
// 5. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: resolvedFiles,
|
||||
liveConfigPath: checkResult.path,
|
||||
configDrift: drift,
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -105,6 +149,13 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function missingLiveContext(ctx) {
|
||||
const missing = [];
|
||||
if (!ctx.hasProduct) missing.push('PRODUCT.md');
|
||||
if (!ctx.hasDesign) missing.push('DESIGN.md');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
@@ -201,11 +252,11 @@ function globToRegex(pattern) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runScript(name, args) {
|
||||
function runScript(name, args, options = {}) {
|
||||
const scriptPath = path.join(__dirname, name);
|
||||
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
||||
try {
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 });
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
|
||||
} catch (err) {
|
||||
// execSync throws on non-zero exit; return stdout if any
|
||||
return err.stdout || err.message || '';
|
||||
@@ -219,10 +270,10 @@ function safeParse(out) {
|
||||
/**
|
||||
* Return { pid, port, token } for the running live server, starting one if needed.
|
||||
*/
|
||||
function ensureServerRunning() {
|
||||
function ensureServerRunning(cwd = process.cwd()) {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
const existing = readLiveServerInfo(cwd)?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
@@ -232,7 +283,7 @@ function ensureServerRunning() {
|
||||
} catch { /* no PID file */ }
|
||||
|
||||
// Start a new server
|
||||
const out = runScript('live-server.mjs', ['--background']);
|
||||
const out = runScript('live-server.mjs', ['--background'], { cwd });
|
||||
return safeParse(out);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: impeccable
|
||||
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
|
||||
version: 3.7.0
|
||||
version: 3.8.0
|
||||
---
|
||||
|
||||
Designs and iterates production-grade frontend interfaces. Real working code, committed design choices, exceptional craft.
|
||||
@@ -10,7 +10,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
|
||||
|
||||
You MUST do these steps before proceeding:
|
||||
|
||||
1. Run `node .gemini/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
1. Run `node .gemini/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .gemini/skills/impeccable/scripts/context.mjs --target <path>` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code and Codex use `PostToolUse` and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project).
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
|
||||
On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands.
|
||||
|
||||
@@ -51,7 +51,7 @@ Prefer the narrowest exception:
|
||||
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
|
||||
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
|
||||
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
|
||||
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
|
||||
- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable <rule>` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it.
|
||||
|
||||
Example value-specific exception:
|
||||
|
||||
@@ -81,8 +81,8 @@ node .gemini/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
|
||||
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
|
||||
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code and Codex do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR
|
||||
|
||||
Execute in order. No step skipped, no step reordered.
|
||||
|
||||
1. `live.mjs`: boot.
|
||||
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .gemini/skills/impeccable/scripts/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
|
||||
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* init flow.
|
||||
*
|
||||
* Path resolution (first match wins):
|
||||
* 1. cwd, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. .agents/context/ then docs/
|
||||
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. Active project .agents/context/ then docs/
|
||||
* 3. Monorepo root context, using the same order, as a per-file fallback
|
||||
* 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* escape hatch, only consulted when defaults are empty
|
||||
* 4. cwd as a "nothing found" default
|
||||
* 5. Active project root as a "nothing found" default
|
||||
*
|
||||
* `resolveContextDir()` and `loadContext()` are also exported for the
|
||||
* server-side scripts (live.mjs, live-server.mjs) that need the structured
|
||||
@@ -19,10 +20,25 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.svelte-kit',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'coverage',
|
||||
]);
|
||||
|
||||
// ─── Update check ──────────────────────────────────────────────────────────
|
||||
// Piggyback a lightweight skill-version check on the once-per-session boot.
|
||||
@@ -38,41 +54,623 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o
|
||||
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
|
||||
const FETCH_TIMEOUT_MS = 1200;
|
||||
|
||||
export function resolveContextDir(cwd = process.cwd()) {
|
||||
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return cwd;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(cwd, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (envDir && envDir.trim()) {
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
return cwd;
|
||||
export function resolveContextDir(cwd = process.cwd(), options = {}) {
|
||||
return resolveContext(cwd, options).contextDir;
|
||||
}
|
||||
|
||||
export function loadContext(cwd = process.cwd()) {
|
||||
const contextDir = resolveContextDir(cwd);
|
||||
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
|
||||
const designPath = firstExisting(contextDir, DESIGN_NAMES);
|
||||
export function loadContext(cwd = process.cwd(), options = {}) {
|
||||
const resolved = resolveContext(cwd, options);
|
||||
const absCwd = path.resolve(cwd);
|
||||
const productPath = resolved.productPath;
|
||||
const designPath = resolved.designPath;
|
||||
const product = productPath ? safeRead(productPath) : null;
|
||||
const design = designPath ? safeRead(designPath) : null;
|
||||
return {
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
productPath: productPath ? path.relative(cwd, productPath) : null,
|
||||
productPath: productPath ? path.relative(absCwd, productPath) : null,
|
||||
hasDesign: !!design,
|
||||
design,
|
||||
designPath: designPath ? path.relative(cwd, designPath) : null,
|
||||
contextDir,
|
||||
designPath: designPath ? path.relative(absCwd, designPath) : null,
|
||||
contextDir: resolved.contextDir,
|
||||
productContextDir: productPath ? path.dirname(productPath) : null,
|
||||
designContextDir: designPath ? path.dirname(designPath) : null,
|
||||
projectRoot: resolved.projectRoot,
|
||||
repoRoot: resolved.repoRoot,
|
||||
isMonorepo: resolved.isMonorepo,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveContext(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const project = resolveProject(absCwd, options);
|
||||
const projectContextDir = resolveLocalContextDir(project.projectRoot);
|
||||
const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
|
||||
? resolveLocalContextDir(project.repoRoot)
|
||||
: null;
|
||||
|
||||
let productPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
|
||||
let designPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
|
||||
|
||||
let envContextDir = null;
|
||||
if (!productPath && !designPath) {
|
||||
envContextDir = resolveEnvContextDir(absCwd);
|
||||
if (envContextDir) {
|
||||
productPath = firstExisting(envContextDir, PRODUCT_NAMES);
|
||||
designPath = firstExisting(envContextDir, DESIGN_NAMES);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contextDir: productPath
|
||||
? path.dirname(productPath)
|
||||
: designPath
|
||||
? path.dirname(designPath)
|
||||
: envContextDir || project.projectRoot,
|
||||
productPath,
|
||||
designPath,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
isMonorepo: project.isMonorepo,
|
||||
targetDir: project.targetDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
|
||||
return resolveProject(cwd, options).projectRoot;
|
||||
}
|
||||
|
||||
export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
|
||||
if (hasTargetOption(options)) return null;
|
||||
const project = resolveProject(cwd);
|
||||
if (
|
||||
!project.isMonorepo
|
||||
|| !project.projectRoot
|
||||
|| !project.repoRoot
|
||||
|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const targetCandidates = discoverTargetCandidates(project.repoRoot);
|
||||
// No discoverable child apps (e.g. `workspaces: ["."]`, a root-only workspace,
|
||||
// or a marker file with no apps/packages children): there is nothing to choose,
|
||||
// so treat the repo root as the active project rather than blocking on an empty
|
||||
// selection prompt that the user cannot answer.
|
||||
if (targetCandidates.length === 0) return null;
|
||||
return {
|
||||
targetPath: null,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
targetCandidates,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProject(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const targetDir = resolveTargetDir(absCwd, options);
|
||||
let repoRoot = findMonorepoRoot(targetDir);
|
||||
if (!repoRoot && targetDir !== absCwd) {
|
||||
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
||||
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
||||
repoRoot = cwdRepoRoot;
|
||||
}
|
||||
}
|
||||
if (!repoRoot) {
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: absCwd,
|
||||
repoRoot: absCwd,
|
||||
isMonorepo: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
|
||||
repoRoot,
|
||||
isMonorepo: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isPathInside(candidate, root) {
|
||||
const rel = path.relative(root, candidate);
|
||||
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function resolveLocalContextDir(root) {
|
||||
if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return root;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(root, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEnvContextDir(cwd) {
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (!envDir || !envDir.trim()) return null;
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
function findMonorepoRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === homeDir) return null;
|
||||
// isMonorepoRoot is checked before hasGitBoundary on purpose: a workspace
|
||||
// root that also carries its own .git is still recognized. The trade-off is
|
||||
// deliberate — a directory with a monorepo *marker* but no workspace patterns
|
||||
// and no apps/packages children is not a monorepo root, so its .git stops
|
||||
// traversal and a further-up root is not searched. The nested .git is treated
|
||||
// as an independent project boundary, which is the intended isolation.
|
||||
if (isMonorepoRoot(dir)) return dir;
|
||||
if (hasGitBoundary(dir)) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return hasFallbackWorkspaceChildren(dir);
|
||||
}
|
||||
|
||||
function hasGitBoundary(dir) {
|
||||
return fs.existsSync(path.join(dir, '.git'));
|
||||
}
|
||||
|
||||
function hasFallbackWorkspaceChildren(dir) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(dir, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function discoverTargetCandidates(repoRoot) {
|
||||
const roots = new Map();
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
for (const pattern of patterns) {
|
||||
for (const root of discoverRootsForPattern(repoRoot, pattern)) {
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(repoRoot, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const root = path.join(base, entry.name);
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots.entries()]
|
||||
.filter(([rel]) => rel && !rel.startsWith('..'))
|
||||
// Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot
|
||||
// sends an excluded package back to the repo root, so an excluded folder must not appear as a
|
||||
// selectable target — choosing it would silently resolve to the root instead.
|
||||
.filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([rel, root]) => {
|
||||
const targetExample = findTargetExample(repoRoot, root);
|
||||
return {
|
||||
name: path.basename(root),
|
||||
path: rel,
|
||||
targetExample,
|
||||
...resolveCandidateContextSummary(repoRoot, root, targetExample),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
|
||||
const ctx = resolveContext(repoRoot, { targetPath });
|
||||
return {
|
||||
productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
|
||||
productPath: contextSourcePath(ctx.productPath, repoRoot),
|
||||
designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
|
||||
designPath: contextSourcePath(ctx.designPath, repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
// Selection candidates surface one of four statuses: 'child' (a canonical
|
||||
// PRODUCT.md/DESIGN.md directly in the app root), 'inherited' (resolved from the
|
||||
// repo root in a monorepo), 'missing' (no file found), and 'fallback'. 'fallback'
|
||||
// intentionally covers two non-canonical locations: a file inside the project
|
||||
// root but in a subdirectory (FALLBACK_DIRS, e.g. `.agents/context/`), and a file
|
||||
// outside both the project and repo roots (IMPECCABLE_CONTEXT_DIR override).
|
||||
function contextSourceStatus(filePath, repoRoot, projectRoot) {
|
||||
if (!filePath) return 'missing';
|
||||
const absPath = path.resolve(filePath);
|
||||
const absProjectRoot = path.resolve(projectRoot);
|
||||
const absRepoRoot = path.resolve(repoRoot);
|
||||
if (isPathInsideOrEqual(absPath, absProjectRoot)) {
|
||||
return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
|
||||
}
|
||||
if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
|
||||
return 'inherited';
|
||||
}
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
function contextSourcePath(filePath, repoRoot) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(repoRoot, filePath);
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
return rel.split(path.sep).join('/');
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function discoverRootsForPattern(repoRoot, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return [];
|
||||
const segments = pattern.split('/').filter(Boolean);
|
||||
if (!segments.length) return [];
|
||||
const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
|
||||
const base = path.join(repoRoot, ...literalPrefix);
|
||||
if (!fs.existsSync(base)) return [];
|
||||
if (segments.includes('**')) {
|
||||
const packageRoots = [];
|
||||
walkDirs(base, (dir) => {
|
||||
if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
|
||||
});
|
||||
if (packageRoots.length) return packageRoots;
|
||||
return directChildDirs(base);
|
||||
}
|
||||
return expandSimplePattern(repoRoot, segments);
|
||||
}
|
||||
|
||||
function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
|
||||
if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
|
||||
const segment = patternSegments[index];
|
||||
if (!segment.includes('*')) {
|
||||
return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const roots = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
if (!segmentMatches(segment, entry.name)) continue;
|
||||
roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function directChildDirs(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function walkDirs(root, visit) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const dir = path.join(root, entry.name);
|
||||
visit(dir);
|
||||
walkDirs(dir, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function isCandidateProjectRoot(dir) {
|
||||
return !!(
|
||||
fs.existsSync(path.join(dir, 'package.json'))
|
||||
|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'src'))
|
||||
|| fs.existsSync(path.join(dir, 'app'))
|
||||
|| fs.existsSync(path.join(dir, 'pages'))
|
||||
|| fs.existsSync(path.join(dir, 'public'))
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnoredWorkspaceDiscoveryDir(name) {
|
||||
return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
|
||||
}
|
||||
|
||||
function findTargetExample(repoRoot, projectRoot) {
|
||||
const examples = [
|
||||
'src/App.jsx',
|
||||
'src/App.tsx',
|
||||
'src/main.jsx',
|
||||
'src/main.tsx',
|
||||
'src/index.jsx',
|
||||
'src/index.ts',
|
||||
'app/page.tsx',
|
||||
'pages/index.tsx',
|
||||
'public/index.html',
|
||||
];
|
||||
for (const rel of examples) {
|
||||
const abs = path.join(projectRoot, rel);
|
||||
if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
|
||||
}
|
||||
return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
|
||||
const rel = path.relative(repoRoot, targetDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
|
||||
if (!excluded) {
|
||||
for (const pattern of patterns) {
|
||||
const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
|
||||
if (projectRoot) return projectRoot;
|
||||
}
|
||||
}
|
||||
if (excluded) return repoRoot;
|
||||
if (
|
||||
relSegments.length >= 2
|
||||
&& MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
|
||||
) {
|
||||
return path.join(repoRoot, relSegments[0], relSegments[1]);
|
||||
}
|
||||
const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
|
||||
if (nearest) return nearest;
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function isExcludedByWorkspacePattern(relSegments, patterns) {
|
||||
return patterns.some((rawPattern) => {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern.startsWith('!')) return false;
|
||||
return workspacePatternMatchesRel(pattern.slice(1), relSegments);
|
||||
});
|
||||
}
|
||||
|
||||
function nearestProjectLikeRoot(repoRoot, targetDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop) {
|
||||
if (
|
||||
firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'package.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(stopDir || repoRoot);
|
||||
const root = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInsideOrEqual(candidate, root) {
|
||||
return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
|
||||
}
|
||||
|
||||
function workspacePatternMatchesRel(pattern, relSegments) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return false;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(repoRoot) {
|
||||
return [
|
||||
...readPackageWorkspaces(repoRoot),
|
||||
...readPnpmWorkspaces(repoRoot),
|
||||
...readLernaWorkspaces(repoRoot),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function readPackageWorkspaces(repoRoot) {
|
||||
const pkg = readJson(path.join(repoRoot, 'package.json'));
|
||||
const workspaces = pkg?.workspaces;
|
||||
if (Array.isArray(workspaces)) return workspaces;
|
||||
if (Array.isArray(workspaces?.packages)) return workspaces.packages;
|
||||
return [];
|
||||
}
|
||||
|
||||
function readLernaWorkspaces(repoRoot) {
|
||||
const lerna = readJson(path.join(repoRoot, 'lerna.json'));
|
||||
return Array.isArray(lerna?.packages) ? lerna.packages : [];
|
||||
}
|
||||
|
||||
function readPnpmWorkspaces(repoRoot) {
|
||||
try {
|
||||
const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
|
||||
const patterns = [];
|
||||
let inPackages = false;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const trimmed = stripYamlInlineComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flowMatch) {
|
||||
patterns.push(...parseYamlFlowList(flowMatch[1]));
|
||||
inPackages = false;
|
||||
continue;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
if (inPackages) {
|
||||
const match = trimmed.match(/^-\s*(.+)$/);
|
||||
if (match) patterns.push(unquoteYamlValue(match[1]));
|
||||
}
|
||||
}
|
||||
return patterns;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stripYamlInlineComment(line) {
|
||||
let quote = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#' && !quote) return line.slice(0, i);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseYamlFlowList(body) {
|
||||
const items = [];
|
||||
let quote = null;
|
||||
let current = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',' && !quote) {
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
return items;
|
||||
}
|
||||
|
||||
function unquoteYamlValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return null;
|
||||
const patternSegments = pattern.split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return null;
|
||||
if (patternSegments.includes('**')) {
|
||||
return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return null;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
|
||||
}
|
||||
return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
|
||||
}
|
||||
|
||||
function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return null;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
|
||||
}
|
||||
const prefixDir = path.join(repoRoot, ...literalPrefix);
|
||||
const targetDir = path.join(repoRoot, ...relSegments);
|
||||
const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
|
||||
if (packageRoot) return packageRoot;
|
||||
return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
|
||||
}
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
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 firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
@@ -89,6 +687,10 @@ function safeRead(p) {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
|
||||
* for a `## Register` section and reading the first non-empty line that
|
||||
@@ -233,7 +835,24 @@ async function computeUpdateDirective(now = Date.now()) {
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
let cliOptions;
|
||||
try {
|
||||
cliOptions = parseCliOptions(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -244,6 +863,10 @@ async function cli() {
|
||||
'Stop the current task, load reference/init.md, and follow its ' +
|
||||
'instructions to write PRODUCT.md before resuming.',
|
||||
];
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
@@ -252,6 +875,10 @@ async function cli() {
|
||||
if (ctx.hasDesign) {
|
||||
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
|
||||
}
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
const register = extractRegister(ctx.product);
|
||||
const next = register
|
||||
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
|
||||
@@ -261,6 +888,60 @@ async function cli() {
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
return parseTargetOptions(args, { strict: true });
|
||||
}
|
||||
|
||||
function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
|
||||
const targetPath = hasTargetOption(options) ? options.targetPath : null;
|
||||
return `RESOLVED_CONTEXT:\n${JSON.stringify({
|
||||
targetPath,
|
||||
...(targetPath ? { targetExists } : {}),
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2)}`;
|
||||
}
|
||||
|
||||
function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
|
||||
if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
|
||||
return !!(
|
||||
ctx.isMonorepo
|
||||
&& (!targetProvided || targetExists === false)
|
||||
&& ctx.projectRoot
|
||||
&& ctx.repoRoot
|
||||
&& path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
|
||||
);
|
||||
}
|
||||
|
||||
function buildMissingTargetDirective() {
|
||||
const script = process.argv[1] || 'context.mjs';
|
||||
return (
|
||||
'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
|
||||
'If the user named a file, route, or child app, do not answer from this output. ' +
|
||||
`Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
|
||||
);
|
||||
}
|
||||
|
||||
function buildTargetSelectionDirective(selection) {
|
||||
return (
|
||||
`TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
|
||||
'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' +
|
||||
'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
|
||||
'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
|
||||
);
|
||||
}
|
||||
|
||||
// Run cli() only when this module is the entry point. Compare realpaths
|
||||
// rather than endsWith(): a loose suffix match also fires for unrelated
|
||||
// scripts like `load-context.mjs`, and realpath tolerates symlinked
|
||||
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
// Output formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatFindingSummary(count) {
|
||||
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
|
||||
}
|
||||
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
|
||||
@@ -39,7 +43,7 @@ function formatFindings(findings, jsonMode) {
|
||||
out.push(` → ${item.description}`);
|
||||
}
|
||||
}
|
||||
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
|
||||
out.push(`\n${formatFindingSummary(findings.length)}`);
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
@@ -86,9 +90,12 @@ 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
|
||||
--gpt Also report GPT-specific provider tells (off by default)
|
||||
--gemini Also report Gemini-specific provider tells (off by default)
|
||||
--no-config Do not apply project config, detector ignores, or DESIGN.md
|
||||
--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
|
||||
--help Show this help message
|
||||
|
||||
@@ -97,6 +104,14 @@ Project config:
|
||||
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:
|
||||
<!-- impeccable-disable overused-font -- exported brand doc -->
|
||||
.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.)
|
||||
@@ -118,6 +133,7 @@ async function detectCli() {
|
||||
});
|
||||
if (args[0] === 'detect') args = args.slice(1);
|
||||
const jsonMode = args.includes('--json');
|
||||
const quietMode = args.includes('--quiet');
|
||||
const helpMode = args.includes('--help');
|
||||
// --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
|
||||
@@ -137,7 +153,12 @@ async function detectCli() {
|
||||
if (args.includes('--gemini')) providers.push('gemini');
|
||||
const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false;
|
||||
const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null;
|
||||
const scanOptions = designSystem ? { providers, designSystem } : { providers };
|
||||
// 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 scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled };
|
||||
if (designSystem) scanOptions.designSystem = designSystem;
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
@@ -169,8 +190,8 @@ async function detectCli() {
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
|
||||
if (!jsonMode) {
|
||||
// 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);
|
||||
@@ -200,7 +221,7 @@ async function detectCli() {
|
||||
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) {
|
||||
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` +
|
||||
@@ -258,6 +279,7 @@ async function detectCli() {
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { GENERIC_FONTS } from '../../shared/constants.mjs';
|
||||
import { isNeutralColor } from '../../shared/color.mjs';
|
||||
import { checkSourceDesignSystem } from '../../design-system.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { filterByProviders } from '../../registry/antipatterns.mjs';
|
||||
import { profileFindings, profileStep } from '../../profile/profiler.mjs';
|
||||
@@ -37,10 +39,11 @@ function shouldRunPageAnalyzers(content, filePath) {
|
||||
}
|
||||
|
||||
function isNeutralBorderColor(str) {
|
||||
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
|
||||
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;
|
||||
const c = m[1].toLowerCase();
|
||||
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
|
||||
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
|
||||
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
|
||||
if (hex) {
|
||||
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
|
||||
@@ -57,10 +60,10 @@ function isNeutralBorderColor(str) {
|
||||
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 >= 1 : n >= 4; },
|
||||
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 >= 1 : n >= 3; },
|
||||
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,
|
||||
@@ -547,7 +550,10 @@ function detectText(content, filePath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return filterByProviders(deduped, options?.providers);
|
||||
const byProvider = filterByProviders(deduped, options?.providers);
|
||||
// Inline `impeccable-disable*` waivers travel with the file; honor them unless
|
||||
// explicitly bypassed (`--no-config` / `--no-inline-ignores`).
|
||||
return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content);
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
mergeDesignSystemFindings,
|
||||
} from '../../design-system.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
||||
import {
|
||||
@@ -223,7 +224,11 @@ async function detectHtml(filePath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return filterByProviders(findings, options.providers);
|
||||
const byProvider = filterByProviders(findings, options.providers);
|
||||
// Static-HTML findings carry no line number, so only whole-file
|
||||
// `impeccable-disable` directives apply here — exactly the standalone-document
|
||||
// waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`.
|
||||
return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html);
|
||||
}
|
||||
|
||||
export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml };
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Inline, in-file ignore directives — eslint-disable-style waivers that live at
|
||||
* the point they apply and travel with the artifact instead of (or alongside)
|
||||
* an ignore in `.impeccable/config.json`.
|
||||
*
|
||||
* A config ignore is the right default for repo-wide policy. This complements it
|
||||
* for the one case config can't cover: a waiver that belongs to a single file and
|
||||
* needs to follow that file when it leaves the repo — a generated/exported
|
||||
* standalone document, an emailed HTML file, a snippet scanned out of context.
|
||||
*
|
||||
* Comment-syntax-agnostic: the directive is a raw token matched anywhere on a
|
||||
* line, so the same marker works across every comment style impeccable scans —
|
||||
* `//`, `/* *\/`, `<!-- -->`, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers
|
||||
* are stripped before the rule list is parsed.
|
||||
*
|
||||
* Syntax (reason optional; eslint `--` or biome `:` separator):
|
||||
*
|
||||
* impeccable-disable <rule>[, <rule>...] [-- reason] whole file
|
||||
* impeccable-disable-line <rule>... [-- reason] the same line
|
||||
* impeccable-disable-next-line <rule>... [-- reason] the following line
|
||||
* impeccable-disable bare / `*` = every rule
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* <!-- impeccable-disable overused-font -- exported brand doc, font is first-party -->
|
||||
* .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/
|
||||
* // impeccable-disable-next-line bounce-easing: intentional playful affordance
|
||||
*
|
||||
* Behavior is suppression, for parity with config ignores: a matched directive
|
||||
* drops the finding. The inline reason is self-documenting in the diff; it is not
|
||||
* required and is discarded at scan time (only used here to keep reason words out
|
||||
* of the parsed rule list).
|
||||
*/
|
||||
|
||||
const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi;
|
||||
|
||||
// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't
|
||||
// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the
|
||||
// space before the closer. `--+>` covers `-->` and any longer dash run.
|
||||
const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/;
|
||||
|
||||
function normalizeRule(token) {
|
||||
return String(token || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
// Split the directive remainder into rule tokens, dropping any human reason that
|
||||
// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever
|
||||
// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:`
|
||||
// are unambiguous separators.
|
||||
function parseRuleList(remainder) {
|
||||
let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim();
|
||||
// Cut off a human reason at the first `--` (eslint) or `:` (biome) separator.
|
||||
const reasonSep = text.match(/\s*(?:--+|:)\s*/);
|
||||
if (reasonSep) text = text.slice(0, reasonSep.index);
|
||||
const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean);
|
||||
if (tokens.length === 0 || tokens.includes('*')) return ['*'];
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function addRules(set, rules) {
|
||||
for (const rule of rules) set.add(rule);
|
||||
}
|
||||
|
||||
function getSet(map, key) {
|
||||
let set = map.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
map.set(key, set);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse every inline ignore directive in a file's raw text.
|
||||
*
|
||||
* Returns sets keyed by the 1-based line the directive *targets* so matching is a
|
||||
* direct lookup:
|
||||
* - file: rules disabled for the whole file
|
||||
* - line: line -> rules disabled on that exact line (disable-line)
|
||||
* - nextLine: line -> rules disabled on that line (disable-next-line on line-1)
|
||||
*
|
||||
* `*` in any set means "every rule".
|
||||
*/
|
||||
function parseInlineIgnores(content) {
|
||||
const result = { file: new Set(), line: new Map(), nextLine: new Map() };
|
||||
const text = typeof content === 'string' ? content : '';
|
||||
// Cheap bail-out: the substring must be present for any directive to exist.
|
||||
// Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`).
|
||||
if (!/impeccable-disable/i.test(text)) return result;
|
||||
|
||||
// Split on `\n` only, exactly as detectText numbers lines, so directive line
|
||||
// keys line up with finding `line` values (incl. on `\r`-only line endings).
|
||||
// The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is
|
||||
// never captured into the rule list.
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
DIRECTIVE_RE.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) {
|
||||
const variant = m[1].toLowerCase();
|
||||
const rules = parseRuleList(m[2]);
|
||||
if (variant === 'disable') {
|
||||
addRules(result.file, rules);
|
||||
} else if (variant === 'disable-line') {
|
||||
addRules(getSet(result.line, i + 1), rules);
|
||||
} else {
|
||||
// disable-next-line on line i+1 targets line i+2.
|
||||
addRules(getSet(result.nextLine, i + 2), rules);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function setMatches(set, rule) {
|
||||
return Boolean(set) && (set.has('*') || set.has(rule));
|
||||
}
|
||||
|
||||
function isInlineIgnored(finding, directives) {
|
||||
const rule = normalizeRule(finding && finding.antipattern);
|
||||
if (!rule) return false;
|
||||
if (setMatches(directives.file, rule)) return true;
|
||||
const line = Number(finding && finding.line) || 0;
|
||||
if (line > 0) {
|
||||
if (setMatches(directives.line.get(line), rule)) return true;
|
||||
if (setMatches(directives.nextLine.get(line), rule)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasDirectives(directives) {
|
||||
return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop findings waived by an inline directive in the same file's source text.
|
||||
* Findings without a usable line number (e.g. static-HTML page-level findings)
|
||||
* are only matched by whole-file directives — which is the standalone-document
|
||||
* case this primitive exists for.
|
||||
*/
|
||||
function applyInlineIgnores(findings, content) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return findings;
|
||||
const directives = parseInlineIgnores(content);
|
||||
if (!hasDirectives(directives)) return findings;
|
||||
return findings.filter((finding) => !isInlineIgnored(finding, directives));
|
||||
}
|
||||
|
||||
export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored };
|
||||
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
|
||||
// manifest is honored by the CLI (once committed to the default branch) and
|
||||
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
|
||||
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
|
||||
provider: '.github',
|
||||
skillRel: '.github/skills/impeccable',
|
||||
destRel: '.github/hooks/impeccable.json',
|
||||
manifest: () => ({
|
||||
version: 1,
|
||||
hooks: {
|
||||
postToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
matcher: 'edit|create|apply_patch',
|
||||
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
|
||||
timeoutSec: TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function readRawConfigFile(filePath) {
|
||||
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
|
||||
// `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;
|
||||
|
||||
@@ -959,13 +959,114 @@ export function resolveTargetFiles(event, projectCwd) {
|
||||
export function resolveHarness(env = {}, event = null) {
|
||||
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
|
||||
if (explicit === 'cursor') return 'cursor';
|
||||
if (explicit === 'github') return 'github';
|
||||
if (explicit === 'claude' || explicit === 'codex') return 'claude';
|
||||
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
|
||||
// has no `tool_name`/`tool_input`. That shape is the discriminator.
|
||||
if (event && typeof event === 'object'
|
||||
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
|
||||
&& event.tool_name === undefined && event.tool_input === undefined) {
|
||||
return 'github';
|
||||
}
|
||||
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
|
||||
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
|
||||
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
|
||||
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
|
||||
// normalizeGitHubEvent). The detector reads the file from disk after the tool
|
||||
// ran, so only the path (not the proposed content) is needed here.
|
||||
export function parseGitHubToolArgs(toolArgs) {
|
||||
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
|
||||
if (typeof toolArgs === 'string' && toolArgs.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolArgs);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
|
||||
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
|
||||
// *** Begin Patch
|
||||
// *** Add File: /abs/app.css
|
||||
// +body { ... }
|
||||
// *** End Patch
|
||||
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
|
||||
// JSON string with the path under `path`. Both must map onto the internal shape.
|
||||
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
|
||||
|
||||
function looksLikeApplyPatch(rawArgs) {
|
||||
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
|
||||
// Guard against an edit/create payload whose edited *content* happens to
|
||||
// contain patch markers: that payload is a JSON object string, whereas a real
|
||||
// apply_patch payload is a raw patch string that does not parse as JSON. Only
|
||||
// treat non-JSON-object strings as apply_patch so edit events still get their
|
||||
// `path` extracted.
|
||||
try {
|
||||
const parsed = JSON.parse(rawArgs);
|
||||
if (parsed && typeof parsed === 'object') return false;
|
||||
} catch { /* not JSON → genuine raw patch */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPatchText(rawArgs) {
|
||||
if (typeof rawArgs === 'string') {
|
||||
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
|
||||
// Defensive: a future Copilot build might JSON-wrap the patch.
|
||||
const parsed = parseGitHubToolArgs(rawArgs);
|
||||
return parsed.patch || parsed.input || parsed.command || '';
|
||||
}
|
||||
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
||||
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeGitHubEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const toolName = event.toolName || event.tool_name || null;
|
||||
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
|
||||
const rawArgs = event.toolArgs;
|
||||
|
||||
let normalizedToolName = toolName;
|
||||
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
|
||||
// resolveTargetFiles() reads the touched paths from tool_input.command when
|
||||
// tool_name is 'apply_patch', so normalize the name even if a future build
|
||||
// sends the patch under a different tool label.
|
||||
const patch = applyPatchText(rawArgs);
|
||||
if (patch) {
|
||||
toolInput.command = patch;
|
||||
normalizedToolName = 'apply_patch';
|
||||
}
|
||||
} else {
|
||||
const args = parseGitHubToolArgs(rawArgs);
|
||||
const filePath = args.path || args.file_path || args.filePath || args.target_file;
|
||||
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: normalizedToolName,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
|
||||
@@ -1200,12 +1301,12 @@ export function setDetectorForTesting(impl) {
|
||||
// session" so the model knows it's a re-mind, not a new finding.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const STEER_LINE = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.';
|
||||
const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.';
|
||||
|
||||
export function renderCleanAck(filePath, opts = {}) {
|
||||
const cwd = opts.cwd || process.cwd();
|
||||
const display = relativize(filePath, cwd);
|
||||
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${STEER_LINE}`;
|
||||
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`;
|
||||
}
|
||||
|
||||
export function renderPendingAck(filePath, knownFindings, opts = {}) {
|
||||
@@ -1261,7 +1362,7 @@ function directiveFooter(display, opts = {}) {
|
||||
'',
|
||||
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
|
||||
'',
|
||||
`Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
|
||||
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -1520,6 +1621,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'cursor') {
|
||||
return JSON.stringify({ additional_context: text });
|
||||
}
|
||||
// GitHub Copilot's postToolUse hook injects context via a top-level
|
||||
// `additionalContext` string (alongside an optional `modifiedResult`).
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
/**
|
||||
* CLI-side reader/writer for the unified `.impeccable` config.
|
||||
*
|
||||
* The CLI (published to npm) and the skill scripts (bundled into the install)
|
||||
* live in separate trees and cannot share runtime code, so this duplicates a
|
||||
* small slice of skill/scripts/hook-lib.mjs — the config-path layout, detector
|
||||
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
|
||||
* ignore filtering, and exclude marker in sync if either side changes.
|
||||
*
|
||||
* Schema (config.json shared / config.local.json gitignored, per-developer):
|
||||
* {
|
||||
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
|
||||
* "hook": { "consent": "accepted" | "declined", ... },
|
||||
* "updateCheck": bool
|
||||
* }
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
||||
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
|
||||
export function getConfigPath(root) {
|
||||
return join(root, '.impeccable', 'config.json');
|
||||
}
|
||||
|
||||
export function getLocalConfigPath(root) {
|
||||
return join(root, '.impeccable', 'config.local.json');
|
||||
}
|
||||
|
||||
function safeReadJson(filePath) {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hookSection(raw) {
|
||||
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
|
||||
}
|
||||
|
||||
function detectorSection(raw) {
|
||||
return raw && raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
|
||||
}
|
||||
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
|
||||
|
||||
const DEFAULT_DETECTION_CONFIG = Object.freeze({
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
designSystem: { enabled: true },
|
||||
});
|
||||
|
||||
function cloneDetectionConfig() {
|
||||
return {
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
|
||||
};
|
||||
}
|
||||
|
||||
function cloneRawDetectionConfig() {
|
||||
return {
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
};
|
||||
}
|
||||
|
||||
function applyDetectionConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
||||
config.designSystem = {
|
||||
...config.designSystem,
|
||||
enabled: raw.designSystem.enabled === false ? false : true,
|
||||
};
|
||||
}
|
||||
if (Array.isArray(raw.ignoreRules)) {
|
||||
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
|
||||
}
|
||||
if (Array.isArray(raw.ignoreFiles)) {
|
||||
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
|
||||
}
|
||||
if (Array.isArray(raw.ignoreValues)) {
|
||||
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function uniqueStrings(values) {
|
||||
return Array.from(new Set(values.map(String)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detector filters shared by `npx impeccable detect` and the design hook.
|
||||
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
|
||||
* the hook is disabled, but they honor the same ignore rules and design-system
|
||||
* toggle.
|
||||
*/
|
||||
export function readDetectionConfig(root) {
|
||||
const config = cloneDetectionConfig();
|
||||
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
|
||||
const raw = safeReadJson(filePath);
|
||||
// Back-compat: old builds stored detector filters under hook.*.
|
||||
applyDetectionConfigSource(config, hookSection(raw));
|
||||
applyDetectionConfigSource(config, detectorSection(raw));
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export function readRawDetectionConfig(root, opts = {}) {
|
||||
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
|
||||
const config = cloneRawDetectionConfig();
|
||||
applyDetectionConfigSource(config, hookSection(raw));
|
||||
applyDetectionConfigSource(config, detectorSection(raw));
|
||||
return config;
|
||||
}
|
||||
|
||||
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
|
||||
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
|
||||
if (opts.local) ensureConfigGitExclude(root);
|
||||
const existing = safeReadJson(filePath) || {};
|
||||
const existingHook = hookSection(existing);
|
||||
const nextHook = stripDetectorKeys(existingHook);
|
||||
const nextDetector = {
|
||||
...(detectorSection(existing) || {}),
|
||||
...normalizeDetectionConfigForWrite(detectorConfig),
|
||||
};
|
||||
const next = {
|
||||
...existing,
|
||||
detector: nextDetector,
|
||||
};
|
||||
if (nextHook && Object.keys(nextHook).length > 0) {
|
||||
next.hook = nextHook;
|
||||
} else {
|
||||
delete next.hook;
|
||||
}
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function normalizeDetectionConfigForWrite(config) {
|
||||
const out = {};
|
||||
if (Array.isArray(config?.ignoreRules)) {
|
||||
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
|
||||
}
|
||||
if (Array.isArray(config?.ignoreFiles)) {
|
||||
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
|
||||
}
|
||||
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
|
||||
out.designSystem = {
|
||||
enabled: config.designSystem.enabled === false ? false : true,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function stripDetectorKeys(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeIgnoreValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeIgnoreRule(rule) {
|
||||
return String(rule || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function colorIgnoreKey(value) {
|
||||
const color = parseIgnoreColor(value);
|
||||
if (!color) return '';
|
||||
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
|
||||
}
|
||||
|
||||
function parseIgnoreColor(value) {
|
||||
const text = String(value || '').trim().toLowerCase();
|
||||
if (!text) return null;
|
||||
|
||||
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
|
||||
if (hex) return parseHexIgnoreColor(hex[1]);
|
||||
|
||||
const rgb = text.match(/^rgba?\((.*)\)$/i);
|
||||
if (rgb) {
|
||||
const parts = splitColorArgs(rgb[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const r = parseRgbChannel(parts[0]);
|
||||
const g = parseRgbChannel(parts[1]);
|
||||
const b = parseRgbChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
if ([r, g, b, a].some((v) => v === null)) return null;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
const hsl = text.match(/^hsla?\((.*)\)$/i);
|
||||
if (hsl) {
|
||||
const parts = splitColorArgs(hsl[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const h = parseHueChannel(parts[0]);
|
||||
const s = parsePercentChannel(parts[1]);
|
||||
const l = parsePercentChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
if ([h, s, l, a].some((v) => v === null)) return null;
|
||||
return hslToRgb(h, s, l, a);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseHexIgnoreColor(hex) {
|
||||
if (hex.length === 3 || hex.length === 4) {
|
||||
const r = parseInt(hex[0] + hex[0], 16);
|
||||
const g = parseInt(hex[1] + hex[1], 16);
|
||||
const b = parseInt(hex[2] + hex[2], 16);
|
||||
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
function splitColorArgs(body) {
|
||||
const text = String(body || '').trim();
|
||||
if (!text) return [];
|
||||
if (text.includes(',')) {
|
||||
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
|
||||
const last = parts[parts.length - 1];
|
||||
if (last && last.includes('/')) {
|
||||
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
|
||||
return [...parts.slice(0, -1), ...split];
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
|
||||
}
|
||||
|
||||
function parseRgbChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const scaled = match[2] ? value * 2.55 : value;
|
||||
if (scaled < 0 || scaled > 255) return null;
|
||||
return Math.round(scaled);
|
||||
}
|
||||
|
||||
function parseAlphaChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const alpha = match[2] ? value / 100 : value;
|
||||
return alpha >= 0 && alpha <= 1 ? alpha : null;
|
||||
}
|
||||
|
||||
function parseHueChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const unit = match[2] || 'deg';
|
||||
if (unit === 'turn') return value * 360;
|
||||
if (unit === 'rad') return value * (180 / Math.PI);
|
||||
if (unit === 'grad') return value * 0.9;
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePercentChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)%$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return value >= 0 && value <= 100 ? value / 100 : null;
|
||||
}
|
||||
|
||||
function hslToRgb(hue, saturation, lightness, alpha) {
|
||||
const h = (((hue % 360) + 360) % 360) / 360;
|
||||
if (saturation === 0) {
|
||||
const gray = clampByte(Math.round(lightness * 255));
|
||||
return { r: gray, g: gray, b: gray, a: alpha };
|
||||
}
|
||||
const q = lightness < 0.5
|
||||
? lightness * (1 + saturation)
|
||||
: lightness + saturation - lightness * saturation;
|
||||
const p = 2 * lightness - q;
|
||||
const toRgb = (t) => {
|
||||
let channel = t;
|
||||
if (channel < 0) channel += 1;
|
||||
if (channel > 1) channel -= 1;
|
||||
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
|
||||
if (channel < 1 / 2) return q;
|
||||
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
|
||||
return p;
|
||||
};
|
||||
return {
|
||||
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
|
||||
g: clampByte(Math.round(toRgb(h) * 255)),
|
||||
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
|
||||
a: alpha,
|
||||
};
|
||||
}
|
||||
|
||||
function clampByte(value) {
|
||||
return Math.min(255, Math.max(0, value));
|
||||
}
|
||||
|
||||
function ignoreValueMatches(rule, entryValue, findingValue) {
|
||||
if (entryValue === findingValue) return true;
|
||||
if (rule !== 'design-system-color') return false;
|
||||
const entryColor = colorIgnoreKey(entryValue);
|
||||
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
|
||||
}
|
||||
|
||||
export function normalizeIgnoreValueEntries(entries) {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
const out = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const rule = normalizeIgnoreRule(entry.rule);
|
||||
const value = normalizeIgnoreValue(entry.value);
|
||||
if (!rule || !value) continue;
|
||||
const normalized = { rule, value };
|
||||
const files = uniqueStrings([
|
||||
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
|
||||
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
|
||||
]);
|
||||
if (files.length > 0) normalized.files = files;
|
||||
if (typeof entry.reason === 'string' && entry.reason.trim()) {
|
||||
normalized.reason = entry.reason.trim();
|
||||
}
|
||||
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
|
||||
normalized.createdAt = entry.createdAt.trim();
|
||||
}
|
||||
out.push(normalized);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeIgnoreValues(existing, incoming) {
|
||||
const map = new Map();
|
||||
for (const entry of normalizeIgnoreValueEntries(existing)) {
|
||||
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
||||
}
|
||||
for (const entry of normalizeIgnoreValueEntries(incoming)) {
|
||||
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
function ignoreValueFilesKey(files) {
|
||||
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
|
||||
}
|
||||
|
||||
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
|
||||
function globToRegex(glob) {
|
||||
let re = '^';
|
||||
let i = 0;
|
||||
while (i < glob.length) {
|
||||
const c = glob[i];
|
||||
if (c === '*') {
|
||||
if (glob[i + 1] === '*') {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
if (glob[i] === '/') i += 1;
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (c === '{') {
|
||||
const end = glob.indexOf('}', i);
|
||||
if (end === -1) { re += '\\{'; i += 1; continue; }
|
||||
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
|
||||
re += `(?:${parts.join('|')})`;
|
||||
i = end + 1;
|
||||
} else if (/[.+^$()|[\]\\]/.test(c)) {
|
||||
re += `\\${c}`;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
re += '$';
|
||||
return new RegExp(re);
|
||||
}
|
||||
|
||||
export function matchesAnyGlob(filePath, globs) {
|
||||
if (!Array.isArray(globs) || globs.length === 0) return false;
|
||||
const normalized = String(filePath || '').split(sep).join('/');
|
||||
for (const glob of globs) {
|
||||
try {
|
||||
const re = globToRegex(String(glob));
|
||||
if (re.test(normalized)) return true;
|
||||
const base = normalized.split('/').pop();
|
||||
if (re.test(base)) return true;
|
||||
} catch {
|
||||
/* malformed glob, skip */
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldIgnoreDetectionFile(filePath, root, config) {
|
||||
const globs = config?.ignoreFiles || [];
|
||||
if (!Array.isArray(globs) || globs.length === 0) return false;
|
||||
const raw = String(filePath || '').trim();
|
||||
if (!raw) return false;
|
||||
if (matchesAnyGlob(raw, globs)) return true;
|
||||
|
||||
try {
|
||||
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
|
||||
if (matchesAnyGlob(abs, globs)) return true;
|
||||
const rel = relative(root, abs);
|
||||
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
|
||||
return matchesAnyGlob(rel, globs);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function filterDetectionFindings(findings, config) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return [];
|
||||
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
|
||||
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
return findings.filter((finding) => {
|
||||
if (!finding || typeof finding !== 'object') return false;
|
||||
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
|
||||
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function isIgnoredFindingValue(finding, ignoreValues) {
|
||||
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
const value = extractFindingIgnoreValue(finding);
|
||||
if (!rule || !value) return false;
|
||||
return ignoreValues.some((entry) => {
|
||||
const wildcardValue = entry.value === '*';
|
||||
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
|
||||
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
|
||||
return findingMatchesScopedIgnoreFile(finding, entry.files);
|
||||
});
|
||||
}
|
||||
|
||||
function findingMatchesScopedIgnoreFile(finding, globs) {
|
||||
const filePath = String(finding?.file || '').trim();
|
||||
if (!filePath) return false;
|
||||
if (matchesAnyGlob(filePath, globs)) return true;
|
||||
|
||||
const normalized = filePath.split(sep).join('/');
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const suffix = parts.slice(i).join('/');
|
||||
if (matchesAnyGlob(suffix, globs)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function extractFindingIgnoreValue(finding) {
|
||||
if (!finding || typeof finding !== 'object') return '';
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
const directValueRules = new Set([
|
||||
'overused-font',
|
||||
'bounce-easing',
|
||||
'design-system-font',
|
||||
'design-system-color',
|
||||
'design-system-radius',
|
||||
]);
|
||||
if (!directValueRules.has(rule)) return '';
|
||||
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
|
||||
}
|
||||
|
||||
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
|
||||
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
|
||||
if (direct) return direct;
|
||||
|
||||
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
|
||||
for (const text of candidates) {
|
||||
if (rule === 'bounce-easing') {
|
||||
const motion = extractMotionIgnoreValue(text);
|
||||
if (motion) return motion;
|
||||
continue;
|
||||
}
|
||||
|
||||
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
|
||||
if (primary) return cleanIgnoreValueDisplay(primary[1]);
|
||||
|
||||
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
|
||||
if (family) return cleanIgnoreValueDisplay(family[1]);
|
||||
|
||||
const google = text.match(/[?&]family=([^&:;\n]+)/i);
|
||||
if (google) {
|
||||
try {
|
||||
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
|
||||
} catch {
|
||||
return cleanIgnoreValueDisplay(google[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractMotionIgnoreValue(text) {
|
||||
const tailwind = text.match(/\banimate-bounce\b/i);
|
||||
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
|
||||
|
||||
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
|
||||
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
|
||||
|
||||
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
|
||||
if (animation) {
|
||||
const token = animation[1]
|
||||
.split(/[,\s]+/)
|
||||
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
||||
if (token) return cleanIgnoreValueDisplay(token);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function cleanIgnoreValueDisplay(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ')
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
|
||||
* config.local.json (per-developer) overrides config.json.
|
||||
*/
|
||||
export function getHookConsent(root) {
|
||||
let consent;
|
||||
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
|
||||
const hook = hookSection(safeReadJson(filePath));
|
||||
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
|
||||
}
|
||||
return consent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the per-developer decision to config.local.json, preserving any
|
||||
* sibling keys, and ensure the file is gitignored.
|
||||
*/
|
||||
export function setHookConsent(root, value) {
|
||||
const filePath = getLocalConfigPath(root);
|
||||
const existing = safeReadJson(filePath) || {};
|
||||
const hook = hookSection(existing) || {};
|
||||
const next = { ...existing, hook: { ...hook, consent: value } };
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
|
||||
ensureConfigGitExclude(root);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
|
||||
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
|
||||
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
|
||||
|
||||
/**
|
||||
* Add config.local.json to `.git/info/exclude` so a developer's decision is
|
||||
* never committed. Idempotent via marker comments. Best-effort; returns false
|
||||
* when there is no resolvable git dir.
|
||||
*/
|
||||
export function ensureConfigGitExclude(root) {
|
||||
try {
|
||||
const gitDir = resolveGitDir(root);
|
||||
if (!gitDir) return false;
|
||||
const target = join(gitDir, 'info', 'exclude');
|
||||
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
|
||||
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
|
||||
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
|
||||
let updated;
|
||||
if (markerRe.test(existing)) {
|
||||
updated = existing.replace(markerRe, block);
|
||||
} else {
|
||||
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
|
||||
updated = `${prefix}${block}\n`;
|
||||
}
|
||||
if (updated !== existing) {
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, updated);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGitDir(root) {
|
||||
const dotGit = join(root, '.git');
|
||||
if (!existsSync(dotGit)) return null;
|
||||
try {
|
||||
if (statSync(dotGit).isDirectory()) return dotGit;
|
||||
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
|
||||
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
|
||||
if (match) {
|
||||
const resolved = match[1].trim();
|
||||
return isAbsolute(resolved) ? resolved : join(root, resolved);
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -1,50 +1,52 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
export const CRITIQUE_DIR = 'critique';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
export function getImpeccableDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
const projectRoot = resolveProjectRoot(cwd, options);
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
getDesignSidecarPath(cwd, options),
|
||||
path.join(projectRoot, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
export function getLiveDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
export function getLiveConfigPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
const primary = getLiveConfigPath(cwd, { targetPath });
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
@@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
export function getLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function readLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
|
||||
@@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) {
|
||||
const filePath = getLiveServerPath(cwd, options);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'annotations');
|
||||
}
|
||||
|
||||
export function getCritiqueDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), CRITIQUE_DIR);
|
||||
export function getCritiqueDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR);
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
class TargetArgError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'TargetArgError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTargetPath(args = [], { strict = false } = {}) {
|
||||
let targetPath = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i]);
|
||||
if (arg === '--target' || arg === '-t') {
|
||||
const next = args[i + 1];
|
||||
if (next && !String(next).startsWith('-')) {
|
||||
targetPath = String(next);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--target=')) {
|
||||
const value = arg.slice('--target='.length);
|
||||
if (value) {
|
||||
targetPath = value;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export function parseTargetOptions(args = [], options = {}) {
|
||||
const targetPath = parseTargetPath(args, options);
|
||||
return targetPath ? { targetPath } : {};
|
||||
}
|
||||
@@ -57,7 +57,7 @@
|
||||
const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
|
||||
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint
|
||||
const PREFIX = 'impeccable-live';
|
||||
const PICK_CURSOR_CLASS = PREFIX + '-pick-cursor';
|
||||
const PICK_CURSOR_STYLE_ID = PREFIX + '-pick-cursor-style';
|
||||
const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000;
|
||||
const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({
|
||||
prefix: PREFIX,
|
||||
@@ -152,6 +152,7 @@
|
||||
let scrollLockTargetY = null;
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock';
|
||||
|
||||
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
@@ -1915,45 +1916,45 @@
|
||||
syncPageInteractionCursor();
|
||||
}
|
||||
|
||||
let pageInteractionCursorActive = false;
|
||||
|
||||
function ensurePickCursorStyle() {
|
||||
if (document.getElementById(PREFIX + '-pick-cursor-style')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = PREFIX + '-pick-cursor-style';
|
||||
/**
|
||||
* Drive the page-level pick / insert cursor through the textContent of one
|
||||
* injected <style>, never by mutating <html> (className or inline style).
|
||||
* Frameworks that server-render the <html>/<body> roots (Next.js App Router)
|
||||
* report a React 19 hydration mismatch when the client adds an attribute the
|
||||
* server HTML never emitted, so a `class`/inline `style` toggled on
|
||||
* `document.documentElement` trips "a tree hydrated but some attributes ...
|
||||
* didn't match" on the next Fast-Refresh re-render. Keying the cursor off a
|
||||
* stable-id <style> keeps the effect off the hydrated host elements (same
|
||||
* shape as the scroll-anchor lock). A falsy cursor clears the rule.
|
||||
*/
|
||||
function setPageInteractionCursor(cursor) {
|
||||
let style = document.getElementById(PICK_CURSOR_STYLE_ID);
|
||||
if (!cursor) {
|
||||
if (style) style.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = PICK_CURSOR_STYLE_ID;
|
||||
// Styles the host page, not the chrome - inside the adapter's shadow UI
|
||||
// root (uiAppendStyle's target) these selectors would match nothing.
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
}
|
||||
style.textContent =
|
||||
'html.' + PICK_CURSOR_CLASS + ' * { cursor: crosshair !important; }\n'
|
||||
+ 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"],\n'
|
||||
+ 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"] * { cursor: revert !important; }';
|
||||
// Styles the host page, not the chrome - inside the adapter's shadow UI
|
||||
// root (uiAppendStyle's target) these selectors would match nothing.
|
||||
document.head.appendChild(style);
|
||||
'* { cursor: ' + cursor + ' !important; }\n'
|
||||
+ '[id^="' + PREFIX + '"],\n'
|
||||
+ '[id^="' + PREFIX + '"] * { cursor: revert !important; }';
|
||||
}
|
||||
|
||||
/** Page-level cursor while pick or insert mode is targeting page elements. */
|
||||
function syncPageInteractionCursor() {
|
||||
const pickCursor = state === 'PICKING' && pickActive && !insertActive;
|
||||
let axisCursor = '';
|
||||
if (state === 'PICKING' && insertActive) {
|
||||
axisCursor = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : '';
|
||||
}
|
||||
|
||||
if (pickCursor) {
|
||||
ensurePickCursorStyle();
|
||||
document.documentElement.classList.add(PICK_CURSOR_CLASS);
|
||||
document.documentElement.style.cursor = '';
|
||||
pageInteractionCursorActive = true;
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.classList.remove(PICK_CURSOR_CLASS);
|
||||
if (axisCursor) {
|
||||
document.documentElement.style.cursor = axisCursor;
|
||||
pageInteractionCursorActive = true;
|
||||
} else if (pageInteractionCursorActive) {
|
||||
document.documentElement.style.cursor = '';
|
||||
pageInteractionCursorActive = false;
|
||||
let cursor = '';
|
||||
if (state === 'PICKING' && pickActive && !insertActive) {
|
||||
cursor = 'crosshair';
|
||||
} else if (state === 'PICKING' && insertActive && insertHoverAnchor) {
|
||||
cursor = cursorForInsertAxis(insertHoverAxis || 'column');
|
||||
}
|
||||
setPageInteractionCursor(cursor);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5815,10 +5816,22 @@
|
||||
|
||||
try { history.scrollRestoration = 'manual'; } catch {}
|
||||
|
||||
const prevHtmlAnchor = document.documentElement.style.overflowAnchor;
|
||||
const prevBodyAnchor = document.body.style.overflowAnchor;
|
||||
document.documentElement.style.overflowAnchor = 'none';
|
||||
document.body.style.overflowAnchor = 'none';
|
||||
// Suppress the browser's scroll-anchoring on the scroll root so it can't
|
||||
// fight our manual scroll correction. Apply this as a stylesheet rule, not
|
||||
// as inline `style` on <html>/<body>: those elements are server-rendered by
|
||||
// frameworks like Next.js App Router, and mutating their inline style makes
|
||||
// React 19 report a hydration mismatch on the next Fast-Refresh re-render.
|
||||
// A <style> rule has the same computed effect without touching any hydrated
|
||||
// element's attributes. Like the inline version, it is recreated on every
|
||||
// startScrollLock call, so reload survival (driven by the persisted scroll
|
||||
// key) is unaffected.
|
||||
let anchorLockStyle = document.getElementById(SCROLL_ANCHOR_LOCK_ID);
|
||||
if (!anchorLockStyle) {
|
||||
anchorLockStyle = document.createElement('style');
|
||||
anchorLockStyle.id = SCROLL_ANCHOR_LOCK_ID;
|
||||
anchorLockStyle.textContent = 'html,body{overflow-anchor:none !important;}';
|
||||
(document.head || document.documentElement).appendChild(anchorLockStyle);
|
||||
}
|
||||
|
||||
const correct = (why) => {
|
||||
scrollLockRaf = null;
|
||||
@@ -5853,8 +5866,7 @@
|
||||
|
||||
scrollLockAbort = new AbortController();
|
||||
scrollLockAbort.signal.addEventListener('abort', () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
document.getElementById(SCROLL_ANCHOR_LOCK_ID)?.remove();
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
// Track whether the most recent scroll came from a user gesture. We
|
||||
@@ -6489,10 +6501,13 @@
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isPageEditableElement(deepActive) && !isInlineEditActive(deepActive)) {
|
||||
return;
|
||||
}
|
||||
// While a contenteditable text-leaf is focused, let the browser handle
|
||||
// all keys except Escape. Escape cancels the current edit (restores
|
||||
// original text) and blurs without saving, staying in CONFIGURING.
|
||||
if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
|
||||
if (e.target.isContentEditable && isInlineEditActive(e.target)) {
|
||||
if (e.key !== 'Escape') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -8186,7 +8201,7 @@ void main() {
|
||||
let voiceInterimBase = '';
|
||||
/** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
|
||||
let voiceCtx = null;
|
||||
const PAGE_CHAT_COLLAPSED_W = '88px';
|
||||
const PAGE_CHAT_COLLAPSED_W = '104px';
|
||||
const PAGE_CHAT_PROCESSING_W = '76px';
|
||||
const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
|
||||
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
|
||||
@@ -8197,7 +8212,7 @@ void main() {
|
||||
const GLOBAL_BAR_SECTION_GAP = 8;
|
||||
const GLOBAL_BAR_INNER_GAP = 2;
|
||||
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
|
||||
const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
|
||||
const PAGE_CHAT_EXPANDED_MAX_W = 280;
|
||||
const ICON_PAGE_CHAT =
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
||||
const ICON_PAGE_VOICE =
|
||||
@@ -8277,6 +8292,52 @@ void main() {
|
||||
return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
|
||||
}
|
||||
|
||||
function globalBarModeToggles() {
|
||||
return [
|
||||
uiGetById(PREFIX + '-pick-toggle'),
|
||||
uiGetById(PREFIX + '-insert-toggle'),
|
||||
uiGetById(PREFIX + '-detect-toggle'),
|
||||
uiGetById(PREFIX + '-design-toggle'),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function applyGlobalBarLabelState(expandInactive, forceCollapse = false) {
|
||||
globalBarModeToggles().forEach((toggle) => {
|
||||
if (forceCollapse) toggle._collapseLabel?.(true);
|
||||
else if (expandInactive || toggle.dataset.active === 'true') toggle._expandLabel?.();
|
||||
else toggle._collapseLabel?.();
|
||||
});
|
||||
}
|
||||
|
||||
function syncGlobalBarExpandedLabels(expanded = globalBarEl?.matches(':hover')) {
|
||||
const expandInactive = !!(expanded && !pageChatExpanded);
|
||||
applyGlobalBarLabelState(expandInactive, pageChatExpanded);
|
||||
|
||||
if (expandInactive && globalBarEl && globalBarEl.scrollWidth > window.innerWidth - 16) {
|
||||
applyGlobalBarLabelState(false);
|
||||
}
|
||||
}
|
||||
|
||||
function pageChatCollapsedWidthPx() {
|
||||
const parsed = parseFloat(PAGE_CHAT_COLLAPSED_W);
|
||||
return Number.isFinite(parsed) ? parsed : 104;
|
||||
}
|
||||
|
||||
function pageChatExpandedWidth() {
|
||||
if (!pageChatEl || !globalBarEl) return PAGE_CHAT_EXPANDED_MAX_W + 'px';
|
||||
const currentChatWidth = pageChatEl.getBoundingClientRect().width || pageChatCollapsedWidthPx();
|
||||
const barWidth = Math.max(globalBarEl.getBoundingClientRect().width || 0, globalBarEl.scrollWidth || 0);
|
||||
const nonChatWidth = Math.max(0, barWidth - currentChatWidth);
|
||||
const available = window.innerWidth - 16 - nonChatWidth;
|
||||
const next = Math.max(pageChatCollapsedWidthPx(), Math.min(PAGE_CHAT_EXPANDED_MAX_W, available));
|
||||
return Math.round(next) + 'px';
|
||||
}
|
||||
|
||||
function syncPageChatExpandedWidth() {
|
||||
if (!pageChatEl || !pageChatExpanded) return;
|
||||
pageChatEl.style.width = pageChatExpandedWidth();
|
||||
}
|
||||
|
||||
function syncPageChatChrome() {
|
||||
if (!pageChatEl) return;
|
||||
const P = pageChatPalette();
|
||||
@@ -8312,6 +8373,21 @@ void main() {
|
||||
&& !steerLocked;
|
||||
}
|
||||
|
||||
function isPageEditableElement(el) {
|
||||
if (!el || own(el)) return false;
|
||||
if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return true;
|
||||
return !!el.isContentEditable;
|
||||
}
|
||||
|
||||
function isInlineEditActive(el) {
|
||||
return !!el && inlineEditRows.some((r) => r.el === el);
|
||||
}
|
||||
|
||||
function isPageEditableActive() {
|
||||
const active = activeElementDeep();
|
||||
return isPageEditableElement(active) && !isInlineEditActive(active);
|
||||
}
|
||||
|
||||
function pageHasHostTextSelection() {
|
||||
const sel = window.getSelection?.();
|
||||
if (!sel || sel.isCollapsed) return false;
|
||||
@@ -8325,6 +8401,7 @@ void main() {
|
||||
function shouldSteerAutoFocus() {
|
||||
return shouldFocusSteerChat()
|
||||
&& !steerFocusSuspended
|
||||
&& !isPageEditableActive()
|
||||
&& performance.now() >= steerFocusPauseUntil;
|
||||
}
|
||||
|
||||
@@ -8562,7 +8639,8 @@ void main() {
|
||||
if (!pageChatEl || !pageChatInput) return false;
|
||||
pageChatExpanded = true;
|
||||
pageChatEl.dataset.expanded = 'true';
|
||||
pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
|
||||
syncGlobalBarExpandedLabels(false);
|
||||
pageChatEl.style.width = pageChatExpandedWidth();
|
||||
pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
|
||||
pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED;
|
||||
if (pageChatHint) {
|
||||
@@ -8657,7 +8735,7 @@ void main() {
|
||||
pageChatEl.setAttribute('aria-label', 'Steer the page');
|
||||
pageChatExpanded = keepExpanded;
|
||||
pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
|
||||
pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
|
||||
pageChatEl.style.width = keepExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
|
||||
pageChatEl.style.cursor = 'pointer';
|
||||
if (pageChatInput) {
|
||||
pageChatInput.disabled = false;
|
||||
@@ -8971,6 +9049,7 @@ void main() {
|
||||
pageChatEl.dataset.expanded = 'false';
|
||||
pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
|
||||
pageChatEl.style.cursor = 'pointer';
|
||||
syncGlobalBarExpandedLabels(globalBarEl?.matches(':hover'));
|
||||
if (blur) {
|
||||
pageChatInput.blur();
|
||||
pageChatInput.style.pointerEvents = 'none';
|
||||
@@ -9270,6 +9349,7 @@ void main() {
|
||||
zIndex: Z.bar + 5,
|
||||
display: 'flex', alignItems: 'stretch',
|
||||
gap: '0',
|
||||
width: 'max-content',
|
||||
background: P.surface,
|
||||
border: '1px solid ' + P.border,
|
||||
borderRadius: '8px',
|
||||
@@ -9277,6 +9357,8 @@ void main() {
|
||||
fontFamily: FONT, fontSize: '12px', lineHeight: '1',
|
||||
opacity: '0',
|
||||
overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
});
|
||||
globalBarEl.id = PREFIX + '-global-bar';
|
||||
@@ -9325,6 +9407,7 @@ void main() {
|
||||
const inner = el('div', {
|
||||
display: 'flex', alignItems: 'center',
|
||||
padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
|
||||
flex: '0 0 auto',
|
||||
});
|
||||
inner.id = PREFIX + '-global-bar-inner';
|
||||
globalBarEl.appendChild(inner);
|
||||
@@ -9333,7 +9416,10 @@ void main() {
|
||||
function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
|
||||
const b = el('button', {
|
||||
position: 'relative',
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
minWidth: '30px',
|
||||
padding: '6px 8px', borderRadius: '7px',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
|
||||
@@ -9352,8 +9438,8 @@ void main() {
|
||||
if (!labelEl) return;
|
||||
labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
|
||||
};
|
||||
const collapse = () => {
|
||||
if (!labelEl || b.dataset.active === 'true') return;
|
||||
const collapse = (force = false) => {
|
||||
if (!labelEl || (!force && b.dataset.active === 'true')) return;
|
||||
labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
|
||||
};
|
||||
// Per-button hover only changes color (no layout). The label expand/
|
||||
@@ -9604,6 +9690,7 @@ void main() {
|
||||
width: '1px', height: '18px',
|
||||
background: P.hairline,
|
||||
margin: '0 4px 0 2px',
|
||||
flexShrink: '0',
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
@@ -9620,6 +9707,7 @@ void main() {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0', boxSizing: 'border-box',
|
||||
width: '24px', height: '24px', borderRadius: '6px',
|
||||
flexShrink: '0',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
|
||||
@@ -9632,16 +9720,16 @@ void main() {
|
||||
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
|
||||
inner.appendChild(exitBtn);
|
||||
|
||||
// Bar-level hover: expand every toggle's label at once; collapse on leave.
|
||||
// Bar-level hover: expand mode labels unless Steer is using the space.
|
||||
// Buttons with dataset.active="true" ignore collapse (their label stays).
|
||||
const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
|
||||
globalBarEl.addEventListener('mouseenter', () => {
|
||||
toggles.forEach((t) => t._expandLabel && t._expandLabel());
|
||||
syncGlobalBarExpandedLabels(true);
|
||||
syncPageChatExpandedWidth();
|
||||
schedulePendingDockPosition();
|
||||
setTimeout(schedulePendingDockPosition, 260);
|
||||
});
|
||||
globalBarEl.addEventListener('mouseleave', () => {
|
||||
toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
|
||||
syncGlobalBarExpandedLabels(false);
|
||||
schedulePendingDockPosition();
|
||||
setTimeout(schedulePendingDockPosition, 260);
|
||||
});
|
||||
@@ -9659,6 +9747,7 @@ void main() {
|
||||
pendingDockResizeObserver.observe(globalBarEl);
|
||||
}
|
||||
window.addEventListener('resize', positionPendingDock);
|
||||
window.addEventListener('resize', syncPageChatExpandedWidth);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
globalBarEl.style.opacity = '1';
|
||||
@@ -9705,9 +9794,7 @@ void main() {
|
||||
// If the bar is currently under the cursor, keep all labels expanded -
|
||||
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
|
||||
// would collapse its label while the user's mouse is still on the bar.
|
||||
if (globalBarEl && globalBarEl.matches(':hover')) {
|
||||
[pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
|
||||
}
|
||||
syncGlobalBarExpandedLabels(globalBarEl && globalBarEl.matches(':hover'));
|
||||
|
||||
if (detectBadge) {
|
||||
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
|
||||
@@ -9896,7 +9983,7 @@ void main() {
|
||||
// Remove detection overlays
|
||||
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
|
||||
setLiveState('IDLE');
|
||||
document.getElementById(PREFIX + '-pick-cursor-style')?.remove();
|
||||
document.getElementById(PICK_CURSOR_STYLE_ID)?.remove();
|
||||
window.__IMPECCABLE_LIVE_INIT__ = false;
|
||||
console.log('[impeccable] Live mode exited.');
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* CLI client for the live variant mode poll/reply protocol.
|
||||
*
|
||||
* Usage:
|
||||
* npx impeccable poll # Block until browser event, print JSON
|
||||
* npx impeccable poll --stream # Experimental: keep polling; one JSON line per event
|
||||
* npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly
|
||||
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
|
||||
* npx impeccable poll --reply <id> error "msg" # Reply with error
|
||||
* node <scripts_path>/live-poll.mjs # Block until browser event, print JSON
|
||||
* node <scripts_path>/live-poll.mjs --stream # Experimental: keep polling; one JSON line per event
|
||||
* node <scripts_path>/live-poll.mjs --timeout=600000 # Custom timeout (ms); default is long-poll friendly
|
||||
* node <scripts_path>/live-poll.mjs --reply <id> done # Reply "done" to event <id>
|
||||
* node <scripts_path>/live-poll.mjs --reply <id> error "msg" # Reply with error
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
@@ -15,6 +15,11 @@ import { fileURLToPath } from 'node:url';
|
||||
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
|
||||
// Absolute path to a sibling script in this skill's scripts dir, so runtime
|
||||
// error hints print a directly-runnable command instead of a placeholder.
|
||||
const SELF_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const scriptCmd = (name) => `node "${path.join(SELF_DIR, name)}"`;
|
||||
|
||||
// Node's built-in fetch (undici under the hood) enforces a 300s headers
|
||||
// timeout that can't be lowered per-request. We cap each request below
|
||||
// that ceiling and loop in `pollOnce` to synthesize a long poll without
|
||||
@@ -27,7 +32,7 @@ const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_ed
|
||||
function readServerInfo() {
|
||||
const record = readLiveServerInfo(process.cwd());
|
||||
if (!record) {
|
||||
console.error('No running live server found. Start one with: npx impeccable live');
|
||||
console.error(`No running live server found. Start one with: ${scriptCmd('live.mjs')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return record.info;
|
||||
@@ -82,7 +87,7 @@ export function parseReplyArgs(args) {
|
||||
}
|
||||
|
||||
function validateReplyArgs({ id, status }) {
|
||||
const usage = "Usage: npx impeccable poll --reply <id> <status> [--file path] [--data '<json>'] [message]";
|
||||
const usage = `Usage: ${scriptCmd('live-poll.mjs')} --reply <id> <status> [--file path] [--data '<json>'] [message]`;
|
||||
if (!id || id.startsWith('--')) {
|
||||
const err = new Error(`${usage}\nMissing event id after --reply.`);
|
||||
err.code = 'INVALID_REPLY_ARGS';
|
||||
@@ -283,11 +288,11 @@ export async function runPollStream(base, token, {
|
||||
function handlePollError(err) {
|
||||
if (err.code === 'AUTH_FAILED') {
|
||||
console.error(err.message);
|
||||
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
|
||||
console.error(`Try restarting: ${scriptCmd('live-server.mjs')} stop && ${scriptCmd('live.mjs')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (err.cause?.code === 'ECONNREFUSED') {
|
||||
console.error('Live server not running. Start one with: npx impeccable live');
|
||||
console.error(`Live server not running. Start one with: ${scriptCmd('live.mjs')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (err.code === 'ACK_TIMEOUT') {
|
||||
@@ -331,7 +336,7 @@ Harness note:
|
||||
const info = readServerInfo();
|
||||
const base = `http://localhost:${info.port}`;
|
||||
|
||||
// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [--data '<json>'] [message]
|
||||
// Reply mode: node <scripts_path>/live-poll.mjs --reply <id> <status> [--file path] [--data '<json>'] [message]
|
||||
if (args.includes('--reply')) {
|
||||
let reply;
|
||||
try {
|
||||
@@ -345,7 +350,7 @@ Harness note:
|
||||
await postReply(base, info.token, reply);
|
||||
} catch (err) {
|
||||
if (err.cause?.code === 'ECONNREFUSED') {
|
||||
console.error('Live server not running. Start one with: npx impeccable live');
|
||||
console.error(`Live server not running. Start one with: ${scriptCmd('live.mjs')}`);
|
||||
} else {
|
||||
console.error('Reply failed:', err.message);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './lib/design-parser.mjs';
|
||||
import { resolveContextDir } from './context.mjs';
|
||||
import { loadContext } from './context.mjs';
|
||||
import {
|
||||
assembleLiveBrowserScript,
|
||||
assertLiveBrowserScriptParts,
|
||||
@@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const PROJECT_CONTEXT = loadContext(process.cwd());
|
||||
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
|
||||
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
|
||||
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
|
||||
: null;
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
|
||||
@@ -371,10 +375,7 @@ function hasProjectContext() {
|
||||
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
||||
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
||||
// concern, surfaced by the design panel's own empty state.
|
||||
try {
|
||||
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
return !!PROJECT_CONTEXT.hasProduct;
|
||||
}
|
||||
|
||||
function statOrNull(filePath) {
|
||||
@@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdPath = DESIGN_MD_PATH;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from './context.mjs';
|
||||
import { parseTargetPath } from './lib/target-args.mjs';
|
||||
|
||||
export function resolveLiveTarget(cwd = process.cwd(), args = []) {
|
||||
const originalCwd = path.resolve(cwd);
|
||||
let targetPath = null;
|
||||
try {
|
||||
targetPath = parseTargetPath(args, { strict: true });
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const absoluteTargetPath = targetPath
|
||||
? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath)
|
||||
: null;
|
||||
const projectRoot = targetPath
|
||||
? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath })
|
||||
: originalCwd;
|
||||
return {
|
||||
originalCwd,
|
||||
projectRoot,
|
||||
targetPath,
|
||||
absoluteTargetPath,
|
||||
targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {},
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* CLI helper: find an element in source and wrap it in a variant container.
|
||||
*
|
||||
* Usage:
|
||||
* npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
|
||||
* node <scripts_path>/live-wrap.mjs --id SESSION_ID --count N --query "hero-combined-left" [--file path]
|
||||
*
|
||||
* Searches project files for the element matching the query (class name, ID, or
|
||||
* text snippet), wraps it with the variant scaffolding, and prints the file path
|
||||
|
||||
@@ -21,14 +21,16 @@ import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './context.mjs';
|
||||
import { loadContext, resolveTargetSelection } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { resolveLiveTarget } from './live-target.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
const liveTarget = resolveLiveTarget(process.cwd(), args);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: node live.mjs
|
||||
@@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command:
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
{ ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }
|
||||
|
||||
On target_selection_required, prints:
|
||||
{ ok: false, error: "target_selection_required", targetCandidates }
|
||||
|
||||
On config_missing, prints:
|
||||
{ ok: false, error: "config_missing", configPath, hint }
|
||||
|
||||
The agent should then:
|
||||
1. If config_missing, create the config and re-run this script
|
||||
2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
3. Enter the poll loop: node live-poll.mjs`);
|
||||
1. If target_selection_required, ask which app to use and rerun from that child cwd
|
||||
2. If config_missing, create the config and re-run this script
|
||||
3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
4. Enter the poll loop: node live-poll.mjs`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
if (targetSelection) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'target_selection_required',
|
||||
...targetSelection,
|
||||
hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.',
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
const activeCwd = ctx.projectRoot;
|
||||
const outputTargetPath = liveTarget.targetPath || null;
|
||||
|
||||
const missingContext = missingLiveContext(ctx);
|
||||
if (missingContext.length > 0) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'context_missing',
|
||||
missing: missingContext,
|
||||
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 1. Check config (fail fast if missing — no point starting anything else)
|
||||
const checkOut = runScript('live-inject.mjs', ['--check']);
|
||||
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
|
||||
const checkResult = safeParse(checkOut);
|
||||
if (!checkResult || !checkResult.ok) {
|
||||
console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut }));
|
||||
console.log(JSON.stringify({
|
||||
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 2. Start server (or reuse existing)
|
||||
const serverInfo = ensureServerRunning();
|
||||
const serverInfo = ensureServerRunning(activeCwd);
|
||||
if (!serverInfo) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Inject the script tag at the current port
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]);
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd });
|
||||
const injectResult = safeParse(injectOut);
|
||||
if (!injectResult || !injectResult.ok) {
|
||||
console.log(JSON.stringify({
|
||||
@@ -80,22 +123,23 @@ The agent should then:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Load PRODUCT.md + DESIGN.md context.
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// 4. Compute drift-heal: compare resolved inject targets against the
|
||||
// project's HTML files. Orphans are HTML files not covered by config.
|
||||
// Warning only — the agent decides whether to act.
|
||||
const resolvedFiles = resolveFiles(process.cwd(), checkResult.config);
|
||||
const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config);
|
||||
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
|
||||
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
// 5. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: resolvedFiles,
|
||||
liveConfigPath: checkResult.path,
|
||||
configDrift: drift,
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -105,6 +149,13 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function missingLiveContext(ctx) {
|
||||
const missing = [];
|
||||
if (!ctx.hasProduct) missing.push('PRODUCT.md');
|
||||
if (!ctx.hasDesign) missing.push('DESIGN.md');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
@@ -201,11 +252,11 @@ function globToRegex(pattern) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runScript(name, args) {
|
||||
function runScript(name, args, options = {}) {
|
||||
const scriptPath = path.join(__dirname, name);
|
||||
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
||||
try {
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 });
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
|
||||
} catch (err) {
|
||||
// execSync throws on non-zero exit; return stdout if any
|
||||
return err.stdout || err.message || '';
|
||||
@@ -219,10 +270,10 @@ function safeParse(out) {
|
||||
/**
|
||||
* Return { pid, port, token } for the running live server, starting one if needed.
|
||||
*/
|
||||
function ensureServerRunning() {
|
||||
function ensureServerRunning(cwd = process.cwd()) {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
const existing = readLiveServerInfo(cwd)?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
@@ -232,7 +283,7 @@ function ensureServerRunning() {
|
||||
} catch { /* no PID file */ }
|
||||
|
||||
// Start a new server
|
||||
const out = runScript('live-server.mjs', ['--background']);
|
||||
const out = runScript('live-server.mjs', ['--background'], { cwd });
|
||||
return safeParse(out);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"postToolUse": [
|
||||
{
|
||||
"type": "command",
|
||||
"matcher": "edit|create|apply_patch",
|
||||
"bash": "node \"$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs\"",
|
||||
"timeoutSec": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: impeccable
|
||||
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
|
||||
version: 3.7.0
|
||||
version: 3.8.0
|
||||
user-invocable: true
|
||||
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
|
||||
license: Apache 2.0
|
||||
@@ -13,7 +13,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
|
||||
|
||||
You MUST do these steps before proceeding:
|
||||
|
||||
1. Run `node .github/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
1. Run `node .github/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .github/skills/impeccable/scripts/context.mjs --target <path>` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code and Codex use `PostToolUse` and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project).
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
|
||||
On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands.
|
||||
|
||||
@@ -51,7 +51,7 @@ Prefer the narrowest exception:
|
||||
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
|
||||
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
|
||||
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
|
||||
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
|
||||
- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable <rule>` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it.
|
||||
|
||||
Example value-specific exception:
|
||||
|
||||
@@ -81,8 +81,8 @@ node .github/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
|
||||
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
|
||||
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code and Codex do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR
|
||||
|
||||
Execute in order. No step skipped, no step reordered.
|
||||
|
||||
1. `live.mjs`: boot.
|
||||
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .github/skills/impeccable/scripts/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
|
||||
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* init flow.
|
||||
*
|
||||
* Path resolution (first match wins):
|
||||
* 1. cwd, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. .agents/context/ then docs/
|
||||
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. Active project .agents/context/ then docs/
|
||||
* 3. Monorepo root context, using the same order, as a per-file fallback
|
||||
* 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* escape hatch, only consulted when defaults are empty
|
||||
* 4. cwd as a "nothing found" default
|
||||
* 5. Active project root as a "nothing found" default
|
||||
*
|
||||
* `resolveContextDir()` and `loadContext()` are also exported for the
|
||||
* server-side scripts (live.mjs, live-server.mjs) that need the structured
|
||||
@@ -19,10 +20,25 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.svelte-kit',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'coverage',
|
||||
]);
|
||||
|
||||
// ─── Update check ──────────────────────────────────────────────────────────
|
||||
// Piggyback a lightweight skill-version check on the once-per-session boot.
|
||||
@@ -38,41 +54,623 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o
|
||||
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
|
||||
const FETCH_TIMEOUT_MS = 1200;
|
||||
|
||||
export function resolveContextDir(cwd = process.cwd()) {
|
||||
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return cwd;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(cwd, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (envDir && envDir.trim()) {
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
return cwd;
|
||||
export function resolveContextDir(cwd = process.cwd(), options = {}) {
|
||||
return resolveContext(cwd, options).contextDir;
|
||||
}
|
||||
|
||||
export function loadContext(cwd = process.cwd()) {
|
||||
const contextDir = resolveContextDir(cwd);
|
||||
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
|
||||
const designPath = firstExisting(contextDir, DESIGN_NAMES);
|
||||
export function loadContext(cwd = process.cwd(), options = {}) {
|
||||
const resolved = resolveContext(cwd, options);
|
||||
const absCwd = path.resolve(cwd);
|
||||
const productPath = resolved.productPath;
|
||||
const designPath = resolved.designPath;
|
||||
const product = productPath ? safeRead(productPath) : null;
|
||||
const design = designPath ? safeRead(designPath) : null;
|
||||
return {
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
productPath: productPath ? path.relative(cwd, productPath) : null,
|
||||
productPath: productPath ? path.relative(absCwd, productPath) : null,
|
||||
hasDesign: !!design,
|
||||
design,
|
||||
designPath: designPath ? path.relative(cwd, designPath) : null,
|
||||
contextDir,
|
||||
designPath: designPath ? path.relative(absCwd, designPath) : null,
|
||||
contextDir: resolved.contextDir,
|
||||
productContextDir: productPath ? path.dirname(productPath) : null,
|
||||
designContextDir: designPath ? path.dirname(designPath) : null,
|
||||
projectRoot: resolved.projectRoot,
|
||||
repoRoot: resolved.repoRoot,
|
||||
isMonorepo: resolved.isMonorepo,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveContext(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const project = resolveProject(absCwd, options);
|
||||
const projectContextDir = resolveLocalContextDir(project.projectRoot);
|
||||
const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
|
||||
? resolveLocalContextDir(project.repoRoot)
|
||||
: null;
|
||||
|
||||
let productPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
|
||||
let designPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
|
||||
|
||||
let envContextDir = null;
|
||||
if (!productPath && !designPath) {
|
||||
envContextDir = resolveEnvContextDir(absCwd);
|
||||
if (envContextDir) {
|
||||
productPath = firstExisting(envContextDir, PRODUCT_NAMES);
|
||||
designPath = firstExisting(envContextDir, DESIGN_NAMES);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contextDir: productPath
|
||||
? path.dirname(productPath)
|
||||
: designPath
|
||||
? path.dirname(designPath)
|
||||
: envContextDir || project.projectRoot,
|
||||
productPath,
|
||||
designPath,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
isMonorepo: project.isMonorepo,
|
||||
targetDir: project.targetDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
|
||||
return resolveProject(cwd, options).projectRoot;
|
||||
}
|
||||
|
||||
export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
|
||||
if (hasTargetOption(options)) return null;
|
||||
const project = resolveProject(cwd);
|
||||
if (
|
||||
!project.isMonorepo
|
||||
|| !project.projectRoot
|
||||
|| !project.repoRoot
|
||||
|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const targetCandidates = discoverTargetCandidates(project.repoRoot);
|
||||
// No discoverable child apps (e.g. `workspaces: ["."]`, a root-only workspace,
|
||||
// or a marker file with no apps/packages children): there is nothing to choose,
|
||||
// so treat the repo root as the active project rather than blocking on an empty
|
||||
// selection prompt that the user cannot answer.
|
||||
if (targetCandidates.length === 0) return null;
|
||||
return {
|
||||
targetPath: null,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
targetCandidates,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProject(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const targetDir = resolveTargetDir(absCwd, options);
|
||||
let repoRoot = findMonorepoRoot(targetDir);
|
||||
if (!repoRoot && targetDir !== absCwd) {
|
||||
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
||||
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
||||
repoRoot = cwdRepoRoot;
|
||||
}
|
||||
}
|
||||
if (!repoRoot) {
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: absCwd,
|
||||
repoRoot: absCwd,
|
||||
isMonorepo: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
|
||||
repoRoot,
|
||||
isMonorepo: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isPathInside(candidate, root) {
|
||||
const rel = path.relative(root, candidate);
|
||||
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function resolveLocalContextDir(root) {
|
||||
if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return root;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(root, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEnvContextDir(cwd) {
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (!envDir || !envDir.trim()) return null;
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
function findMonorepoRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === homeDir) return null;
|
||||
// isMonorepoRoot is checked before hasGitBoundary on purpose: a workspace
|
||||
// root that also carries its own .git is still recognized. The trade-off is
|
||||
// deliberate — a directory with a monorepo *marker* but no workspace patterns
|
||||
// and no apps/packages children is not a monorepo root, so its .git stops
|
||||
// traversal and a further-up root is not searched. The nested .git is treated
|
||||
// as an independent project boundary, which is the intended isolation.
|
||||
if (isMonorepoRoot(dir)) return dir;
|
||||
if (hasGitBoundary(dir)) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return hasFallbackWorkspaceChildren(dir);
|
||||
}
|
||||
|
||||
function hasGitBoundary(dir) {
|
||||
return fs.existsSync(path.join(dir, '.git'));
|
||||
}
|
||||
|
||||
function hasFallbackWorkspaceChildren(dir) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(dir, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function discoverTargetCandidates(repoRoot) {
|
||||
const roots = new Map();
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
for (const pattern of patterns) {
|
||||
for (const root of discoverRootsForPattern(repoRoot, pattern)) {
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(repoRoot, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const root = path.join(base, entry.name);
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots.entries()]
|
||||
.filter(([rel]) => rel && !rel.startsWith('..'))
|
||||
// Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot
|
||||
// sends an excluded package back to the repo root, so an excluded folder must not appear as a
|
||||
// selectable target — choosing it would silently resolve to the root instead.
|
||||
.filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([rel, root]) => {
|
||||
const targetExample = findTargetExample(repoRoot, root);
|
||||
return {
|
||||
name: path.basename(root),
|
||||
path: rel,
|
||||
targetExample,
|
||||
...resolveCandidateContextSummary(repoRoot, root, targetExample),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
|
||||
const ctx = resolveContext(repoRoot, { targetPath });
|
||||
return {
|
||||
productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
|
||||
productPath: contextSourcePath(ctx.productPath, repoRoot),
|
||||
designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
|
||||
designPath: contextSourcePath(ctx.designPath, repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
// Selection candidates surface one of four statuses: 'child' (a canonical
|
||||
// PRODUCT.md/DESIGN.md directly in the app root), 'inherited' (resolved from the
|
||||
// repo root in a monorepo), 'missing' (no file found), and 'fallback'. 'fallback'
|
||||
// intentionally covers two non-canonical locations: a file inside the project
|
||||
// root but in a subdirectory (FALLBACK_DIRS, e.g. `.agents/context/`), and a file
|
||||
// outside both the project and repo roots (IMPECCABLE_CONTEXT_DIR override).
|
||||
function contextSourceStatus(filePath, repoRoot, projectRoot) {
|
||||
if (!filePath) return 'missing';
|
||||
const absPath = path.resolve(filePath);
|
||||
const absProjectRoot = path.resolve(projectRoot);
|
||||
const absRepoRoot = path.resolve(repoRoot);
|
||||
if (isPathInsideOrEqual(absPath, absProjectRoot)) {
|
||||
return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
|
||||
}
|
||||
if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
|
||||
return 'inherited';
|
||||
}
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
function contextSourcePath(filePath, repoRoot) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(repoRoot, filePath);
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
return rel.split(path.sep).join('/');
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function discoverRootsForPattern(repoRoot, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return [];
|
||||
const segments = pattern.split('/').filter(Boolean);
|
||||
if (!segments.length) return [];
|
||||
const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
|
||||
const base = path.join(repoRoot, ...literalPrefix);
|
||||
if (!fs.existsSync(base)) return [];
|
||||
if (segments.includes('**')) {
|
||||
const packageRoots = [];
|
||||
walkDirs(base, (dir) => {
|
||||
if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
|
||||
});
|
||||
if (packageRoots.length) return packageRoots;
|
||||
return directChildDirs(base);
|
||||
}
|
||||
return expandSimplePattern(repoRoot, segments);
|
||||
}
|
||||
|
||||
function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
|
||||
if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
|
||||
const segment = patternSegments[index];
|
||||
if (!segment.includes('*')) {
|
||||
return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const roots = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
if (!segmentMatches(segment, entry.name)) continue;
|
||||
roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function directChildDirs(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function walkDirs(root, visit) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const dir = path.join(root, entry.name);
|
||||
visit(dir);
|
||||
walkDirs(dir, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function isCandidateProjectRoot(dir) {
|
||||
return !!(
|
||||
fs.existsSync(path.join(dir, 'package.json'))
|
||||
|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'src'))
|
||||
|| fs.existsSync(path.join(dir, 'app'))
|
||||
|| fs.existsSync(path.join(dir, 'pages'))
|
||||
|| fs.existsSync(path.join(dir, 'public'))
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnoredWorkspaceDiscoveryDir(name) {
|
||||
return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
|
||||
}
|
||||
|
||||
function findTargetExample(repoRoot, projectRoot) {
|
||||
const examples = [
|
||||
'src/App.jsx',
|
||||
'src/App.tsx',
|
||||
'src/main.jsx',
|
||||
'src/main.tsx',
|
||||
'src/index.jsx',
|
||||
'src/index.ts',
|
||||
'app/page.tsx',
|
||||
'pages/index.tsx',
|
||||
'public/index.html',
|
||||
];
|
||||
for (const rel of examples) {
|
||||
const abs = path.join(projectRoot, rel);
|
||||
if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
|
||||
}
|
||||
return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
|
||||
const rel = path.relative(repoRoot, targetDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
|
||||
if (!excluded) {
|
||||
for (const pattern of patterns) {
|
||||
const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
|
||||
if (projectRoot) return projectRoot;
|
||||
}
|
||||
}
|
||||
if (excluded) return repoRoot;
|
||||
if (
|
||||
relSegments.length >= 2
|
||||
&& MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
|
||||
) {
|
||||
return path.join(repoRoot, relSegments[0], relSegments[1]);
|
||||
}
|
||||
const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
|
||||
if (nearest) return nearest;
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function isExcludedByWorkspacePattern(relSegments, patterns) {
|
||||
return patterns.some((rawPattern) => {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern.startsWith('!')) return false;
|
||||
return workspacePatternMatchesRel(pattern.slice(1), relSegments);
|
||||
});
|
||||
}
|
||||
|
||||
function nearestProjectLikeRoot(repoRoot, targetDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop) {
|
||||
if (
|
||||
firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'package.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(stopDir || repoRoot);
|
||||
const root = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInsideOrEqual(candidate, root) {
|
||||
return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
|
||||
}
|
||||
|
||||
function workspacePatternMatchesRel(pattern, relSegments) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return false;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(repoRoot) {
|
||||
return [
|
||||
...readPackageWorkspaces(repoRoot),
|
||||
...readPnpmWorkspaces(repoRoot),
|
||||
...readLernaWorkspaces(repoRoot),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function readPackageWorkspaces(repoRoot) {
|
||||
const pkg = readJson(path.join(repoRoot, 'package.json'));
|
||||
const workspaces = pkg?.workspaces;
|
||||
if (Array.isArray(workspaces)) return workspaces;
|
||||
if (Array.isArray(workspaces?.packages)) return workspaces.packages;
|
||||
return [];
|
||||
}
|
||||
|
||||
function readLernaWorkspaces(repoRoot) {
|
||||
const lerna = readJson(path.join(repoRoot, 'lerna.json'));
|
||||
return Array.isArray(lerna?.packages) ? lerna.packages : [];
|
||||
}
|
||||
|
||||
function readPnpmWorkspaces(repoRoot) {
|
||||
try {
|
||||
const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
|
||||
const patterns = [];
|
||||
let inPackages = false;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const trimmed = stripYamlInlineComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flowMatch) {
|
||||
patterns.push(...parseYamlFlowList(flowMatch[1]));
|
||||
inPackages = false;
|
||||
continue;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
if (inPackages) {
|
||||
const match = trimmed.match(/^-\s*(.+)$/);
|
||||
if (match) patterns.push(unquoteYamlValue(match[1]));
|
||||
}
|
||||
}
|
||||
return patterns;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stripYamlInlineComment(line) {
|
||||
let quote = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#' && !quote) return line.slice(0, i);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseYamlFlowList(body) {
|
||||
const items = [];
|
||||
let quote = null;
|
||||
let current = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',' && !quote) {
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
return items;
|
||||
}
|
||||
|
||||
function unquoteYamlValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return null;
|
||||
const patternSegments = pattern.split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return null;
|
||||
if (patternSegments.includes('**')) {
|
||||
return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return null;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
|
||||
}
|
||||
return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
|
||||
}
|
||||
|
||||
function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return null;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
|
||||
}
|
||||
const prefixDir = path.join(repoRoot, ...literalPrefix);
|
||||
const targetDir = path.join(repoRoot, ...relSegments);
|
||||
const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
|
||||
if (packageRoot) return packageRoot;
|
||||
return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
|
||||
}
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
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 firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
@@ -89,6 +687,10 @@ function safeRead(p) {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
|
||||
* for a `## Register` section and reading the first non-empty line that
|
||||
@@ -233,7 +835,24 @@ async function computeUpdateDirective(now = Date.now()) {
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
let cliOptions;
|
||||
try {
|
||||
cliOptions = parseCliOptions(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -244,6 +863,10 @@ async function cli() {
|
||||
'Stop the current task, load reference/init.md, and follow its ' +
|
||||
'instructions to write PRODUCT.md before resuming.',
|
||||
];
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
@@ -252,6 +875,10 @@ async function cli() {
|
||||
if (ctx.hasDesign) {
|
||||
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
|
||||
}
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
const register = extractRegister(ctx.product);
|
||||
const next = register
|
||||
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
|
||||
@@ -261,6 +888,60 @@ async function cli() {
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
return parseTargetOptions(args, { strict: true });
|
||||
}
|
||||
|
||||
function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
|
||||
const targetPath = hasTargetOption(options) ? options.targetPath : null;
|
||||
return `RESOLVED_CONTEXT:\n${JSON.stringify({
|
||||
targetPath,
|
||||
...(targetPath ? { targetExists } : {}),
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2)}`;
|
||||
}
|
||||
|
||||
function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
|
||||
if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
|
||||
return !!(
|
||||
ctx.isMonorepo
|
||||
&& (!targetProvided || targetExists === false)
|
||||
&& ctx.projectRoot
|
||||
&& ctx.repoRoot
|
||||
&& path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
|
||||
);
|
||||
}
|
||||
|
||||
function buildMissingTargetDirective() {
|
||||
const script = process.argv[1] || 'context.mjs';
|
||||
return (
|
||||
'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
|
||||
'If the user named a file, route, or child app, do not answer from this output. ' +
|
||||
`Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
|
||||
);
|
||||
}
|
||||
|
||||
function buildTargetSelectionDirective(selection) {
|
||||
return (
|
||||
`TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
|
||||
'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' +
|
||||
'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
|
||||
'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
|
||||
);
|
||||
}
|
||||
|
||||
// Run cli() only when this module is the entry point. Compare realpaths
|
||||
// rather than endsWith(): a loose suffix match also fires for unrelated
|
||||
// scripts like `load-context.mjs`, and realpath tolerates symlinked
|
||||
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
// Output formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatFindingSummary(count) {
|
||||
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
|
||||
}
|
||||
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
|
||||
@@ -39,7 +43,7 @@ function formatFindings(findings, jsonMode) {
|
||||
out.push(` → ${item.description}`);
|
||||
}
|
||||
}
|
||||
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
|
||||
out.push(`\n${formatFindingSummary(findings.length)}`);
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
@@ -86,9 +90,12 @@ 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
|
||||
--gpt Also report GPT-specific provider tells (off by default)
|
||||
--gemini Also report Gemini-specific provider tells (off by default)
|
||||
--no-config Do not apply project config, detector ignores, or DESIGN.md
|
||||
--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
|
||||
--help Show this help message
|
||||
|
||||
@@ -97,6 +104,14 @@ Project config:
|
||||
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:
|
||||
<!-- impeccable-disable overused-font -- exported brand doc -->
|
||||
.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.)
|
||||
@@ -118,6 +133,7 @@ async function detectCli() {
|
||||
});
|
||||
if (args[0] === 'detect') args = args.slice(1);
|
||||
const jsonMode = args.includes('--json');
|
||||
const quietMode = args.includes('--quiet');
|
||||
const helpMode = args.includes('--help');
|
||||
// --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
|
||||
@@ -137,7 +153,12 @@ async function detectCli() {
|
||||
if (args.includes('--gemini')) providers.push('gemini');
|
||||
const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false;
|
||||
const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null;
|
||||
const scanOptions = designSystem ? { providers, designSystem } : { providers };
|
||||
// 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 scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled };
|
||||
if (designSystem) scanOptions.designSystem = designSystem;
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
@@ -169,8 +190,8 @@ async function detectCli() {
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
|
||||
if (!jsonMode) {
|
||||
// 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);
|
||||
@@ -200,7 +221,7 @@ async function detectCli() {
|
||||
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) {
|
||||
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` +
|
||||
@@ -258,6 +279,7 @@ async function detectCli() {
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { GENERIC_FONTS } from '../../shared/constants.mjs';
|
||||
import { isNeutralColor } from '../../shared/color.mjs';
|
||||
import { checkSourceDesignSystem } from '../../design-system.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { filterByProviders } from '../../registry/antipatterns.mjs';
|
||||
import { profileFindings, profileStep } from '../../profile/profiler.mjs';
|
||||
@@ -37,10 +39,11 @@ function shouldRunPageAnalyzers(content, filePath) {
|
||||
}
|
||||
|
||||
function isNeutralBorderColor(str) {
|
||||
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
|
||||
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;
|
||||
const c = m[1].toLowerCase();
|
||||
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
|
||||
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
|
||||
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
|
||||
if (hex) {
|
||||
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
|
||||
@@ -57,10 +60,10 @@ function isNeutralBorderColor(str) {
|
||||
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 >= 1 : n >= 4; },
|
||||
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 >= 1 : n >= 3; },
|
||||
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,
|
||||
@@ -547,7 +550,10 @@ function detectText(content, filePath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return filterByProviders(deduped, options?.providers);
|
||||
const byProvider = filterByProviders(deduped, options?.providers);
|
||||
// Inline `impeccable-disable*` waivers travel with the file; honor them unless
|
||||
// explicitly bypassed (`--no-config` / `--no-inline-ignores`).
|
||||
return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content);
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
mergeDesignSystemFindings,
|
||||
} from '../../design-system.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
||||
import {
|
||||
@@ -223,7 +224,11 @@ async function detectHtml(filePath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return filterByProviders(findings, options.providers);
|
||||
const byProvider = filterByProviders(findings, options.providers);
|
||||
// Static-HTML findings carry no line number, so only whole-file
|
||||
// `impeccable-disable` directives apply here — exactly the standalone-document
|
||||
// waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`.
|
||||
return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html);
|
||||
}
|
||||
|
||||
export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml };
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Inline, in-file ignore directives — eslint-disable-style waivers that live at
|
||||
* the point they apply and travel with the artifact instead of (or alongside)
|
||||
* an ignore in `.impeccable/config.json`.
|
||||
*
|
||||
* A config ignore is the right default for repo-wide policy. This complements it
|
||||
* for the one case config can't cover: a waiver that belongs to a single file and
|
||||
* needs to follow that file when it leaves the repo — a generated/exported
|
||||
* standalone document, an emailed HTML file, a snippet scanned out of context.
|
||||
*
|
||||
* Comment-syntax-agnostic: the directive is a raw token matched anywhere on a
|
||||
* line, so the same marker works across every comment style impeccable scans —
|
||||
* `//`, `/* *\/`, `<!-- -->`, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers
|
||||
* are stripped before the rule list is parsed.
|
||||
*
|
||||
* Syntax (reason optional; eslint `--` or biome `:` separator):
|
||||
*
|
||||
* impeccable-disable <rule>[, <rule>...] [-- reason] whole file
|
||||
* impeccable-disable-line <rule>... [-- reason] the same line
|
||||
* impeccable-disable-next-line <rule>... [-- reason] the following line
|
||||
* impeccable-disable bare / `*` = every rule
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* <!-- impeccable-disable overused-font -- exported brand doc, font is first-party -->
|
||||
* .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/
|
||||
* // impeccable-disable-next-line bounce-easing: intentional playful affordance
|
||||
*
|
||||
* Behavior is suppression, for parity with config ignores: a matched directive
|
||||
* drops the finding. The inline reason is self-documenting in the diff; it is not
|
||||
* required and is discarded at scan time (only used here to keep reason words out
|
||||
* of the parsed rule list).
|
||||
*/
|
||||
|
||||
const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi;
|
||||
|
||||
// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't
|
||||
// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the
|
||||
// space before the closer. `--+>` covers `-->` and any longer dash run.
|
||||
const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/;
|
||||
|
||||
function normalizeRule(token) {
|
||||
return String(token || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
// Split the directive remainder into rule tokens, dropping any human reason that
|
||||
// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever
|
||||
// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:`
|
||||
// are unambiguous separators.
|
||||
function parseRuleList(remainder) {
|
||||
let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim();
|
||||
// Cut off a human reason at the first `--` (eslint) or `:` (biome) separator.
|
||||
const reasonSep = text.match(/\s*(?:--+|:)\s*/);
|
||||
if (reasonSep) text = text.slice(0, reasonSep.index);
|
||||
const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean);
|
||||
if (tokens.length === 0 || tokens.includes('*')) return ['*'];
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function addRules(set, rules) {
|
||||
for (const rule of rules) set.add(rule);
|
||||
}
|
||||
|
||||
function getSet(map, key) {
|
||||
let set = map.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
map.set(key, set);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse every inline ignore directive in a file's raw text.
|
||||
*
|
||||
* Returns sets keyed by the 1-based line the directive *targets* so matching is a
|
||||
* direct lookup:
|
||||
* - file: rules disabled for the whole file
|
||||
* - line: line -> rules disabled on that exact line (disable-line)
|
||||
* - nextLine: line -> rules disabled on that line (disable-next-line on line-1)
|
||||
*
|
||||
* `*` in any set means "every rule".
|
||||
*/
|
||||
function parseInlineIgnores(content) {
|
||||
const result = { file: new Set(), line: new Map(), nextLine: new Map() };
|
||||
const text = typeof content === 'string' ? content : '';
|
||||
// Cheap bail-out: the substring must be present for any directive to exist.
|
||||
// Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`).
|
||||
if (!/impeccable-disable/i.test(text)) return result;
|
||||
|
||||
// Split on `\n` only, exactly as detectText numbers lines, so directive line
|
||||
// keys line up with finding `line` values (incl. on `\r`-only line endings).
|
||||
// The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is
|
||||
// never captured into the rule list.
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
DIRECTIVE_RE.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) {
|
||||
const variant = m[1].toLowerCase();
|
||||
const rules = parseRuleList(m[2]);
|
||||
if (variant === 'disable') {
|
||||
addRules(result.file, rules);
|
||||
} else if (variant === 'disable-line') {
|
||||
addRules(getSet(result.line, i + 1), rules);
|
||||
} else {
|
||||
// disable-next-line on line i+1 targets line i+2.
|
||||
addRules(getSet(result.nextLine, i + 2), rules);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function setMatches(set, rule) {
|
||||
return Boolean(set) && (set.has('*') || set.has(rule));
|
||||
}
|
||||
|
||||
function isInlineIgnored(finding, directives) {
|
||||
const rule = normalizeRule(finding && finding.antipattern);
|
||||
if (!rule) return false;
|
||||
if (setMatches(directives.file, rule)) return true;
|
||||
const line = Number(finding && finding.line) || 0;
|
||||
if (line > 0) {
|
||||
if (setMatches(directives.line.get(line), rule)) return true;
|
||||
if (setMatches(directives.nextLine.get(line), rule)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasDirectives(directives) {
|
||||
return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop findings waived by an inline directive in the same file's source text.
|
||||
* Findings without a usable line number (e.g. static-HTML page-level findings)
|
||||
* are only matched by whole-file directives — which is the standalone-document
|
||||
* case this primitive exists for.
|
||||
*/
|
||||
function applyInlineIgnores(findings, content) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return findings;
|
||||
const directives = parseInlineIgnores(content);
|
||||
if (!hasDirectives(directives)) return findings;
|
||||
return findings.filter((finding) => !isInlineIgnored(finding, directives));
|
||||
}
|
||||
|
||||
export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored };
|
||||
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
|
||||
// manifest is honored by the CLI (once committed to the default branch) and
|
||||
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
|
||||
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
|
||||
provider: '.github',
|
||||
skillRel: '.github/skills/impeccable',
|
||||
destRel: '.github/hooks/impeccable.json',
|
||||
manifest: () => ({
|
||||
version: 1,
|
||||
hooks: {
|
||||
postToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
matcher: 'edit|create|apply_patch',
|
||||
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
|
||||
timeoutSec: TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function readRawConfigFile(filePath) {
|
||||
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
|
||||
// `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;
|
||||
|
||||
@@ -959,13 +959,114 @@ export function resolveTargetFiles(event, projectCwd) {
|
||||
export function resolveHarness(env = {}, event = null) {
|
||||
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
|
||||
if (explicit === 'cursor') return 'cursor';
|
||||
if (explicit === 'github') return 'github';
|
||||
if (explicit === 'claude' || explicit === 'codex') return 'claude';
|
||||
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
|
||||
// has no `tool_name`/`tool_input`. That shape is the discriminator.
|
||||
if (event && typeof event === 'object'
|
||||
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
|
||||
&& event.tool_name === undefined && event.tool_input === undefined) {
|
||||
return 'github';
|
||||
}
|
||||
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
|
||||
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
|
||||
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
|
||||
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
|
||||
// normalizeGitHubEvent). The detector reads the file from disk after the tool
|
||||
// ran, so only the path (not the proposed content) is needed here.
|
||||
export function parseGitHubToolArgs(toolArgs) {
|
||||
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
|
||||
if (typeof toolArgs === 'string' && toolArgs.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolArgs);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
|
||||
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
|
||||
// *** Begin Patch
|
||||
// *** Add File: /abs/app.css
|
||||
// +body { ... }
|
||||
// *** End Patch
|
||||
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
|
||||
// JSON string with the path under `path`. Both must map onto the internal shape.
|
||||
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
|
||||
|
||||
function looksLikeApplyPatch(rawArgs) {
|
||||
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
|
||||
// Guard against an edit/create payload whose edited *content* happens to
|
||||
// contain patch markers: that payload is a JSON object string, whereas a real
|
||||
// apply_patch payload is a raw patch string that does not parse as JSON. Only
|
||||
// treat non-JSON-object strings as apply_patch so edit events still get their
|
||||
// `path` extracted.
|
||||
try {
|
||||
const parsed = JSON.parse(rawArgs);
|
||||
if (parsed && typeof parsed === 'object') return false;
|
||||
} catch { /* not JSON → genuine raw patch */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPatchText(rawArgs) {
|
||||
if (typeof rawArgs === 'string') {
|
||||
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
|
||||
// Defensive: a future Copilot build might JSON-wrap the patch.
|
||||
const parsed = parseGitHubToolArgs(rawArgs);
|
||||
return parsed.patch || parsed.input || parsed.command || '';
|
||||
}
|
||||
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
||||
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeGitHubEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const toolName = event.toolName || event.tool_name || null;
|
||||
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
|
||||
const rawArgs = event.toolArgs;
|
||||
|
||||
let normalizedToolName = toolName;
|
||||
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
|
||||
// resolveTargetFiles() reads the touched paths from tool_input.command when
|
||||
// tool_name is 'apply_patch', so normalize the name even if a future build
|
||||
// sends the patch under a different tool label.
|
||||
const patch = applyPatchText(rawArgs);
|
||||
if (patch) {
|
||||
toolInput.command = patch;
|
||||
normalizedToolName = 'apply_patch';
|
||||
}
|
||||
} else {
|
||||
const args = parseGitHubToolArgs(rawArgs);
|
||||
const filePath = args.path || args.file_path || args.filePath || args.target_file;
|
||||
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: normalizedToolName,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
|
||||
@@ -1200,12 +1301,12 @@ export function setDetectorForTesting(impl) {
|
||||
// session" so the model knows it's a re-mind, not a new finding.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const STEER_LINE = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.';
|
||||
const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.';
|
||||
|
||||
export function renderCleanAck(filePath, opts = {}) {
|
||||
const cwd = opts.cwd || process.cwd();
|
||||
const display = relativize(filePath, cwd);
|
||||
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${STEER_LINE}`;
|
||||
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`;
|
||||
}
|
||||
|
||||
export function renderPendingAck(filePath, knownFindings, opts = {}) {
|
||||
@@ -1261,7 +1362,7 @@ function directiveFooter(display, opts = {}) {
|
||||
'',
|
||||
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
|
||||
'',
|
||||
`Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
|
||||
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -1520,6 +1621,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'cursor') {
|
||||
return JSON.stringify({ additional_context: text });
|
||||
}
|
||||
// GitHub Copilot's postToolUse hook injects context via a top-level
|
||||
// `additionalContext` string (alongside an optional `modifiedResult`).
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
/**
|
||||
* CLI-side reader/writer for the unified `.impeccable` config.
|
||||
*
|
||||
* The CLI (published to npm) and the skill scripts (bundled into the install)
|
||||
* live in separate trees and cannot share runtime code, so this duplicates a
|
||||
* small slice of skill/scripts/hook-lib.mjs — the config-path layout, detector
|
||||
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
|
||||
* ignore filtering, and exclude marker in sync if either side changes.
|
||||
*
|
||||
* Schema (config.json shared / config.local.json gitignored, per-developer):
|
||||
* {
|
||||
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
|
||||
* "hook": { "consent": "accepted" | "declined", ... },
|
||||
* "updateCheck": bool
|
||||
* }
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
||||
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
|
||||
export function getConfigPath(root) {
|
||||
return join(root, '.impeccable', 'config.json');
|
||||
}
|
||||
|
||||
export function getLocalConfigPath(root) {
|
||||
return join(root, '.impeccable', 'config.local.json');
|
||||
}
|
||||
|
||||
function safeReadJson(filePath) {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hookSection(raw) {
|
||||
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
|
||||
}
|
||||
|
||||
function detectorSection(raw) {
|
||||
return raw && raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
|
||||
}
|
||||
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
|
||||
|
||||
const DEFAULT_DETECTION_CONFIG = Object.freeze({
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
designSystem: { enabled: true },
|
||||
});
|
||||
|
||||
function cloneDetectionConfig() {
|
||||
return {
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
|
||||
};
|
||||
}
|
||||
|
||||
function cloneRawDetectionConfig() {
|
||||
return {
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
};
|
||||
}
|
||||
|
||||
function applyDetectionConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
||||
config.designSystem = {
|
||||
...config.designSystem,
|
||||
enabled: raw.designSystem.enabled === false ? false : true,
|
||||
};
|
||||
}
|
||||
if (Array.isArray(raw.ignoreRules)) {
|
||||
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
|
||||
}
|
||||
if (Array.isArray(raw.ignoreFiles)) {
|
||||
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
|
||||
}
|
||||
if (Array.isArray(raw.ignoreValues)) {
|
||||
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function uniqueStrings(values) {
|
||||
return Array.from(new Set(values.map(String)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detector filters shared by `npx impeccable detect` and the design hook.
|
||||
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
|
||||
* the hook is disabled, but they honor the same ignore rules and design-system
|
||||
* toggle.
|
||||
*/
|
||||
export function readDetectionConfig(root) {
|
||||
const config = cloneDetectionConfig();
|
||||
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
|
||||
const raw = safeReadJson(filePath);
|
||||
// Back-compat: old builds stored detector filters under hook.*.
|
||||
applyDetectionConfigSource(config, hookSection(raw));
|
||||
applyDetectionConfigSource(config, detectorSection(raw));
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export function readRawDetectionConfig(root, opts = {}) {
|
||||
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
|
||||
const config = cloneRawDetectionConfig();
|
||||
applyDetectionConfigSource(config, hookSection(raw));
|
||||
applyDetectionConfigSource(config, detectorSection(raw));
|
||||
return config;
|
||||
}
|
||||
|
||||
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
|
||||
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
|
||||
if (opts.local) ensureConfigGitExclude(root);
|
||||
const existing = safeReadJson(filePath) || {};
|
||||
const existingHook = hookSection(existing);
|
||||
const nextHook = stripDetectorKeys(existingHook);
|
||||
const nextDetector = {
|
||||
...(detectorSection(existing) || {}),
|
||||
...normalizeDetectionConfigForWrite(detectorConfig),
|
||||
};
|
||||
const next = {
|
||||
...existing,
|
||||
detector: nextDetector,
|
||||
};
|
||||
if (nextHook && Object.keys(nextHook).length > 0) {
|
||||
next.hook = nextHook;
|
||||
} else {
|
||||
delete next.hook;
|
||||
}
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function normalizeDetectionConfigForWrite(config) {
|
||||
const out = {};
|
||||
if (Array.isArray(config?.ignoreRules)) {
|
||||
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
|
||||
}
|
||||
if (Array.isArray(config?.ignoreFiles)) {
|
||||
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
|
||||
}
|
||||
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
|
||||
out.designSystem = {
|
||||
enabled: config.designSystem.enabled === false ? false : true,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function stripDetectorKeys(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeIgnoreValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeIgnoreRule(rule) {
|
||||
return String(rule || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function colorIgnoreKey(value) {
|
||||
const color = parseIgnoreColor(value);
|
||||
if (!color) return '';
|
||||
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
|
||||
}
|
||||
|
||||
function parseIgnoreColor(value) {
|
||||
const text = String(value || '').trim().toLowerCase();
|
||||
if (!text) return null;
|
||||
|
||||
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
|
||||
if (hex) return parseHexIgnoreColor(hex[1]);
|
||||
|
||||
const rgb = text.match(/^rgba?\((.*)\)$/i);
|
||||
if (rgb) {
|
||||
const parts = splitColorArgs(rgb[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const r = parseRgbChannel(parts[0]);
|
||||
const g = parseRgbChannel(parts[1]);
|
||||
const b = parseRgbChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
if ([r, g, b, a].some((v) => v === null)) return null;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
const hsl = text.match(/^hsla?\((.*)\)$/i);
|
||||
if (hsl) {
|
||||
const parts = splitColorArgs(hsl[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const h = parseHueChannel(parts[0]);
|
||||
const s = parsePercentChannel(parts[1]);
|
||||
const l = parsePercentChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
if ([h, s, l, a].some((v) => v === null)) return null;
|
||||
return hslToRgb(h, s, l, a);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseHexIgnoreColor(hex) {
|
||||
if (hex.length === 3 || hex.length === 4) {
|
||||
const r = parseInt(hex[0] + hex[0], 16);
|
||||
const g = parseInt(hex[1] + hex[1], 16);
|
||||
const b = parseInt(hex[2] + hex[2], 16);
|
||||
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
function splitColorArgs(body) {
|
||||
const text = String(body || '').trim();
|
||||
if (!text) return [];
|
||||
if (text.includes(',')) {
|
||||
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
|
||||
const last = parts[parts.length - 1];
|
||||
if (last && last.includes('/')) {
|
||||
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
|
||||
return [...parts.slice(0, -1), ...split];
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
|
||||
}
|
||||
|
||||
function parseRgbChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const scaled = match[2] ? value * 2.55 : value;
|
||||
if (scaled < 0 || scaled > 255) return null;
|
||||
return Math.round(scaled);
|
||||
}
|
||||
|
||||
function parseAlphaChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const alpha = match[2] ? value / 100 : value;
|
||||
return alpha >= 0 && alpha <= 1 ? alpha : null;
|
||||
}
|
||||
|
||||
function parseHueChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const unit = match[2] || 'deg';
|
||||
if (unit === 'turn') return value * 360;
|
||||
if (unit === 'rad') return value * (180 / Math.PI);
|
||||
if (unit === 'grad') return value * 0.9;
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePercentChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)%$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return value >= 0 && value <= 100 ? value / 100 : null;
|
||||
}
|
||||
|
||||
function hslToRgb(hue, saturation, lightness, alpha) {
|
||||
const h = (((hue % 360) + 360) % 360) / 360;
|
||||
if (saturation === 0) {
|
||||
const gray = clampByte(Math.round(lightness * 255));
|
||||
return { r: gray, g: gray, b: gray, a: alpha };
|
||||
}
|
||||
const q = lightness < 0.5
|
||||
? lightness * (1 + saturation)
|
||||
: lightness + saturation - lightness * saturation;
|
||||
const p = 2 * lightness - q;
|
||||
const toRgb = (t) => {
|
||||
let channel = t;
|
||||
if (channel < 0) channel += 1;
|
||||
if (channel > 1) channel -= 1;
|
||||
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
|
||||
if (channel < 1 / 2) return q;
|
||||
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
|
||||
return p;
|
||||
};
|
||||
return {
|
||||
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
|
||||
g: clampByte(Math.round(toRgb(h) * 255)),
|
||||
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
|
||||
a: alpha,
|
||||
};
|
||||
}
|
||||
|
||||
function clampByte(value) {
|
||||
return Math.min(255, Math.max(0, value));
|
||||
}
|
||||
|
||||
function ignoreValueMatches(rule, entryValue, findingValue) {
|
||||
if (entryValue === findingValue) return true;
|
||||
if (rule !== 'design-system-color') return false;
|
||||
const entryColor = colorIgnoreKey(entryValue);
|
||||
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
|
||||
}
|
||||
|
||||
export function normalizeIgnoreValueEntries(entries) {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
const out = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const rule = normalizeIgnoreRule(entry.rule);
|
||||
const value = normalizeIgnoreValue(entry.value);
|
||||
if (!rule || !value) continue;
|
||||
const normalized = { rule, value };
|
||||
const files = uniqueStrings([
|
||||
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
|
||||
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
|
||||
]);
|
||||
if (files.length > 0) normalized.files = files;
|
||||
if (typeof entry.reason === 'string' && entry.reason.trim()) {
|
||||
normalized.reason = entry.reason.trim();
|
||||
}
|
||||
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
|
||||
normalized.createdAt = entry.createdAt.trim();
|
||||
}
|
||||
out.push(normalized);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeIgnoreValues(existing, incoming) {
|
||||
const map = new Map();
|
||||
for (const entry of normalizeIgnoreValueEntries(existing)) {
|
||||
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
||||
}
|
||||
for (const entry of normalizeIgnoreValueEntries(incoming)) {
|
||||
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
function ignoreValueFilesKey(files) {
|
||||
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
|
||||
}
|
||||
|
||||
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
|
||||
function globToRegex(glob) {
|
||||
let re = '^';
|
||||
let i = 0;
|
||||
while (i < glob.length) {
|
||||
const c = glob[i];
|
||||
if (c === '*') {
|
||||
if (glob[i + 1] === '*') {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
if (glob[i] === '/') i += 1;
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (c === '{') {
|
||||
const end = glob.indexOf('}', i);
|
||||
if (end === -1) { re += '\\{'; i += 1; continue; }
|
||||
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
|
||||
re += `(?:${parts.join('|')})`;
|
||||
i = end + 1;
|
||||
} else if (/[.+^$()|[\]\\]/.test(c)) {
|
||||
re += `\\${c}`;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
re += '$';
|
||||
return new RegExp(re);
|
||||
}
|
||||
|
||||
export function matchesAnyGlob(filePath, globs) {
|
||||
if (!Array.isArray(globs) || globs.length === 0) return false;
|
||||
const normalized = String(filePath || '').split(sep).join('/');
|
||||
for (const glob of globs) {
|
||||
try {
|
||||
const re = globToRegex(String(glob));
|
||||
if (re.test(normalized)) return true;
|
||||
const base = normalized.split('/').pop();
|
||||
if (re.test(base)) return true;
|
||||
} catch {
|
||||
/* malformed glob, skip */
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldIgnoreDetectionFile(filePath, root, config) {
|
||||
const globs = config?.ignoreFiles || [];
|
||||
if (!Array.isArray(globs) || globs.length === 0) return false;
|
||||
const raw = String(filePath || '').trim();
|
||||
if (!raw) return false;
|
||||
if (matchesAnyGlob(raw, globs)) return true;
|
||||
|
||||
try {
|
||||
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
|
||||
if (matchesAnyGlob(abs, globs)) return true;
|
||||
const rel = relative(root, abs);
|
||||
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
|
||||
return matchesAnyGlob(rel, globs);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function filterDetectionFindings(findings, config) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return [];
|
||||
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
|
||||
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
return findings.filter((finding) => {
|
||||
if (!finding || typeof finding !== 'object') return false;
|
||||
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
|
||||
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function isIgnoredFindingValue(finding, ignoreValues) {
|
||||
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
const value = extractFindingIgnoreValue(finding);
|
||||
if (!rule || !value) return false;
|
||||
return ignoreValues.some((entry) => {
|
||||
const wildcardValue = entry.value === '*';
|
||||
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
|
||||
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
|
||||
return findingMatchesScopedIgnoreFile(finding, entry.files);
|
||||
});
|
||||
}
|
||||
|
||||
function findingMatchesScopedIgnoreFile(finding, globs) {
|
||||
const filePath = String(finding?.file || '').trim();
|
||||
if (!filePath) return false;
|
||||
if (matchesAnyGlob(filePath, globs)) return true;
|
||||
|
||||
const normalized = filePath.split(sep).join('/');
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const suffix = parts.slice(i).join('/');
|
||||
if (matchesAnyGlob(suffix, globs)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function extractFindingIgnoreValue(finding) {
|
||||
if (!finding || typeof finding !== 'object') return '';
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
const directValueRules = new Set([
|
||||
'overused-font',
|
||||
'bounce-easing',
|
||||
'design-system-font',
|
||||
'design-system-color',
|
||||
'design-system-radius',
|
||||
]);
|
||||
if (!directValueRules.has(rule)) return '';
|
||||
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
|
||||
}
|
||||
|
||||
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
|
||||
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
|
||||
if (direct) return direct;
|
||||
|
||||
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
|
||||
for (const text of candidates) {
|
||||
if (rule === 'bounce-easing') {
|
||||
const motion = extractMotionIgnoreValue(text);
|
||||
if (motion) return motion;
|
||||
continue;
|
||||
}
|
||||
|
||||
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
|
||||
if (primary) return cleanIgnoreValueDisplay(primary[1]);
|
||||
|
||||
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
|
||||
if (family) return cleanIgnoreValueDisplay(family[1]);
|
||||
|
||||
const google = text.match(/[?&]family=([^&:;\n]+)/i);
|
||||
if (google) {
|
||||
try {
|
||||
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
|
||||
} catch {
|
||||
return cleanIgnoreValueDisplay(google[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractMotionIgnoreValue(text) {
|
||||
const tailwind = text.match(/\banimate-bounce\b/i);
|
||||
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
|
||||
|
||||
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
|
||||
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
|
||||
|
||||
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
|
||||
if (animation) {
|
||||
const token = animation[1]
|
||||
.split(/[,\s]+/)
|
||||
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
||||
if (token) return cleanIgnoreValueDisplay(token);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function cleanIgnoreValueDisplay(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ')
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
|
||||
* config.local.json (per-developer) overrides config.json.
|
||||
*/
|
||||
export function getHookConsent(root) {
|
||||
let consent;
|
||||
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
|
||||
const hook = hookSection(safeReadJson(filePath));
|
||||
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
|
||||
}
|
||||
return consent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the per-developer decision to config.local.json, preserving any
|
||||
* sibling keys, and ensure the file is gitignored.
|
||||
*/
|
||||
export function setHookConsent(root, value) {
|
||||
const filePath = getLocalConfigPath(root);
|
||||
const existing = safeReadJson(filePath) || {};
|
||||
const hook = hookSection(existing) || {};
|
||||
const next = { ...existing, hook: { ...hook, consent: value } };
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
|
||||
ensureConfigGitExclude(root);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
|
||||
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
|
||||
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
|
||||
|
||||
/**
|
||||
* Add config.local.json to `.git/info/exclude` so a developer's decision is
|
||||
* never committed. Idempotent via marker comments. Best-effort; returns false
|
||||
* when there is no resolvable git dir.
|
||||
*/
|
||||
export function ensureConfigGitExclude(root) {
|
||||
try {
|
||||
const gitDir = resolveGitDir(root);
|
||||
if (!gitDir) return false;
|
||||
const target = join(gitDir, 'info', 'exclude');
|
||||
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
|
||||
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
|
||||
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
|
||||
let updated;
|
||||
if (markerRe.test(existing)) {
|
||||
updated = existing.replace(markerRe, block);
|
||||
} else {
|
||||
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
|
||||
updated = `${prefix}${block}\n`;
|
||||
}
|
||||
if (updated !== existing) {
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, updated);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGitDir(root) {
|
||||
const dotGit = join(root, '.git');
|
||||
if (!existsSync(dotGit)) return null;
|
||||
try {
|
||||
if (statSync(dotGit).isDirectory()) return dotGit;
|
||||
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
|
||||
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
|
||||
if (match) {
|
||||
const resolved = match[1].trim();
|
||||
return isAbsolute(resolved) ? resolved : join(root, resolved);
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -1,50 +1,52 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
export const CRITIQUE_DIR = 'critique';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
export function getImpeccableDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
const projectRoot = resolveProjectRoot(cwd, options);
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
getDesignSidecarPath(cwd, options),
|
||||
path.join(projectRoot, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
export function getLiveDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
export function getLiveConfigPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
const primary = getLiveConfigPath(cwd, { targetPath });
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
@@ -53,16 +55,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
export function getLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function readLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
|
||||
@@ -88,37 +90,37 @@ export function isLiveServerPidReachable(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) {
|
||||
const filePath = getLiveServerPath(cwd, options);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'annotations');
|
||||
}
|
||||
|
||||
export function getCritiqueDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), CRITIQUE_DIR);
|
||||
export function getCritiqueDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR);
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
class TargetArgError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'TargetArgError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTargetPath(args = [], { strict = false } = {}) {
|
||||
let targetPath = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i]);
|
||||
if (arg === '--target' || arg === '-t') {
|
||||
const next = args[i + 1];
|
||||
if (next && !String(next).startsWith('-')) {
|
||||
targetPath = String(next);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--target=')) {
|
||||
const value = arg.slice('--target='.length);
|
||||
if (value) {
|
||||
targetPath = value;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export function parseTargetOptions(args = [], options = {}) {
|
||||
const targetPath = parseTargetPath(args, options);
|
||||
return targetPath ? { targetPath } : {};
|
||||
}
|
||||
@@ -57,7 +57,7 @@
|
||||
const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
|
||||
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint
|
||||
const PREFIX = 'impeccable-live';
|
||||
const PICK_CURSOR_CLASS = PREFIX + '-pick-cursor';
|
||||
const PICK_CURSOR_STYLE_ID = PREFIX + '-pick-cursor-style';
|
||||
const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000;
|
||||
const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({
|
||||
prefix: PREFIX,
|
||||
@@ -152,6 +152,7 @@
|
||||
let scrollLockTargetY = null;
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock';
|
||||
|
||||
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
@@ -1915,45 +1916,45 @@
|
||||
syncPageInteractionCursor();
|
||||
}
|
||||
|
||||
let pageInteractionCursorActive = false;
|
||||
|
||||
function ensurePickCursorStyle() {
|
||||
if (document.getElementById(PREFIX + '-pick-cursor-style')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = PREFIX + '-pick-cursor-style';
|
||||
/**
|
||||
* Drive the page-level pick / insert cursor through the textContent of one
|
||||
* injected <style>, never by mutating <html> (className or inline style).
|
||||
* Frameworks that server-render the <html>/<body> roots (Next.js App Router)
|
||||
* report a React 19 hydration mismatch when the client adds an attribute the
|
||||
* server HTML never emitted, so a `class`/inline `style` toggled on
|
||||
* `document.documentElement` trips "a tree hydrated but some attributes ...
|
||||
* didn't match" on the next Fast-Refresh re-render. Keying the cursor off a
|
||||
* stable-id <style> keeps the effect off the hydrated host elements (same
|
||||
* shape as the scroll-anchor lock). A falsy cursor clears the rule.
|
||||
*/
|
||||
function setPageInteractionCursor(cursor) {
|
||||
let style = document.getElementById(PICK_CURSOR_STYLE_ID);
|
||||
if (!cursor) {
|
||||
if (style) style.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = PICK_CURSOR_STYLE_ID;
|
||||
// Styles the host page, not the chrome - inside the adapter's shadow UI
|
||||
// root (uiAppendStyle's target) these selectors would match nothing.
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
}
|
||||
style.textContent =
|
||||
'html.' + PICK_CURSOR_CLASS + ' * { cursor: crosshair !important; }\n'
|
||||
+ 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"],\n'
|
||||
+ 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"] * { cursor: revert !important; }';
|
||||
// Styles the host page, not the chrome - inside the adapter's shadow UI
|
||||
// root (uiAppendStyle's target) these selectors would match nothing.
|
||||
document.head.appendChild(style);
|
||||
'* { cursor: ' + cursor + ' !important; }\n'
|
||||
+ '[id^="' + PREFIX + '"],\n'
|
||||
+ '[id^="' + PREFIX + '"] * { cursor: revert !important; }';
|
||||
}
|
||||
|
||||
/** Page-level cursor while pick or insert mode is targeting page elements. */
|
||||
function syncPageInteractionCursor() {
|
||||
const pickCursor = state === 'PICKING' && pickActive && !insertActive;
|
||||
let axisCursor = '';
|
||||
if (state === 'PICKING' && insertActive) {
|
||||
axisCursor = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : '';
|
||||
}
|
||||
|
||||
if (pickCursor) {
|
||||
ensurePickCursorStyle();
|
||||
document.documentElement.classList.add(PICK_CURSOR_CLASS);
|
||||
document.documentElement.style.cursor = '';
|
||||
pageInteractionCursorActive = true;
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.classList.remove(PICK_CURSOR_CLASS);
|
||||
if (axisCursor) {
|
||||
document.documentElement.style.cursor = axisCursor;
|
||||
pageInteractionCursorActive = true;
|
||||
} else if (pageInteractionCursorActive) {
|
||||
document.documentElement.style.cursor = '';
|
||||
pageInteractionCursorActive = false;
|
||||
let cursor = '';
|
||||
if (state === 'PICKING' && pickActive && !insertActive) {
|
||||
cursor = 'crosshair';
|
||||
} else if (state === 'PICKING' && insertActive && insertHoverAnchor) {
|
||||
cursor = cursorForInsertAxis(insertHoverAxis || 'column');
|
||||
}
|
||||
setPageInteractionCursor(cursor);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5815,10 +5816,22 @@
|
||||
|
||||
try { history.scrollRestoration = 'manual'; } catch {}
|
||||
|
||||
const prevHtmlAnchor = document.documentElement.style.overflowAnchor;
|
||||
const prevBodyAnchor = document.body.style.overflowAnchor;
|
||||
document.documentElement.style.overflowAnchor = 'none';
|
||||
document.body.style.overflowAnchor = 'none';
|
||||
// Suppress the browser's scroll-anchoring on the scroll root so it can't
|
||||
// fight our manual scroll correction. Apply this as a stylesheet rule, not
|
||||
// as inline `style` on <html>/<body>: those elements are server-rendered by
|
||||
// frameworks like Next.js App Router, and mutating their inline style makes
|
||||
// React 19 report a hydration mismatch on the next Fast-Refresh re-render.
|
||||
// A <style> rule has the same computed effect without touching any hydrated
|
||||
// element's attributes. Like the inline version, it is recreated on every
|
||||
// startScrollLock call, so reload survival (driven by the persisted scroll
|
||||
// key) is unaffected.
|
||||
let anchorLockStyle = document.getElementById(SCROLL_ANCHOR_LOCK_ID);
|
||||
if (!anchorLockStyle) {
|
||||
anchorLockStyle = document.createElement('style');
|
||||
anchorLockStyle.id = SCROLL_ANCHOR_LOCK_ID;
|
||||
anchorLockStyle.textContent = 'html,body{overflow-anchor:none !important;}';
|
||||
(document.head || document.documentElement).appendChild(anchorLockStyle);
|
||||
}
|
||||
|
||||
const correct = (why) => {
|
||||
scrollLockRaf = null;
|
||||
@@ -5853,8 +5866,7 @@
|
||||
|
||||
scrollLockAbort = new AbortController();
|
||||
scrollLockAbort.signal.addEventListener('abort', () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
document.getElementById(SCROLL_ANCHOR_LOCK_ID)?.remove();
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
// Track whether the most recent scroll came from a user gesture. We
|
||||
@@ -6489,10 +6501,13 @@
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isPageEditableElement(deepActive) && !isInlineEditActive(deepActive)) {
|
||||
return;
|
||||
}
|
||||
// While a contenteditable text-leaf is focused, let the browser handle
|
||||
// all keys except Escape. Escape cancels the current edit (restores
|
||||
// original text) and blurs without saving, staying in CONFIGURING.
|
||||
if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
|
||||
if (e.target.isContentEditable && isInlineEditActive(e.target)) {
|
||||
if (e.key !== 'Escape') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -8186,7 +8201,7 @@ void main() {
|
||||
let voiceInterimBase = '';
|
||||
/** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
|
||||
let voiceCtx = null;
|
||||
const PAGE_CHAT_COLLAPSED_W = '88px';
|
||||
const PAGE_CHAT_COLLAPSED_W = '104px';
|
||||
const PAGE_CHAT_PROCESSING_W = '76px';
|
||||
const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
|
||||
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
|
||||
@@ -8197,7 +8212,7 @@ void main() {
|
||||
const GLOBAL_BAR_SECTION_GAP = 8;
|
||||
const GLOBAL_BAR_INNER_GAP = 2;
|
||||
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
|
||||
const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
|
||||
const PAGE_CHAT_EXPANDED_MAX_W = 280;
|
||||
const ICON_PAGE_CHAT =
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
||||
const ICON_PAGE_VOICE =
|
||||
@@ -8277,6 +8292,52 @@ void main() {
|
||||
return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
|
||||
}
|
||||
|
||||
function globalBarModeToggles() {
|
||||
return [
|
||||
uiGetById(PREFIX + '-pick-toggle'),
|
||||
uiGetById(PREFIX + '-insert-toggle'),
|
||||
uiGetById(PREFIX + '-detect-toggle'),
|
||||
uiGetById(PREFIX + '-design-toggle'),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function applyGlobalBarLabelState(expandInactive, forceCollapse = false) {
|
||||
globalBarModeToggles().forEach((toggle) => {
|
||||
if (forceCollapse) toggle._collapseLabel?.(true);
|
||||
else if (expandInactive || toggle.dataset.active === 'true') toggle._expandLabel?.();
|
||||
else toggle._collapseLabel?.();
|
||||
});
|
||||
}
|
||||
|
||||
function syncGlobalBarExpandedLabels(expanded = globalBarEl?.matches(':hover')) {
|
||||
const expandInactive = !!(expanded && !pageChatExpanded);
|
||||
applyGlobalBarLabelState(expandInactive, pageChatExpanded);
|
||||
|
||||
if (expandInactive && globalBarEl && globalBarEl.scrollWidth > window.innerWidth - 16) {
|
||||
applyGlobalBarLabelState(false);
|
||||
}
|
||||
}
|
||||
|
||||
function pageChatCollapsedWidthPx() {
|
||||
const parsed = parseFloat(PAGE_CHAT_COLLAPSED_W);
|
||||
return Number.isFinite(parsed) ? parsed : 104;
|
||||
}
|
||||
|
||||
function pageChatExpandedWidth() {
|
||||
if (!pageChatEl || !globalBarEl) return PAGE_CHAT_EXPANDED_MAX_W + 'px';
|
||||
const currentChatWidth = pageChatEl.getBoundingClientRect().width || pageChatCollapsedWidthPx();
|
||||
const barWidth = Math.max(globalBarEl.getBoundingClientRect().width || 0, globalBarEl.scrollWidth || 0);
|
||||
const nonChatWidth = Math.max(0, barWidth - currentChatWidth);
|
||||
const available = window.innerWidth - 16 - nonChatWidth;
|
||||
const next = Math.max(pageChatCollapsedWidthPx(), Math.min(PAGE_CHAT_EXPANDED_MAX_W, available));
|
||||
return Math.round(next) + 'px';
|
||||
}
|
||||
|
||||
function syncPageChatExpandedWidth() {
|
||||
if (!pageChatEl || !pageChatExpanded) return;
|
||||
pageChatEl.style.width = pageChatExpandedWidth();
|
||||
}
|
||||
|
||||
function syncPageChatChrome() {
|
||||
if (!pageChatEl) return;
|
||||
const P = pageChatPalette();
|
||||
@@ -8312,6 +8373,21 @@ void main() {
|
||||
&& !steerLocked;
|
||||
}
|
||||
|
||||
function isPageEditableElement(el) {
|
||||
if (!el || own(el)) return false;
|
||||
if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return true;
|
||||
return !!el.isContentEditable;
|
||||
}
|
||||
|
||||
function isInlineEditActive(el) {
|
||||
return !!el && inlineEditRows.some((r) => r.el === el);
|
||||
}
|
||||
|
||||
function isPageEditableActive() {
|
||||
const active = activeElementDeep();
|
||||
return isPageEditableElement(active) && !isInlineEditActive(active);
|
||||
}
|
||||
|
||||
function pageHasHostTextSelection() {
|
||||
const sel = window.getSelection?.();
|
||||
if (!sel || sel.isCollapsed) return false;
|
||||
@@ -8325,6 +8401,7 @@ void main() {
|
||||
function shouldSteerAutoFocus() {
|
||||
return shouldFocusSteerChat()
|
||||
&& !steerFocusSuspended
|
||||
&& !isPageEditableActive()
|
||||
&& performance.now() >= steerFocusPauseUntil;
|
||||
}
|
||||
|
||||
@@ -8562,7 +8639,8 @@ void main() {
|
||||
if (!pageChatEl || !pageChatInput) return false;
|
||||
pageChatExpanded = true;
|
||||
pageChatEl.dataset.expanded = 'true';
|
||||
pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
|
||||
syncGlobalBarExpandedLabels(false);
|
||||
pageChatEl.style.width = pageChatExpandedWidth();
|
||||
pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
|
||||
pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED;
|
||||
if (pageChatHint) {
|
||||
@@ -8657,7 +8735,7 @@ void main() {
|
||||
pageChatEl.setAttribute('aria-label', 'Steer the page');
|
||||
pageChatExpanded = keepExpanded;
|
||||
pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
|
||||
pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
|
||||
pageChatEl.style.width = keepExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
|
||||
pageChatEl.style.cursor = 'pointer';
|
||||
if (pageChatInput) {
|
||||
pageChatInput.disabled = false;
|
||||
@@ -8971,6 +9049,7 @@ void main() {
|
||||
pageChatEl.dataset.expanded = 'false';
|
||||
pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
|
||||
pageChatEl.style.cursor = 'pointer';
|
||||
syncGlobalBarExpandedLabels(globalBarEl?.matches(':hover'));
|
||||
if (blur) {
|
||||
pageChatInput.blur();
|
||||
pageChatInput.style.pointerEvents = 'none';
|
||||
@@ -9270,6 +9349,7 @@ void main() {
|
||||
zIndex: Z.bar + 5,
|
||||
display: 'flex', alignItems: 'stretch',
|
||||
gap: '0',
|
||||
width: 'max-content',
|
||||
background: P.surface,
|
||||
border: '1px solid ' + P.border,
|
||||
borderRadius: '8px',
|
||||
@@ -9277,6 +9357,8 @@ void main() {
|
||||
fontFamily: FONT, fontSize: '12px', lineHeight: '1',
|
||||
opacity: '0',
|
||||
overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
});
|
||||
globalBarEl.id = PREFIX + '-global-bar';
|
||||
@@ -9325,6 +9407,7 @@ void main() {
|
||||
const inner = el('div', {
|
||||
display: 'flex', alignItems: 'center',
|
||||
padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
|
||||
flex: '0 0 auto',
|
||||
});
|
||||
inner.id = PREFIX + '-global-bar-inner';
|
||||
globalBarEl.appendChild(inner);
|
||||
@@ -9333,7 +9416,10 @@ void main() {
|
||||
function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
|
||||
const b = el('button', {
|
||||
position: 'relative',
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
minWidth: '30px',
|
||||
padding: '6px 8px', borderRadius: '7px',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
|
||||
@@ -9352,8 +9438,8 @@ void main() {
|
||||
if (!labelEl) return;
|
||||
labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
|
||||
};
|
||||
const collapse = () => {
|
||||
if (!labelEl || b.dataset.active === 'true') return;
|
||||
const collapse = (force = false) => {
|
||||
if (!labelEl || (!force && b.dataset.active === 'true')) return;
|
||||
labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
|
||||
};
|
||||
// Per-button hover only changes color (no layout). The label expand/
|
||||
@@ -9604,6 +9690,7 @@ void main() {
|
||||
width: '1px', height: '18px',
|
||||
background: P.hairline,
|
||||
margin: '0 4px 0 2px',
|
||||
flexShrink: '0',
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
@@ -9620,6 +9707,7 @@ void main() {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0', boxSizing: 'border-box',
|
||||
width: '24px', height: '24px', borderRadius: '6px',
|
||||
flexShrink: '0',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
|
||||
@@ -9632,16 +9720,16 @@ void main() {
|
||||
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
|
||||
inner.appendChild(exitBtn);
|
||||
|
||||
// Bar-level hover: expand every toggle's label at once; collapse on leave.
|
||||
// Bar-level hover: expand mode labels unless Steer is using the space.
|
||||
// Buttons with dataset.active="true" ignore collapse (their label stays).
|
||||
const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
|
||||
globalBarEl.addEventListener('mouseenter', () => {
|
||||
toggles.forEach((t) => t._expandLabel && t._expandLabel());
|
||||
syncGlobalBarExpandedLabels(true);
|
||||
syncPageChatExpandedWidth();
|
||||
schedulePendingDockPosition();
|
||||
setTimeout(schedulePendingDockPosition, 260);
|
||||
});
|
||||
globalBarEl.addEventListener('mouseleave', () => {
|
||||
toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
|
||||
syncGlobalBarExpandedLabels(false);
|
||||
schedulePendingDockPosition();
|
||||
setTimeout(schedulePendingDockPosition, 260);
|
||||
});
|
||||
@@ -9659,6 +9747,7 @@ void main() {
|
||||
pendingDockResizeObserver.observe(globalBarEl);
|
||||
}
|
||||
window.addEventListener('resize', positionPendingDock);
|
||||
window.addEventListener('resize', syncPageChatExpandedWidth);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
globalBarEl.style.opacity = '1';
|
||||
@@ -9705,9 +9794,7 @@ void main() {
|
||||
// If the bar is currently under the cursor, keep all labels expanded -
|
||||
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
|
||||
// would collapse its label while the user's mouse is still on the bar.
|
||||
if (globalBarEl && globalBarEl.matches(':hover')) {
|
||||
[pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
|
||||
}
|
||||
syncGlobalBarExpandedLabels(globalBarEl && globalBarEl.matches(':hover'));
|
||||
|
||||
if (detectBadge) {
|
||||
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
|
||||
@@ -9896,7 +9983,7 @@ void main() {
|
||||
// Remove detection overlays
|
||||
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
|
||||
setLiveState('IDLE');
|
||||
document.getElementById(PREFIX + '-pick-cursor-style')?.remove();
|
||||
document.getElementById(PICK_CURSOR_STYLE_ID)?.remove();
|
||||
window.__IMPECCABLE_LIVE_INIT__ = false;
|
||||
console.log('[impeccable] Live mode exited.');
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* CLI client for the live variant mode poll/reply protocol.
|
||||
*
|
||||
* Usage:
|
||||
* npx impeccable poll # Block until browser event, print JSON
|
||||
* npx impeccable poll --stream # Experimental: keep polling; one JSON line per event
|
||||
* npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly
|
||||
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
|
||||
* npx impeccable poll --reply <id> error "msg" # Reply with error
|
||||
* node <scripts_path>/live-poll.mjs # Block until browser event, print JSON
|
||||
* node <scripts_path>/live-poll.mjs --stream # Experimental: keep polling; one JSON line per event
|
||||
* node <scripts_path>/live-poll.mjs --timeout=600000 # Custom timeout (ms); default is long-poll friendly
|
||||
* node <scripts_path>/live-poll.mjs --reply <id> done # Reply "done" to event <id>
|
||||
* node <scripts_path>/live-poll.mjs --reply <id> error "msg" # Reply with error
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
@@ -15,6 +15,11 @@ import { fileURLToPath } from 'node:url';
|
||||
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
|
||||
// Absolute path to a sibling script in this skill's scripts dir, so runtime
|
||||
// error hints print a directly-runnable command instead of a placeholder.
|
||||
const SELF_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const scriptCmd = (name) => `node "${path.join(SELF_DIR, name)}"`;
|
||||
|
||||
// Node's built-in fetch (undici under the hood) enforces a 300s headers
|
||||
// timeout that can't be lowered per-request. We cap each request below
|
||||
// that ceiling and loop in `pollOnce` to synthesize a long poll without
|
||||
@@ -27,7 +32,7 @@ const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_ed
|
||||
function readServerInfo() {
|
||||
const record = readLiveServerInfo(process.cwd());
|
||||
if (!record) {
|
||||
console.error('No running live server found. Start one with: npx impeccable live');
|
||||
console.error(`No running live server found. Start one with: ${scriptCmd('live.mjs')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return record.info;
|
||||
@@ -82,7 +87,7 @@ export function parseReplyArgs(args) {
|
||||
}
|
||||
|
||||
function validateReplyArgs({ id, status }) {
|
||||
const usage = "Usage: npx impeccable poll --reply <id> <status> [--file path] [--data '<json>'] [message]";
|
||||
const usage = `Usage: ${scriptCmd('live-poll.mjs')} --reply <id> <status> [--file path] [--data '<json>'] [message]`;
|
||||
if (!id || id.startsWith('--')) {
|
||||
const err = new Error(`${usage}\nMissing event id after --reply.`);
|
||||
err.code = 'INVALID_REPLY_ARGS';
|
||||
@@ -283,11 +288,11 @@ export async function runPollStream(base, token, {
|
||||
function handlePollError(err) {
|
||||
if (err.code === 'AUTH_FAILED') {
|
||||
console.error(err.message);
|
||||
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
|
||||
console.error(`Try restarting: ${scriptCmd('live-server.mjs')} stop && ${scriptCmd('live.mjs')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (err.cause?.code === 'ECONNREFUSED') {
|
||||
console.error('Live server not running. Start one with: npx impeccable live');
|
||||
console.error(`Live server not running. Start one with: ${scriptCmd('live.mjs')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (err.code === 'ACK_TIMEOUT') {
|
||||
@@ -331,7 +336,7 @@ Harness note:
|
||||
const info = readServerInfo();
|
||||
const base = `http://localhost:${info.port}`;
|
||||
|
||||
// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [--data '<json>'] [message]
|
||||
// Reply mode: node <scripts_path>/live-poll.mjs --reply <id> <status> [--file path] [--data '<json>'] [message]
|
||||
if (args.includes('--reply')) {
|
||||
let reply;
|
||||
try {
|
||||
@@ -345,7 +350,7 @@ Harness note:
|
||||
await postReply(base, info.token, reply);
|
||||
} catch (err) {
|
||||
if (err.cause?.code === 'ECONNREFUSED') {
|
||||
console.error('Live server not running. Start one with: npx impeccable live');
|
||||
console.error(`Live server not running. Start one with: ${scriptCmd('live.mjs')}`);
|
||||
} else {
|
||||
console.error('Reply failed:', err.message);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './lib/design-parser.mjs';
|
||||
import { resolveContextDir } from './context.mjs';
|
||||
import { loadContext } from './context.mjs';
|
||||
import {
|
||||
assembleLiveBrowserScript,
|
||||
assertLiveBrowserScriptParts,
|
||||
@@ -55,7 +55,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const PROJECT_CONTEXT = loadContext(process.cwd());
|
||||
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
|
||||
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
|
||||
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
|
||||
: null;
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
|
||||
@@ -371,10 +375,7 @@ function hasProjectContext() {
|
||||
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
||||
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
||||
// concern, surfaced by the design panel's own empty state.
|
||||
try {
|
||||
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
return !!PROJECT_CONTEXT.hasProduct;
|
||||
}
|
||||
|
||||
function statOrNull(filePath) {
|
||||
@@ -549,8 +550,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdPath = DESIGN_MD_PATH;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from './context.mjs';
|
||||
import { parseTargetPath } from './lib/target-args.mjs';
|
||||
|
||||
export function resolveLiveTarget(cwd = process.cwd(), args = []) {
|
||||
const originalCwd = path.resolve(cwd);
|
||||
let targetPath = null;
|
||||
try {
|
||||
targetPath = parseTargetPath(args, { strict: true });
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const absoluteTargetPath = targetPath
|
||||
? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath)
|
||||
: null;
|
||||
const projectRoot = targetPath
|
||||
? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath })
|
||||
: originalCwd;
|
||||
return {
|
||||
originalCwd,
|
||||
projectRoot,
|
||||
targetPath,
|
||||
absoluteTargetPath,
|
||||
targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {},
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* CLI helper: find an element in source and wrap it in a variant container.
|
||||
*
|
||||
* Usage:
|
||||
* npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
|
||||
* node <scripts_path>/live-wrap.mjs --id SESSION_ID --count N --query "hero-combined-left" [--file path]
|
||||
*
|
||||
* Searches project files for the element matching the query (class name, ID, or
|
||||
* text snippet), wraps it with the variant scaffolding, and prints the file path
|
||||
|
||||
@@ -21,14 +21,16 @@ import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './context.mjs';
|
||||
import { loadContext, resolveTargetSelection } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { resolveLiveTarget } from './live-target.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
const liveTarget = resolveLiveTarget(process.cwd(), args);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: node live.mjs
|
||||
@@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command:
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
{ ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }
|
||||
|
||||
On target_selection_required, prints:
|
||||
{ ok: false, error: "target_selection_required", targetCandidates }
|
||||
|
||||
On config_missing, prints:
|
||||
{ ok: false, error: "config_missing", configPath, hint }
|
||||
|
||||
The agent should then:
|
||||
1. If config_missing, create the config and re-run this script
|
||||
2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
3. Enter the poll loop: node live-poll.mjs`);
|
||||
1. If target_selection_required, ask which app to use and rerun from that child cwd
|
||||
2. If config_missing, create the config and re-run this script
|
||||
3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
4. Enter the poll loop: node live-poll.mjs`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
if (targetSelection) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'target_selection_required',
|
||||
...targetSelection,
|
||||
hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.',
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
const activeCwd = ctx.projectRoot;
|
||||
const outputTargetPath = liveTarget.targetPath || null;
|
||||
|
||||
const missingContext = missingLiveContext(ctx);
|
||||
if (missingContext.length > 0) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'context_missing',
|
||||
missing: missingContext,
|
||||
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 1. Check config (fail fast if missing — no point starting anything else)
|
||||
const checkOut = runScript('live-inject.mjs', ['--check']);
|
||||
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
|
||||
const checkResult = safeParse(checkOut);
|
||||
if (!checkResult || !checkResult.ok) {
|
||||
console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut }));
|
||||
console.log(JSON.stringify({
|
||||
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 2. Start server (or reuse existing)
|
||||
const serverInfo = ensureServerRunning();
|
||||
const serverInfo = ensureServerRunning(activeCwd);
|
||||
if (!serverInfo) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Inject the script tag at the current port
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]);
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd });
|
||||
const injectResult = safeParse(injectOut);
|
||||
if (!injectResult || !injectResult.ok) {
|
||||
console.log(JSON.stringify({
|
||||
@@ -80,22 +123,23 @@ The agent should then:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Load PRODUCT.md + DESIGN.md context.
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// 4. Compute drift-heal: compare resolved inject targets against the
|
||||
// project's HTML files. Orphans are HTML files not covered by config.
|
||||
// Warning only — the agent decides whether to act.
|
||||
const resolvedFiles = resolveFiles(process.cwd(), checkResult.config);
|
||||
const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config);
|
||||
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
|
||||
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
// 5. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: resolvedFiles,
|
||||
liveConfigPath: checkResult.path,
|
||||
configDrift: drift,
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -105,6 +149,13 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function missingLiveContext(ctx) {
|
||||
const missing = [];
|
||||
if (!ctx.hasProduct) missing.push('PRODUCT.md');
|
||||
if (!ctx.hasDesign) missing.push('DESIGN.md');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
@@ -201,11 +252,11 @@ function globToRegex(pattern) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runScript(name, args) {
|
||||
function runScript(name, args, options = {}) {
|
||||
const scriptPath = path.join(__dirname, name);
|
||||
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
||||
try {
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 });
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
|
||||
} catch (err) {
|
||||
// execSync throws on non-zero exit; return stdout if any
|
||||
return err.stdout || err.message || '';
|
||||
@@ -219,10 +270,10 @@ function safeParse(out) {
|
||||
/**
|
||||
* Return { pid, port, token } for the running live server, starting one if needed.
|
||||
*/
|
||||
function ensureServerRunning() {
|
||||
function ensureServerRunning(cwd = process.cwd()) {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
const existing = readLiveServerInfo(cwd)?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
@@ -232,7 +283,7 @@ function ensureServerRunning() {
|
||||
} catch { /* no PID file */ }
|
||||
|
||||
// Start a new server
|
||||
const out = runScript('live-server.mjs', ['--background']);
|
||||
const out = runScript('live-server.mjs', ['--background'], { cwd });
|
||||
return safeParse(out);
|
||||
}
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ jobs:
|
||||
bun-version: latest
|
||||
|
||||
- name: Cache fixture npm downloads
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }}
|
||||
@@ -172,7 +172,7 @@ jobs:
|
||||
${{ runner.os }}-fixture-npm-
|
||||
|
||||
- name: Cache Playwright Chromium
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }}
|
||||
@@ -238,7 +238,7 @@ jobs:
|
||||
bun-version: latest
|
||||
|
||||
- name: Cache fixture npm downloads
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }}
|
||||
@@ -246,7 +246,7 @@ jobs:
|
||||
${{ runner.os }}-fixture-npm-
|
||||
|
||||
- name: Cache Playwright Chromium
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }}
|
||||
@@ -307,7 +307,7 @@ jobs:
|
||||
|
||||
- name: Cache fixture npm downloads
|
||||
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }}
|
||||
@@ -316,7 +316,7 @@ jobs:
|
||||
|
||||
- name: Cache Playwright Chromium
|
||||
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }}
|
||||
@@ -371,7 +371,7 @@ jobs:
|
||||
|
||||
- name: Cache fixture npm downloads
|
||||
if: ${{ env.DEEPSEEK_API_KEY != '' }}
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }}
|
||||
@@ -380,7 +380,7 @@ jobs:
|
||||
|
||||
- name: Cache Playwright Chromium
|
||||
if: ${{ env.DEEPSEEK_API_KEY != '' }}
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: impeccable
|
||||
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
|
||||
version: 3.7.0
|
||||
version: 3.8.0
|
||||
license: Apache 2.0
|
||||
---
|
||||
|
||||
@@ -11,7 +11,7 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
|
||||
|
||||
You MUST do these steps before proceeding:
|
||||
|
||||
1. Run `node .kiro/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
1. Run `node .kiro/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .kiro/skills/impeccable/scripts/context.mjs --target <path>` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user