Compare commits

...
Author SHA1 Message Date
Paul BakausandClaude Opus 4.8 da18929df0 Release skill v3.8.0 and CLI v3.1.0
Bump skill to 3.8.0 (GitHub Copilot design hooks, monorepo-aware
context) and CLI to 3.1.0 (inline detector ignore comments, fail-loudly
on unknown subcommands). Add changelog entries and sync generated
provider output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 05:41:24 +09:00
github-actions[bot] a110ec5ed7 Sync generated provider output 2026-06-21 13:01:51 +00:00
8eedb150c5 Fix React hydration mismatch from live pick-cursor class on SSR roots (#286)
* Fix React hydration mismatch from live pick-cursor class on SSR roots

Entering pick mode toggled a `impeccable-live-pick-cursor` class on
`document.documentElement` (and the insert-axis cursor wrote an inline
`style.cursor` on it). `<html>`/`<body>` are server-rendered by frameworks
like Next.js App Router, so a client-only attribute the server HTML never
emitted makes React 19 log "a tree hydrated but some attributes of the server
rendered HTML didn't match" on the next Fast-Refresh re-render. It surfaced as
a console.error that flaked the nextjs-app-router live-e2e fixture's
expectConsoleClean probe.

This is the same root-cause class as the scroll-anchor lock fixed in #276
(client mutation of a hydrated SSR root), but a separate offender that fix did
not cover. Apply the same shape: drive the pick / insert cursor entirely
through the textContent of one injected `<style>` keyed by PICK_CURSOR_STYLE_ID,
never by a class or inline style on `<html>`. Same computed effect (global
`cursor` rule, reverted inside the overlay chrome), recreated on activation and
removed on teardown.

Regression guard updated to pin the new shape: no
`document.documentElement.classList.*` mutation anywhere in the overlay, the
cursor applied through the injected style, and the style removed by id on exit.

Verified end-to-end: the nextjs-app-router live-e2e fixture now passes the full
click -> Go -> cycle -> accept cycle with a clean console.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Remove now-dead pageInteractionCursorActive flag

The flag's only reader was the old inline-style cleanup branch in
syncPageInteractionCursor, which the stylesheet refactor removed. It is now
write-only, so drop the declaration and both writes (Greptile review). No
behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:01:17 +09:00
github-actions[bot] 55d11fb2ad Sync generated provider output 2026-06-21 12:42:04 +00:00
776c019041 Add inline, in-file ignore comments for the detector (#283) (#285)
* Add inline, in-file ignore comments for the detector (issue #283)

Complement config ignores with eslint-disable-style waivers that live where
they apply and travel with the file when it leaves the repo. The motivating
case is a generated/exported standalone document that legitimately uses a
first-party brand typeface (on the overused-font list) and is later scanned
without .impeccable/config.json present.

Marker is comment-syntax-agnostic (works in //, /* */, <!-- -->, #, {/* */}):

  impeccable-disable <rule>[, <rule>...] [-- reason | : reason]   whole file
  impeccable-disable-line <rule>...                               same line
  impeccable-disable-next-line <rule>...                          next line

Bare directive or * means every rule; reason is optional and discarded at
scan time. Behavior is suppression, for parity with config ignores.

Implementation:
- New pure module cli/engine/shared/inline-ignores.mjs (parser + filter, no
  Node deps). Static-HTML findings have no line number, so only whole-file
  directives apply there -- exactly the standalone-document case; the
  regex/text engine additionally honors the line-scoped forms.
- Wired into detectText and detectHtml, gated by options.inlineIgnores.
- detect CLI applies inline ignores by default; --no-inline-ignores skips
  just them, --no-config skips config and inline ignores together.

Docs: config.md (new section), detector.md, README. skill/reference/hooks.md
reversed its prior "inline comments are not supported" guidance and now points
the agent to inline waivers for the travels-with-the-file case. Changelog 3.x.

Tests: tests/inline-ignores.test.mjs (parser units, detectText/detectHtml
integration, CLI end-to-end), registered in the detector suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Reconcile design hook wording with inline ignores

Two hook-side fixes prompted by review of the new inline-ignore feature:

1. Clean-ack steer line. The old line ("Keep typography hierarchy, spacing
   rhythm, and color contrast intentional on the next change.") read as an
   odd non-sequitur after "No anti-patterns." Reworded the whole clean ack to
   say what it means: a clean scan only clears the deterministic rule set, not
   overall design quality, so keep following the design system and skill
   guidance. Now: "Design hook scanned X. No deterministic design-quality
   issues found. That does not mean the design is good: keep following the
   project design system and the impeccable skill guidance."

2. Directive footer. It still told the agent "Do not add source comments such
   as `impeccable: ignore`; those pollute the code and do not suppress hook
   findings." That is now misleading: the hook runs the same detector engine
   as the CLI, which honors inline `impeccable-disable` waivers, so they DO
   suppress hook findings (consistent with config ignores, which filterFindings
   already honors). Reworded to: don't silence a real finding to skip fixing
   it; suppress only after the user confirms intent; prefer a config ignore,
   and reach for an inline `impeccable-disable <rule>` comment only when the
   waiver must travel with a file that leaves the repo.

Added a hook test asserting an inline `impeccable-disable-line` comment makes
the hook scan the file clean (locks in the cross-cutting behavior), and updated
the clean-ack / footer assertions to the new wording.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Address review on inline-ignores parser

- Case-insensitive fast-path bail-out (Cursor): the cheap substring guard was
  lowercase-only while DIRECTIVE_RE has the `i` flag, so a mixed-case marker
  like `Impeccable-Disable` skipped parsing entirely and never suppressed.
  Switched the guard to `/impeccable-disable/i.test(...)`. Added a regression
  test.
- Removed the unreachable `-->` branch from TRAILING_CLOSER_RE (Greptile):
  `--+>` already matches `-->` and any longer dash run.
- Replaced the always-truthy lazy-match + `if (sep)` reason strip with an
  explicit first-separator slice (Greptile): clearer and drops the dead branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Align inline-ignore line numbering with the detector (CRLF/CR endings)

parseInlineIgnores split lines with /\r\n|\r|\n/, but detectText numbers lines
with split('\n'). On classic `\r`-only endings the two diverged, so a
disable-line / disable-next-line directive could key a different line than the
finding it should waive (Cursor review). Split on '\n' only, matching the
detector exactly; the directive regex already excludes '\r', so a trailing '\r'
on CRLF files is never captured into the rule list. Added a CRLF regression test
through the real detectText.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:41:36 +09:00
github-actions[bot] 68a15b6be4 Sync generated provider output 2026-06-20 15:00:27 +00:00
42be79eab5 Fix monorepo target-selection edge cases from #213 review (#282)
Two Cursor Bugbot Medium findings on the merged monorepo context PR:

- Excluded packages still listed: discoverTargetCandidates added every glob
  match but never applied negated workspace patterns, so an excluded package
  (e.g. "!packages/internal") showed up as a selectable target even though
  resolveWorkspaceProjectRoot sends it back to the repo root. Now filtered
  with the same isExcludedByWorkspacePattern check the resolver uses.
- Empty app list blocks root: resolveTargetSelection returned
  TARGET_SELECTION_REQUIRED whenever projectRoot === repoRoot, even with zero
  discoverable child apps (e.g. `workspaces: ["."]`), leaving an unanswerable
  prompt. It now returns null (use the repo root as the project) when there
  are no candidates.

Also documents two Greptile P2 clarity notes (the four contextSourceStatus
labels incl. the dual meaning of 'fallback', and the deliberate
isMonorepoRoot-before-hasGitBoundary ordering in findMonorepoRoot).

Adds regression tests for both behaviors.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 07:59:56 -07:00
github-actions[bot] 1e4e74793a Sync generated provider output 2026-06-20 10:50:06 +00:00
Abdul WahabandGitHub 0306b41949 Add monorepo context support (#213)
Context files (PRODUCT.md / DESIGN.md) resolve child-first then fall back to the repo root, and /impeccable live lets the user pick a child app in a monorepo. Single-app behavior is unchanged. Closes #202. Co-Authored-By: abdulwahabone
2026-06-20 19:49:37 +09:00
Abdul WahabandGitHub f1e9b3df3a Fix: fail loudly on unknown CLI subcommands (#270)
Unknown/mistyped CLI subcommands now print 'Unknown command' and exit non-zero instead of silently routing to the detector. Closes #266. Version bump and changelog entry deferred (batching). Co-Authored-By: abdulwahabone
2026-06-20 19:34:38 +09:00
2f9dc05978 Give GitHub Copilot equal prominence in harness listings (#280)
Audit of every user-facing surface that enumerates supported harnesses
found GitHub Copilot missing or buried in several. Bring it to parity with
Claude Code, Codex, Cursor, and Gemini.

Missing -> added:
- site/content/reference/hooks.md: the public /docs/hooks page (tagline,
  the post-edit list, and the manifest table) now covers GitHub Copilot,
  including the `.github/hooks/impeccable.json` surface and the
  default-branch/trust note. (Only skill/reference/hooks.md was updated in
  the feature PR; this is the website doc.)
- README.md Design hook section + the manifest surface list.
- site/content/tutorials/getting-started.md hook note.
- site/pages/faq.astro tool-specific setup list and the docs-links list.
- PRODUCT.md audience line and README.npm.md suite description.

Prominence + naming:
- README "Supported Tools" and the homepage hero logo row: move GitHub
  Copilot up to third (after Claude Code) instead of trailing.
- site/pages/designing: list GitHub Copilot earlier, full name.
- README "Supported Tools": the harness link now points at GitHub Copilot
  (github.com/features/copilot) instead of the unrelated VS Code entry.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:27:06 -07:00
github-actions[bot] 221064858e Sync generated provider output 2026-06-20 09:24:47 +00:00
41ff946121 Add GitHub Copilot hook support (CLI + cloud agent) (#279)
* Add GitHub Copilot hook support (CLI + cloud agent)

Wire the Impeccable design detector into GitHub Copilot's hook system so
direct file edits get the same post-edit design feedback the Claude Code,
Codex, and Cursor harnesses already receive.

GitHub Copilot's contract differs from the existing harnesses (verified
against Copilot CLI 1.0.63):
- Repo-level manifest at `.github/hooks/impeccable.json` (read by both the
  CLI, once committed to the default branch, and the cloud/app agent).
- Flat `postToolUse` entries with `bash`/`timeoutSec` and a full-match
  `matcher` regex; the file-editing tools are `edit` and `create`.
- The stdin event uses camelCase `toolName`/`toolArgs`, where `toolArgs` is
  a JSON *string* carrying the touched file under `path`.
- Context is injected via a top-level `additionalContext` string.

Changes:
- hooks.js: buildGitHubHooksManifest() + route `github` in hooksJsonFor().
- providers.js: emitHooks/hooksManifestRel for the github provider.
- hook-lib.mjs: detect the github harness, normalize the camelCase event
  (parse the JSON-string toolArgs -> tool_input.file_path), and emit the
  `additionalContext` payload shape.
- hook-admin.mjs / skills.mjs: install + idempotent-repair the
  `.github/hooks/impeccable.json` manifest (bash-aware marker stripping).
- hooks.md: document GitHub Copilot as a supported harness.
- Tests for the builder, routing, event normalization, and end-to-end run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Cover Copilot apply_patch edits in the hook (live-verified)

The first cut matched only `edit|create`, the tool names `copilot -p` uses.
A live trace against Copilot CLI 1.0.63 in an interactive session showed it
edits files via `apply_patch`, whose toolArgs is a raw OpenAI-format patch
string (`*** Begin Patch` / `*** Add File:`), not JSON. With the narrow
matcher the hook command never ran.

- hooks.js / hook-admin.mjs: matcher -> `edit|create|apply_patch`.
- hook-lib.mjs: normalizeGitHubEvent now routes apply_patch's raw patch
  string into tool_input.command (reusing the existing parseApplyPatchPaths /
  resolveTargetFiles plumbing) and only JSON-parses toolArgs for the
  edit/create/view tools. tool_name is normalized to apply_patch so the patch
  path is extracted even if a future build relabels the tool.
- Tests: apply_patch matcher assertions, event normalization, and an
  end-to-end runHook covering the interactive/cloud path.

Verified live: a trusted interactive `apply_patch` edit fires the hook and
returns the expected `additionalContext` design reminder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Address review feedback + add changelog entry

- hook-lib.mjs (Bugbot, low): looksLikeApplyPatch no longer misroutes an
  edit/create event whose edited *content* contains apply_patch markers. A
  real apply_patch payload is a raw string that does not parse as JSON; an
  edit payload is a JSON object, so only non-JSON-object strings are treated
  as apply_patch. Edit events keep extracting `path`. Adds a regression test.
- skills.mjs (Bugbot, medium): document why `.github` is intentionally
  excluded from hookScriptPathForProvider. Its hook manifest is committed and
  shared (read by the Copilot cloud agent and teammates), so the command must
  stay portable via `$(git rev-parse ...)`; rewriting it to a machine-local
  absolute path would break those. GitHub skills are project-scoped, so the
  project-relative path resolves.
- changelog: add an Upcoming (v3.x placeholder) entry for the Copilot hook.
  Version is not bumped yet (batching with other changes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 02:24:18 -07:00
793feda5a0 Guard plugin/skill version drift in the build (issue #274) (#278)
* Guard plugin/skill version drift in the build (issue #274)

The Claude Code marketplace installs from the committed ./plugin subtree,
so a version disagreement between the hand-edited manifests and the
generated subtree ships stale content under a wrong version. This is the
class of bug reported in #274: a version bump that doesn't regenerate
./plugin (e.g. PR #252, where root plugin.json was 3.7.0 while
plugin/.claude-plugin/plugin.json was still 3.6.0) merges a drift window
onto main, and marketplace/Cowork installs pull the stale subtree.

Add a build-time validator that treats root .claude-plugin/plugin.json
as the source of truth and fails the build if any of these disagree:
  - .claude-plugin/marketplace.json plugins[0].version (hand-edited; the
    post-merge sync workflow never bumps versions, so it can't repair a
    mismatch here)
  - plugin/.claude-plugin/plugin.json version (generated subtree)
  - plugin/skills/impeccable/SKILL.md frontmatter version (bundled skill)

It only fires on an inconsistent bump; PRs that don't touch versions keep
every file in agreement and stay silent. The pure comparison lives in
scripts/lib/validate-plugin-versions.js with direct unit coverage; build.js
owns the logging and the non-zero exit. Documents the regenerate-on-bump
step in CLAUDE.md's Versioning section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Harden version-drift collector against malformed/incomplete manifests

Address Greptile review on #278:

- Wrap every file read/parse in a sentinel helper (extractFromFile) so a
  half-edited manifest — the exact state during a version bump — yields a
  clean "could not parse (...)" diagnostic naming the file instead of a raw
  JSON.parse stack trace out of build().
- Report a present-but-malformed root plugin.json, or one missing its
  `version` field, as an explicit error. Previously `undefined` version
  short-circuited the build wrapper's `source == null` guard and passed
  silently. collectPluginVersions now returns an `errors` array; build.js
  fails on errors + mismatches combined, and only the genuinely-absent root
  manifest is a no-op skip.

Adds 4 unit tests: malformed checked manifest, malformed root, missing
version field, and the absent-root no-errors case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Make SKILL.md frontmatter version read CRLF-tolerant

Address Cursor Bugbot review on #278: readSkillFrontmatterVersion only
matched `\n` delimiters, while the shared parseFrontmatter in
scripts/lib/utils.js accepts `\r?\n`. A bundled SKILL.md saved with CRLF
line endings would parse to a null version and trip a false mismatch
against root plugin.json even when the version line is correct.

Match the shared parser's `\r?\n` tolerance and drop the `$` anchor on
the version line (it would not match before a `\r`). Adds CRLF coverage
for both readSkillFrontmatterVersion and collectPluginVersions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Re-trigger CI (no file change)

CI did not fire for 5cda9f6b; force a fresh run on the current tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:51:16 +09:00
github-actions[bot] c0d50e36da Sync generated provider output 2026-06-20 04:51:03 +00:00
67e8757401 Fix React hydration mismatch from live scroll-lock on SSR roots (#276)
* Fix React hydration mismatch from live scroll-lock on SSR roots

The live overlay's startScrollLock disabled the browser's scroll
anchoring by setting `overflow-anchor: none` as an inline style on
`<html>` and `<body>`. On frameworks that server-render those roots
(notably Next.js App Router), that client-only inline style desyncs from
the server HTML, so React 19 logs "a tree hydrated but some attributes
of the server rendered HTML didn't match" on the next Fast-Refresh
re-render. It surfaced as a flaky failure of the nextjs-app-router
live-e2e fixture's expectConsoleClean probe.

Inject the suppression as a `<style>` rule keyed by a stable id instead
of mutating inline styles on hydrated host elements. Same computed
effect, but React no longer sees a client-only attribute on `<html>` /
`<body>`. The rule is recreated on every startScrollLock and removed on
teardown, so reload survival (driven by the persisted scroll key) is
unchanged.

Adds a regression guard pinning the new shape (no inline overflowAnchor
mutation on html/body; injected <style> created and removed by id).
Verified end-to-end: the nextjs-app-router live-e2e fixture now passes
the expectConsoleClean probe deterministically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Relax regression-guard regex spans to {0,400}

Address Greptile review: the {0,200}/{0,220}/{0,160} character-span
limits between the injected-style constructs were tight enough that an
innocent refactor or added comment inside startScrollLock could silently
break the shape-check. Widen each segment to {0,400}; the guard still
passes on the fix and still fails when the inline html/body overflowAnchor
mutation is reintroduced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 13:50:32 +09:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1fd1eb11bc chore(deps-dev): bump the bun-minor-and-patch group across 1 directory with 9 updates (#248)
Bumps the bun-minor-and-patch group with 9 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@ai-sdk/anthropic](https://github.com/vercel/ai/tree/HEAD/packages/anthropic) | `3.0.81` | `3.0.84` |
| [@ai-sdk/google](https://github.com/vercel/ai/tree/HEAD/packages/google) | `3.0.80` | `3.0.82` |
| [@ai-sdk/openai](https://github.com/vercel/ai/tree/HEAD/packages/openai) | `3.0.68` | `3.0.71` |
| [@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript) | `0.3.168` | `0.3.178` |
| [@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript) | `0.102.0` | `0.104.2` |
| [ai](https://github.com/vercel/ai/tree/HEAD/packages/ai) | `6.0.197` | `6.0.206` |
| [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) | `6.4.4` | `6.4.7` |
| [playwright](https://github.com/microsoft/playwright) | `1.60.0` | `1.61.0` |
| [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler) | `4.98.0` | `4.100.0` |



Updates `@ai-sdk/anthropic` from 3.0.81 to 3.0.84
- [Release notes](https://github.com/vercel/ai/releases)
- [Changelog](https://github.com/vercel/ai/blob/@ai-sdk/anthropic@3.0.84/packages/anthropic/CHANGELOG.md)
- [Commits](https://github.com/vercel/ai/commits/@ai-sdk/anthropic@3.0.84/packages/anthropic)

Updates `@ai-sdk/google` from 3.0.80 to 3.0.82
- [Release notes](https://github.com/vercel/ai/releases)
- [Changelog](https://github.com/vercel/ai/blob/@ai-sdk/google@3.0.82/packages/google/CHANGELOG.md)
- [Commits](https://github.com/vercel/ai/commits/@ai-sdk/google@3.0.82/packages/google)

Updates `@ai-sdk/openai` from 3.0.68 to 3.0.71
- [Release notes](https://github.com/vercel/ai/releases)
- [Changelog](https://github.com/vercel/ai/blob/@ai-sdk/openai@3.0.71/packages/openai/CHANGELOG.md)
- [Commits](https://github.com/vercel/ai/commits/@ai-sdk/openai@3.0.71/packages/openai)

Updates `@anthropic-ai/claude-agent-sdk` from 0.3.168 to 0.3.178
- [Release notes](https://github.com/anthropics/claude-agent-sdk-typescript/releases)
- [Changelog](https://github.com/anthropics/claude-agent-sdk-typescript/blob/main/CHANGELOG.md)
- [Commits](https://github.com/anthropics/claude-agent-sdk-typescript/compare/v0.3.168...v0.3.178)

Updates `@anthropic-ai/sdk` from 0.102.0 to 0.104.2
- [Release notes](https://github.com/anthropics/anthropic-sdk-typescript/releases)
- [Changelog](https://github.com/anthropics/anthropic-sdk-typescript/blob/main/CHANGELOG.md)
- [Commits](https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.102.0...sdk-v0.104.2)

Updates `ai` from 6.0.197 to 6.0.206
- [Release notes](https://github.com/vercel/ai/releases)
- [Changelog](https://github.com/vercel/ai/blob/ai@6.0.206/packages/ai/CHANGELOG.md)
- [Commits](https://github.com/vercel/ai/commits/ai@6.0.206/packages/ai)

Updates `astro` from 6.4.4 to 6.4.7
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@6.4.7/packages/astro)

Updates `playwright` from 1.60.0 to 1.61.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.60.0...v1.61.0)

Updates `wrangler` from 4.98.0 to 4.100.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/wrangler@4.100.0/packages/wrangler)

---
updated-dependencies:
- dependency-name: "@ai-sdk/anthropic"
  dependency-version: 3.0.84
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: "@ai-sdk/google"
  dependency-version: 3.0.82
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: "@ai-sdk/openai"
  dependency-version: 3.0.71
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: "@anthropic-ai/claude-agent-sdk"
  dependency-version: 0.3.177
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: "@anthropic-ai/sdk"
  dependency-version: 0.104.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: bun-minor-and-patch
- dependency-name: ai
  dependency-version: 6.0.205
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: astro
  dependency-version: 6.4.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: playwright
  dependency-version: 1.61.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: bun-minor-and-patch
- dependency-name: wrangler
  dependency-version: 4.100.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: bun-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-19 21:37:29 -07:00
github-actions[bot] a42d4a7060 Sync generated provider output 2026-06-20 04:29:05 +00:00
a1560fb0f5 Fix misleading npx hints in live-mode poll/wrap scripts (#275)
* Replace npx hints in live scripts with bundled-script paths

The live-mode poll/wrap scripts are invoked by the agent via
`node {{scripts_path}}/live-*.mjs`, never through the `npx impeccable`
CLI. Their help text and runtime error hints still pointed at
`npx impeccable poll|live|wrap`, which is misleading and, for the
error paths, not directly runnable.

- Docstrings/comments (never executed): switch to the
  `node <scripts_path>/...` convention already used by live-server.mjs.
- Runtime-printed error/usage strings: resolve the script's own dir via
  import.meta.url and print a real, copy-pasteable absolute path instead
  of a placeholder.

Verified by triggering the error paths from the synced bundle and by
running the live-mode E2E (vite8-react-modal) through the full cycle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Quote script paths in runtime hints to handle spaces

Paths containing spaces (e.g. /Users/john doe/...) would otherwise
produce a non-runnable command. Addresses Greptile review feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:28:35 -07:00
d5403f9d65 Bump extension to v1.2.1 and add consolidated changelog (#265)
Release bump covering the recent extension fixes that ship together:
toolbar badge count parity (#262), local file:// scan failure messaging
(#258), and the Kinpaku popup theme (#260).

Merge after #264, #261, and #263.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 21:51:15 -07:00
1f4021b16c Fix: make Chrome extension toolbar badge count anti-patterns (#264)
The toolbar badge counted flagged elements (state.findings.length) while
the popup and DevTools panel counted total anti-pattern findings, so the
same scan showed two numbers (e.g. 21 vs 34 on the design-system page).
Since the surfaces are labeled "anti-patterns", count total findings in
the badge too so all three agree.

Closes #262

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 21:50:36 -07:00
046a8593f5 Fix: surface scan failures in extension popup for local files (#261)
* Fix: surface scan failures in extension popup for local files

Scanning a local file:// page with "Allow access to file URLs" off left
the popup stuck on "Scanning..." because the blocked content-script
injection returned silently. ensureContentScriptInjected() now returns the
real error, and sendScanToTab() sends a scan-failed message that the popup
renders as a small line, with a permission hint shown only for file:// tabs.

Fixes #258

Co-authored-by: Cursor <cursoragent@cursor.com>

* Improve: report the actual error when a non-file scan fails

The generic "This page can't be scanned." gave no reason. Non-file failures
now read "Couldn't scan this page: <error>" so the user sees what Chrome
reported instead of a dead end.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix: scope popup broadcasts to the active tab

The popup acted on every findings-updated / scan-failed / overlays broadcast
regardless of which tab it targeted, so a background or DevTools-driven
rescan on another tab could reset the button or show a spurious error. Cache
the active tab id and ignore broadcasts for other tabs.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 21:49:16 -07:00
Abdul WahabandGitHub e371c99f08 Update extension popup to Kinpaku theme (#263)
* Update extension popup to Kinpaku theme

* Fix popup light mode contrast
2026-06-18 21:48:37 -07:00
github-actions[bot] d949abd180 Sync generated provider output 2026-06-19 01:47:38 +00:00
Abdul WahabandGitHub 07667ed08f Add quiet mode to detect CLI (#259) 2026-06-18 18:47:02 -07:00
Paul Bakaus 1c897a09c8 Polish docs page 2026-06-17 17:49:38 +09:00
Paul Bakaus 617b3a6e5e Polish live mode and slop pages 2026-06-17 17:38:07 +09:00
Paul Bakaus c7539c867d Fix live picker sizing and divider detection 2026-06-17 13:10:53 +09:00
Paul Bakaus 4f50db2bca Fix live picker steer sizing 2026-06-17 12:36:58 +09:00
github-actions[bot] f726894373 Sync generated provider output 2026-06-17 03:00:20 +00:00
Paul BakausandGitHub 99a284a0d9 Fix live page editable focus handling (#256) 2026-06-16 19:59:48 -07:00
github-actions[bot] b86f2cc353 Sync generated provider output 2026-06-17 02:51:20 +00:00
Paul BakausandGitHub 8b0c895703 [codex] Fix CLI skill update detection (#257)
* Fix CLI skill update detection

* Preserve linked skills during install refresh

* Keep existing installs working offline

* Respect provider scope during install refresh
2026-06-16 19:50:40 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1268f10b76 chore(deps): bump actions/cache from 4 to 5 in the github-actions group (#249)
Bumps the github-actions group with 1 update: [actions/cache](https://github.com/actions/cache).


Updates `actions/cache` from 4 to 5
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 06:56:23 -07:00
Paul BakausandClaude Opus 4.8 795e8ed5e5 fix(skill): bundle detector config dependency so critique runs (#254)
The bundled detector's cli/main.mjs imports ../../lib/impeccable-config.mjs,
which in the source CLI resolves to cli/lib/impeccable-config.mjs. The skill
build only copies cli/engine/** into scripts/detector/**, leaving that
dependency behind, so from the bundled scripts/detector/cli/main.mjs the same
import resolved to scripts/lib/impeccable-config.mjs and failed with
"Cannot find module .../lib/impeccable-config.mjs". /impeccable critique (and
any detector-backed command) crashed on startup for every provider since #252.

Teach the detector bundler to copy out-of-bundle engine dependencies into the
skill's scripts/lib/, and add a build test that walks every bundled script and
asserts each relative import resolves to another bundled file, so a future
out-of-bundle dependency fails the test instead of the user.

Skill v3.7.1 (patch). CLI unchanged — the engine resolves fine in the CLI's
own tree; only the skill bundling was wrong.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:49:31 +09:00
322 changed files with 30838 additions and 2944 deletions
+2 -1
View File
@@ -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.
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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=`.
+709 -28
View File
@@ -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;
+110 -4
View File
@@ -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 } : {};
}
+143 -56
View File
@@ -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.');
}
+16 -11
View File
@@ -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
+72 -21
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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"
+2 -2
View File
@@ -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.
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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=`.
+709 -28
View File
@@ -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;
+110 -4
View File
@@ -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 } : {};
}
+143 -56
View File
@@ -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.');
}
+16 -11
View File
@@ -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
+72 -21
View File
@@ -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.mdnot 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.mdnot 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);
}
+2 -2
View File
@@ -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.
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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=`.
+709 -28
View File
@@ -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;
+110 -4
View File
@@ -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 } : {};
}
+143 -56
View File
@@ -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.');
}
+16 -11
View File
@@ -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
+72 -21
View File
@@ -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.mdnot 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.mdnot 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);
}
+2 -2
View File
@@ -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.
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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=`.
+709 -28
View File
@@ -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;
+110 -4
View File
@@ -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 } : {};
}
+143 -56
View File
@@ -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.');
}
+16 -11
View File
@@ -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
+72 -21
View File
@@ -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.mdnot 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.mdnot 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);
}
+13
View File
@@ -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
}
]
}
}
+2 -2
View File
@@ -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.
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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=`.
+709 -28
View File
@@ -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;
+110 -4
View File
@@ -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 } : {};
}
+143 -56
View File
@@ -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.');
}
+16 -11
View File
@@ -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
+72 -21
View File
@@ -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.mdnot 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.mdnot 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);
}
+8 -8
View File
@@ -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') }}
+2 -2
View File
@@ -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