quiver-cli 1.3.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (384) hide show
  1. package/README.md +197 -239
  2. package/dist/cli.js +3163 -2040
  3. package/package.json +10 -11
  4. package/template/.agents/AGENTS.md +0 -26
  5. package/template/.agents/commands/cp.md +0 -108
  6. package/template/.agents/commands/next-setup.md +0 -1064
  7. package/template/.agents/commands/review.md +0 -68
  8. package/template/.agents/commands/terraform-readme.md +0 -38
  9. package/template/.agents/config.json +0 -80
  10. package/template/.agents/plugins/opencode/rtk.ts +0 -34
  11. package/template/.agents/skills/agent-browser/SKILL.md +0 -51
  12. package/template/.agents/skills/apps/skybridge/SKILL.md +0 -50
  13. package/template/.agents/skills/code/cleanup/SKILL.md +0 -26
  14. package/template/.agents/skills/code/improve/SKILL.md +0 -122
  15. package/template/.agents/skills/code/improve/references/audit-playbook.md +0 -130
  16. package/template/.agents/skills/code/improve/references/closing-the-loop.md +0 -96
  17. package/template/.agents/skills/code/improve/references/plan-template.md +0 -197
  18. package/template/.agents/skills/code/vercel-react-best-practices/AGENTS.md +0 -3810
  19. package/template/.agents/skills/code/vercel-react-best-practices/README.md +0 -123
  20. package/template/.agents/skills/code/vercel-react-best-practices/SKILL.md +0 -149
  21. package/template/.agents/skills/code/vercel-react-best-practices/metadata.json +0 -15
  22. package/template/.agents/skills/code/vercel-react-best-practices/rules/_sections.md +0 -46
  23. package/template/.agents/skills/code/vercel-react-best-practices/rules/_template.md +0 -28
  24. package/template/.agents/skills/code/vercel-react-best-practices/rules/advanced-effect-event-deps.md +0 -56
  25. package/template/.agents/skills/code/vercel-react-best-practices/rules/advanced-event-handler-refs.md +0 -55
  26. package/template/.agents/skills/code/vercel-react-best-practices/rules/advanced-init-once.md +0 -42
  27. package/template/.agents/skills/code/vercel-react-best-practices/rules/advanced-use-latest.md +0 -39
  28. package/template/.agents/skills/code/vercel-react-best-practices/rules/async-api-routes.md +0 -38
  29. package/template/.agents/skills/code/vercel-react-best-practices/rules/async-cheap-condition-before-await.md +0 -37
  30. package/template/.agents/skills/code/vercel-react-best-practices/rules/async-defer-await.md +0 -82
  31. package/template/.agents/skills/code/vercel-react-best-practices/rules/async-dependencies.md +0 -51
  32. package/template/.agents/skills/code/vercel-react-best-practices/rules/async-parallel.md +0 -28
  33. package/template/.agents/skills/code/vercel-react-best-practices/rules/async-suspense-boundaries.md +0 -99
  34. package/template/.agents/skills/code/vercel-react-best-practices/rules/bundle-analyzable-paths.md +0 -63
  35. package/template/.agents/skills/code/vercel-react-best-practices/rules/bundle-barrel-imports.md +0 -60
  36. package/template/.agents/skills/code/vercel-react-best-practices/rules/bundle-conditional.md +0 -31
  37. package/template/.agents/skills/code/vercel-react-best-practices/rules/bundle-defer-third-party.md +0 -49
  38. package/template/.agents/skills/code/vercel-react-best-practices/rules/bundle-dynamic-imports.md +0 -35
  39. package/template/.agents/skills/code/vercel-react-best-practices/rules/bundle-preload.md +0 -50
  40. package/template/.agents/skills/code/vercel-react-best-practices/rules/client-event-listeners.md +0 -74
  41. package/template/.agents/skills/code/vercel-react-best-practices/rules/client-localstorage-schema.md +0 -71
  42. package/template/.agents/skills/code/vercel-react-best-practices/rules/client-passive-event-listeners.md +0 -48
  43. package/template/.agents/skills/code/vercel-react-best-practices/rules/client-swr-dedup.md +0 -56
  44. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-batch-dom-css.md +0 -107
  45. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-cache-function-results.md +0 -80
  46. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-cache-property-access.md +0 -28
  47. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-cache-storage.md +0 -70
  48. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-combine-iterations.md +0 -32
  49. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-early-exit.md +0 -50
  50. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-flatmap-filter.md +0 -60
  51. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-hoist-regexp.md +0 -45
  52. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-index-maps.md +0 -37
  53. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-length-check-first.md +0 -49
  54. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-min-max-loop.md +0 -82
  55. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-request-idle-callback.md +0 -105
  56. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-set-map-lookups.md +0 -24
  57. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-tosorted-immutable.md +0 -57
  58. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-activity.md +0 -26
  59. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md +0 -47
  60. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-conditional-render.md +0 -40
  61. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-content-visibility.md +0 -38
  62. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-hoist-jsx.md +0 -46
  63. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md +0 -82
  64. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md +0 -30
  65. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-resource-hints.md +0 -85
  66. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-script-defer-async.md +0 -68
  67. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-svg-precision.md +0 -28
  68. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-usetransition-loading.md +0 -75
  69. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-defer-reads.md +0 -39
  70. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-dependencies.md +0 -45
  71. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md +0 -40
  72. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-derived-state.md +0 -29
  73. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-functional-setstate.md +0 -74
  74. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-lazy-state-init.md +0 -58
  75. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-memo-with-default-value.md +0 -38
  76. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-memo.md +0 -44
  77. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-move-effect-to-event.md +0 -45
  78. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-no-inline-components.md +0 -82
  79. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md +0 -35
  80. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-split-combined-hooks.md +0 -64
  81. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-transitions.md +0 -40
  82. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-use-deferred-value.md +0 -59
  83. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md +0 -73
  84. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-after-nonblocking.md +0 -73
  85. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-auth-actions.md +0 -96
  86. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-cache-lru.md +0 -41
  87. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-cache-react.md +0 -76
  88. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-dedup-props.md +0 -65
  89. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-hoist-static-io.md +0 -149
  90. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-no-shared-module-state.md +0 -50
  91. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-parallel-fetching.md +0 -83
  92. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-parallel-nested-fetching.md +0 -34
  93. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-serialization.md +0 -38
  94. package/template/.agents/skills/data/prisma-cli/SKILL.md +0 -247
  95. package/template/.agents/skills/data/prisma-cli/references/db-execute.md +0 -78
  96. package/template/.agents/skills/data/prisma-cli/references/db-pull.md +0 -185
  97. package/template/.agents/skills/data/prisma-cli/references/db-push.md +0 -148
  98. package/template/.agents/skills/data/prisma-cli/references/db-seed.md +0 -188
  99. package/template/.agents/skills/data/prisma-cli/references/debug.md +0 -46
  100. package/template/.agents/skills/data/prisma-cli/references/dev.md +0 -157
  101. package/template/.agents/skills/data/prisma-cli/references/format.md +0 -48
  102. package/template/.agents/skills/data/prisma-cli/references/generate.md +0 -173
  103. package/template/.agents/skills/data/prisma-cli/references/init.md +0 -136
  104. package/template/.agents/skills/data/prisma-cli/references/mcp.md +0 -38
  105. package/template/.agents/skills/data/prisma-cli/references/migrate-deploy.md +0 -127
  106. package/template/.agents/skills/data/prisma-cli/references/migrate-dev.md +0 -145
  107. package/template/.agents/skills/data/prisma-cli/references/migrate-diff.md +0 -89
  108. package/template/.agents/skills/data/prisma-cli/references/migrate-reset.md +0 -78
  109. package/template/.agents/skills/data/prisma-cli/references/migrate-resolve.md +0 -57
  110. package/template/.agents/skills/data/prisma-cli/references/migrate-status.md +0 -65
  111. package/template/.agents/skills/data/prisma-cli/references/studio.md +0 -137
  112. package/template/.agents/skills/data/prisma-cli/references/validate.md +0 -53
  113. package/template/.agents/skills/data/prisma-client-api/SKILL.md +0 -216
  114. package/template/.agents/skills/data/prisma-client-api/references/client-methods.md +0 -223
  115. package/template/.agents/skills/data/prisma-client-api/references/constructor.md +0 -208
  116. package/template/.agents/skills/data/prisma-client-api/references/filters.md +0 -256
  117. package/template/.agents/skills/data/prisma-client-api/references/model-queries.md +0 -281
  118. package/template/.agents/skills/data/prisma-client-api/references/query-options.md +0 -276
  119. package/template/.agents/skills/data/prisma-client-api/references/raw-queries.md +0 -194
  120. package/template/.agents/skills/data/prisma-client-api/references/relations.md +0 -308
  121. package/template/.agents/skills/data/prisma-client-api/references/transactions.md +0 -184
  122. package/template/.agents/skills/design/impeccable/SKILL.md +0 -84
  123. package/template/.agents/skills/design/impeccable/reference/adapt.md +0 -312
  124. package/template/.agents/skills/design/impeccable/reference/adapt.native.md +0 -58
  125. package/template/.agents/skills/design/impeccable/reference/android.md +0 -40
  126. package/template/.agents/skills/design/impeccable/reference/animate.md +0 -86
  127. package/template/.agents/skills/design/impeccable/reference/audit.md +0 -136
  128. package/template/.agents/skills/design/impeccable/reference/audit.native.md +0 -139
  129. package/template/.agents/skills/design/impeccable/reference/bolder.md +0 -31
  130. package/template/.agents/skills/design/impeccable/reference/clarify.md +0 -94
  131. package/template/.agents/skills/design/impeccable/reference/colorize.md +0 -86
  132. package/template/.agents/skills/design/impeccable/reference/craft-floor.md +0 -42
  133. package/template/.agents/skills/design/impeccable/reference/craft.md +0 -5
  134. package/template/.agents/skills/design/impeccable/reference/critique.md +0 -788
  135. package/template/.agents/skills/design/impeccable/reference/degraded/asset-producer.md +0 -90
  136. package/template/.agents/skills/design/impeccable/reference/degraded/documenter.md +0 -24
  137. package/template/.agents/skills/design/impeccable/reference/degraded/finish-reviewer.md +0 -37
  138. package/template/.agents/skills/design/impeccable/reference/degraded/manual-edit-applier.md +0 -92
  139. package/template/.agents/skills/design/impeccable/reference/delight.md +0 -70
  140. package/template/.agents/skills/design/impeccable/reference/distill.md +0 -111
  141. package/template/.agents/skills/design/impeccable/reference/doctor.md +0 -53
  142. package/template/.agents/skills/design/impeccable/reference/document.md +0 -416
  143. package/template/.agents/skills/design/impeccable/reference/extract.md +0 -69
  144. package/template/.agents/skills/design/impeccable/reference/harden.md +0 -336
  145. package/template/.agents/skills/design/impeccable/reference/hooks.md +0 -105
  146. package/template/.agents/skills/design/impeccable/reference/init.md +0 -125
  147. package/template/.agents/skills/design/impeccable/reference/ios.md +0 -45
  148. package/template/.agents/skills/design/impeccable/reference/layout.md +0 -84
  149. package/template/.agents/skills/design/impeccable/reference/live-setup.md +0 -102
  150. package/template/.agents/skills/design/impeccable/reference/live.md +0 -323
  151. package/template/.agents/skills/design/impeccable/reference/new-work.md +0 -105
  152. package/template/.agents/skills/design/impeccable/reference/onboard.md +0 -234
  153. package/template/.agents/skills/design/impeccable/reference/operate.md +0 -61
  154. package/template/.agents/skills/design/impeccable/reference/optimize.md +0 -258
  155. package/template/.agents/skills/design/impeccable/reference/overdrive.md +0 -127
  156. package/template/.agents/skills/design/impeccable/reference/polish.md +0 -97
  157. package/template/.agents/skills/design/impeccable/reference/quieter.md +0 -99
  158. package/template/.agents/skills/design/impeccable/reference/routing.md +0 -18
  159. package/template/.agents/skills/design/impeccable/reference/shape.md +0 -59
  160. package/template/.agents/skills/design/impeccable/reference/typeset.md +0 -80
  161. package/template/.agents/skills/design/impeccable/reference/visualize.md +0 -47
  162. package/template/.agents/skills/design/impeccable/scripts/command-metadata.json +0 -94
  163. package/template/.agents/skills/design/impeccable/scripts/concept-seed.mjs +0 -558
  164. package/template/.agents/skills/design/impeccable/scripts/context-signals.mjs +0 -334
  165. package/template/.agents/skills/design/impeccable/scripts/context.mjs +0 -1450
  166. package/template/.agents/skills/design/impeccable/scripts/critique-storage.mjs +0 -213
  167. package/template/.agents/skills/design/impeccable/scripts/detect-csp.mjs +0 -198
  168. package/template/.agents/skills/design/impeccable/scripts/detect.mjs +0 -21
  169. package/template/.agents/skills/design/impeccable/scripts/detector/browser/injected/index.mjs +0 -2023
  170. package/template/.agents/skills/design/impeccable/scripts/detector/cli/main.mjs +0 -438
  171. package/template/.agents/skills/design/impeccable/scripts/detector/design-system.mjs +0 -983
  172. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js +0 -8283
  173. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns.mjs +0 -50
  174. package/template/.agents/skills/design/impeccable/scripts/detector/engines/browser/detect-url.mjs +0 -372
  175. package/template/.agents/skills/design/impeccable/scripts/detector/engines/regex/detect-text.mjs +0 -768
  176. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +0 -1186
  177. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/detect-html.mjs +0 -264
  178. package/template/.agents/skills/design/impeccable/scripts/detector/engines/visual/screenshot-contrast.mjs +0 -189
  179. package/template/.agents/skills/design/impeccable/scripts/detector/findings.mjs +0 -18
  180. package/template/.agents/skills/design/impeccable/scripts/detector/node/file-system.mjs +0 -212
  181. package/template/.agents/skills/design/impeccable/scripts/detector/profile/profiler.mjs +0 -166
  182. package/template/.agents/skills/design/impeccable/scripts/detector/registry/antipatterns.mjs +0 -617
  183. package/template/.agents/skills/design/impeccable/scripts/detector/rules/checks.mjs +0 -5580
  184. package/template/.agents/skills/design/impeccable/scripts/detector/shared/color.mjs +0 -124
  185. package/template/.agents/skills/design/impeccable/scripts/detector/shared/constants.mjs +0 -112
  186. package/template/.agents/skills/design/impeccable/scripts/detector/shared/fonts.mjs +0 -30
  187. package/template/.agents/skills/design/impeccable/scripts/detector/shared/inline-ignores.mjs +0 -148
  188. package/template/.agents/skills/design/impeccable/scripts/detector/shared/page.mjs +0 -7
  189. package/template/.agents/skills/design/impeccable/scripts/doctor.mjs +0 -336
  190. package/template/.agents/skills/design/impeccable/scripts/embed-prompt.mjs +0 -133
  191. package/template/.agents/skills/design/impeccable/scripts/generate-image.mjs +0 -240
  192. package/template/.agents/skills/design/impeccable/scripts/hook-admin.mjs +0 -741
  193. package/template/.agents/skills/design/impeccable/scripts/hook-before-edit.mjs +0 -516
  194. package/template/.agents/skills/design/impeccable/scripts/hook-lib.mjs +0 -2100
  195. package/template/.agents/skills/design/impeccable/scripts/hook.mjs +0 -78
  196. package/template/.agents/skills/design/impeccable/scripts/lib/artifact-schema.mjs +0 -93
  197. package/template/.agents/skills/design/impeccable/scripts/lib/composition-catalog.mjs +0 -200
  198. package/template/.agents/skills/design/impeccable/scripts/lib/concept-catalog.mjs +0 -357
  199. package/template/.agents/skills/design/impeccable/scripts/lib/design-parser.mjs +0 -842
  200. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-config.mjs +0 -658
  201. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-paths.mjs +0 -137
  202. package/template/.agents/skills/design/impeccable/scripts/lib/is-generated.mjs +0 -69
  203. package/template/.agents/skills/design/impeccable/scripts/lib/provider.mjs +0 -5
  204. package/template/.agents/skills/design/impeccable/scripts/lib/roll-selection.mjs +0 -362
  205. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-deep.mjs +0 -457
  206. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-notice.mjs +0 -169
  207. package/template/.agents/skills/design/impeccable/scripts/lib/staleness.mjs +0 -457
  208. package/template/.agents/skills/design/impeccable/scripts/lib/surface-briefs.mjs +0 -151
  209. package/template/.agents/skills/design/impeccable/scripts/lib/target-args.mjs +0 -42
  210. package/template/.agents/skills/design/impeccable/scripts/lib/target-slug.mjs +0 -33
  211. package/template/.agents/skills/design/impeccable/scripts/lib/template-extensions.mjs +0 -146
  212. package/template/.agents/skills/design/impeccable/scripts/live/accept-css.mjs +0 -617
  213. package/template/.agents/skills/design/impeccable/scripts/live/accept-verify.mjs +0 -60
  214. package/template/.agents/skills/design/impeccable/scripts/live/browser-script-parts.mjs +0 -55
  215. package/template/.agents/skills/design/impeccable/scripts/live/completion.mjs +0 -28
  216. package/template/.agents/skills/design/impeccable/scripts/live/event-validation.mjs +0 -199
  217. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/astro.mjs +0 -47
  218. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/detect-utils.mjs +0 -73
  219. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/index.mjs +0 -143
  220. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/journal.mjs +0 -197
  221. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/nextjs.mjs +0 -49
  222. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/nuxt.mjs +0 -161
  223. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/script-src.mjs +0 -17
  224. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/static-html.mjs +0 -26
  225. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/sveltekit.mjs +0 -71
  226. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/tag-strategy.mjs +0 -247
  227. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/tanstack-start.mjs +0 -70
  228. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/vite-generic.mjs +0 -42
  229. package/template/.agents/skills/design/impeccable/scripts/live/generation-preflight.mjs +0 -149
  230. package/template/.agents/skills/design/impeccable/scripts/live/insert-ui.mjs +0 -458
  231. package/template/.agents/skills/design/impeccable/scripts/live/instructions.mjs +0 -142
  232. package/template/.agents/skills/design/impeccable/scripts/live/manual-apply.mjs +0 -939
  233. package/template/.agents/skills/design/impeccable/scripts/live/manual-edit-routes.mjs +0 -357
  234. package/template/.agents/skills/design/impeccable/scripts/live/manual-edits-buffer.mjs +0 -152
  235. package/template/.agents/skills/design/impeccable/scripts/live/poll-lanes.mjs +0 -14
  236. package/template/.agents/skills/design/impeccable/scripts/live/roots.mjs +0 -508
  237. package/template/.agents/skills/design/impeccable/scripts/live/session-store.mjs +0 -563
  238. package/template/.agents/skills/design/impeccable/scripts/live/source-lock.mjs +0 -105
  239. package/template/.agents/skills/design/impeccable/scripts/live/source-search.mjs +0 -105
  240. package/template/.agents/skills/design/impeccable/scripts/live/svelte-ast.mjs +0 -961
  241. package/template/.agents/skills/design/impeccable/scripts/live/svelte-component.mjs +0 -1342
  242. package/template/.agents/skills/design/impeccable/scripts/live/sveltekit-adapter.mjs +0 -316
  243. package/template/.agents/skills/design/impeccable/scripts/live/tanstack-adapter.mjs +0 -280
  244. package/template/.agents/skills/design/impeccable/scripts/live/ui-core.mjs +0 -180
  245. package/template/.agents/skills/design/impeccable/scripts/live/vocabulary.mjs +0 -171
  246. package/template/.agents/skills/design/impeccable/scripts/live-accept.mjs +0 -954
  247. package/template/.agents/skills/design/impeccable/scripts/live-browser-dom.js +0 -146
  248. package/template/.agents/skills/design/impeccable/scripts/live-browser-session.js +0 -123
  249. package/template/.agents/skills/design/impeccable/scripts/live-browser.js +0 -12512
  250. package/template/.agents/skills/design/impeccable/scripts/live-commit-manual-edits.mjs +0 -1244
  251. package/template/.agents/skills/design/impeccable/scripts/live-complete.mjs +0 -107
  252. package/template/.agents/skills/design/impeccable/scripts/live-copy-edit-agent.mjs +0 -683
  253. package/template/.agents/skills/design/impeccable/scripts/live-discard-manual-edits.mjs +0 -51
  254. package/template/.agents/skills/design/impeccable/scripts/live-inject.mjs +0 -503
  255. package/template/.agents/skills/design/impeccable/scripts/live-insert.mjs +0 -292
  256. package/template/.agents/skills/design/impeccable/scripts/live-manual-edit-evidence.mjs +0 -368
  257. package/template/.agents/skills/design/impeccable/scripts/live-poll.mjs +0 -429
  258. package/template/.agents/skills/design/impeccable/scripts/live-resume.mjs +0 -123
  259. package/template/.agents/skills/design/impeccable/scripts/live-server.mjs +0 -1661
  260. package/template/.agents/skills/design/impeccable/scripts/live-status.mjs +0 -71
  261. package/template/.agents/skills/design/impeccable/scripts/live-target.mjs +0 -30
  262. package/template/.agents/skills/design/impeccable/scripts/live-wrap.mjs +0 -927
  263. package/template/.agents/skills/design/impeccable/scripts/live.mjs +0 -359
  264. package/template/.agents/skills/design/impeccable/scripts/modern-screenshot.umd.js +0 -14
  265. package/template/.agents/skills/design/impeccable/scripts/palette.mjs +0 -628
  266. package/template/.agents/skills/design/impeccable/scripts/pin.mjs +0 -221
  267. package/template/.agents/skills/design/impeccable/scripts/serve-question.mjs +0 -932
  268. package/template/.agents/skills/design/impeccable/scripts/surface-brief.mjs +0 -74
  269. package/template/.agents/skills/design/shadcn/SKILL.md +0 -277
  270. package/template/.agents/skills/design/shadcn/agents/openai.yml +0 -5
  271. package/template/.agents/skills/design/shadcn/assets/shadcn-small.png +0 -0
  272. package/template/.agents/skills/design/shadcn/assets/shadcn.png +0 -0
  273. package/template/.agents/skills/design/shadcn/cli.md +0 -290
  274. package/template/.agents/skills/design/shadcn/customization.md +0 -209
  275. package/template/.agents/skills/design/shadcn/evals/evals.json +0 -77
  276. package/template/.agents/skills/design/shadcn/mcp.md +0 -105
  277. package/template/.agents/skills/design/shadcn/registry.md +0 -277
  278. package/template/.agents/skills/design/shadcn/rules/base-vs-radix.md +0 -306
  279. package/template/.agents/skills/design/shadcn/rules/chat.md +0 -224
  280. package/template/.agents/skills/design/shadcn/rules/composition.md +0 -213
  281. package/template/.agents/skills/design/shadcn/rules/forms.md +0 -192
  282. package/template/.agents/skills/design/shadcn/rules/icons.md +0 -101
  283. package/template/.agents/skills/design/shadcn/rules/styling.md +0 -185
  284. package/template/.agents/skills/find-skills/SKILL.md +0 -141
  285. package/template/.agents/skills/hono/SKILL.md +0 -579
  286. package/template/.agents/skills/integrations/langfuse/SKILL.md +0 -146
  287. package/template/.agents/skills/integrations/langfuse/references/ci-cd.md +0 -41
  288. package/template/.agents/skills/integrations/langfuse/references/cli.md +0 -60
  289. package/template/.agents/skills/integrations/langfuse/references/error-analysis.md +0 -88
  290. package/template/.agents/skills/integrations/langfuse/references/instrumentation.md +0 -121
  291. package/template/.agents/skills/integrations/langfuse/references/judge-calibration.md +0 -291
  292. package/template/.agents/skills/integrations/langfuse/references/prompt-engineering.md +0 -35
  293. package/template/.agents/skills/integrations/langfuse/references/prompt-migration.md +0 -79
  294. package/template/.agents/skills/integrations/langfuse/references/skill-feedback.md +0 -55
  295. package/template/.agents/skills/integrations/langfuse/references/trace-evaluator-upgrade.md +0 -76
  296. package/template/.agents/skills/integrations/langfuse/references/user-feedback.md +0 -92
  297. package/template/.agents/skills/integrations/langfuse/references/v4-project-migration.md +0 -73
  298. package/template/.agents/skills/integrations/posthog/SKILL.md +0 -102
  299. package/template/.agents/skills/integrations/posthog/references/error-tracking-alerts.md +0 -63
  300. package/template/.agents/skills/integrations/posthog/references/error-tracking-assigning-issues.md +0 -77
  301. package/template/.agents/skills/integrations/posthog/references/error-tracking-fingerprints.md +0 -57
  302. package/template/.agents/skills/integrations/posthog/references/error-tracking-monitoring.md +0 -140
  303. package/template/.agents/skills/integrations/posthog/references/error-tracking-nextjs.md +0 -490
  304. package/template/.agents/skills/integrations/posthog/references/error-tracking-source-maps.md +0 -45
  305. package/template/.agents/skills/integrations/posthog/references/feature-flags-best-practices.md +0 -139
  306. package/template/.agents/skills/integrations/posthog/references/feature-flags-react.md +0 -302
  307. package/template/.agents/skills/integrations/posthog/references/identify-users.md +0 -202
  308. package/template/.agents/skills/integrations/posthog/references/integration-example.md +0 -706
  309. package/template/.agents/skills/integrations/posthog/references/integration-nextjs.md +0 -385
  310. package/template/.agents/skills/integrations/posthog/references/integration-step-1-begin.md +0 -43
  311. package/template/.agents/skills/integrations/posthog/references/integration-step-2-edit.md +0 -37
  312. package/template/.agents/skills/integrations/posthog/references/integration-step-3-revise.md +0 -22
  313. package/template/.agents/skills/integrations/posthog/references/integration-step-4-conclude.md +0 -38
  314. package/template/.agents/skills/integrations/posthog/references/llm-analytics-anthropic.md +0 -200
  315. package/template/.agents/skills/integrations/posthog/references/llm-analytics-basics.md +0 -62
  316. package/template/.agents/skills/integrations/posthog/references/llm-analytics-costs.md +0 -197
  317. package/template/.agents/skills/integrations/posthog/references/llm-analytics-manual-capture.md +0 -397
  318. package/template/.agents/skills/integrations/posthog/references/llm-analytics-traces.md +0 -98
  319. package/template/.agents/skills/integrations/posthog/references/llm-analytics-vercel-ai.md +0 -120
  320. package/template/.agents/skills/repo/repo-ci/SKILL.md +0 -265
  321. package/template/.agents/skills/repo/repo-init-next-js/SKILL.md +0 -129
  322. package/template/.agents/skills/repo/repo-init-next-js/references/file-contents.md +0 -800
  323. package/template/.agents/skills/repo/repo-init-next-js/scripts/setup.sh +0 -47
  324. package/template/.agents/skills/repo/repo-init-node/SKILL.md +0 -196
  325. package/template/.agents/skills/skill-creator/LICENSE.txt +0 -202
  326. package/template/.agents/skills/skill-creator/SKILL.md +0 -485
  327. package/template/.agents/skills/skill-creator/agents/analyzer.md +0 -274
  328. package/template/.agents/skills/skill-creator/agents/comparator.md +0 -202
  329. package/template/.agents/skills/skill-creator/agents/grader.md +0 -223
  330. package/template/.agents/skills/skill-creator/assets/eval_review.html +0 -146
  331. package/template/.agents/skills/skill-creator/eval-viewer/generate_review.py +0 -471
  332. package/template/.agents/skills/skill-creator/eval-viewer/viewer.html +0 -1325
  333. package/template/.agents/skills/skill-creator/references/schemas.md +0 -430
  334. package/template/.agents/skills/skill-creator/scripts/__init__.py +0 -0
  335. package/template/.agents/skills/skill-creator/scripts/aggregate_benchmark.py +0 -401
  336. package/template/.agents/skills/skill-creator/scripts/generate_report.py +0 -326
  337. package/template/.agents/skills/skill-creator/scripts/improve_description.py +0 -247
  338. package/template/.agents/skills/skill-creator/scripts/package_skill.py +0 -136
  339. package/template/.agents/skills/skill-creator/scripts/quick_validate.py +0 -103
  340. package/template/.agents/skills/skill-creator/scripts/run_eval.py +0 -310
  341. package/template/.agents/skills/skill-creator/scripts/run_loop.py +0 -328
  342. package/template/.agents/skills/skill-creator/scripts/utils.py +0 -47
  343. package/template/.agents/skills/supabase/CHANGELOG.md +0 -71
  344. package/template/.agents/skills/supabase/SKILL.md +0 -145
  345. package/template/.agents/skills/supabase/assets/feedback-issue-template.md +0 -17
  346. package/template/.agents/skills/supabase/references/skill-feedback.md +0 -17
  347. package/template/.agents/skills/supabase-postgres-best-practices/CHANGELOG.md +0 -73
  348. package/template/.agents/skills/supabase-postgres-best-practices/SKILL.md +0 -64
  349. package/template/.agents/skills/supabase-postgres-best-practices/references/_contributing.md +0 -170
  350. package/template/.agents/skills/supabase-postgres-best-practices/references/_sections.md +0 -39
  351. package/template/.agents/skills/supabase-postgres-best-practices/references/_template.md +0 -34
  352. package/template/.agents/skills/supabase-postgres-best-practices/references/advanced-full-text-search.md +0 -55
  353. package/template/.agents/skills/supabase-postgres-best-practices/references/advanced-jsonb-indexing.md +0 -49
  354. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-idle-timeout.md +0 -46
  355. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-limits.md +0 -44
  356. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-pooling.md +0 -41
  357. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-prepared-statements.md +0 -46
  358. package/template/.agents/skills/supabase-postgres-best-practices/references/data-batch-inserts.md +0 -54
  359. package/template/.agents/skills/supabase-postgres-best-practices/references/data-n-plus-one.md +0 -53
  360. package/template/.agents/skills/supabase-postgres-best-practices/references/data-pagination.md +0 -50
  361. package/template/.agents/skills/supabase-postgres-best-practices/references/data-upsert.md +0 -50
  362. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-advisory.md +0 -56
  363. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-deadlock-prevention.md +0 -68
  364. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-short-transactions.md +0 -50
  365. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-skip-locked.md +0 -54
  366. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-explain-analyze.md +0 -45
  367. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-pg-stat-statements.md +0 -55
  368. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-vacuum-analyze.md +0 -55
  369. package/template/.agents/skills/supabase-postgres-best-practices/references/query-composite-indexes.md +0 -44
  370. package/template/.agents/skills/supabase-postgres-best-practices/references/query-covering-indexes.md +0 -40
  371. package/template/.agents/skills/supabase-postgres-best-practices/references/query-index-types.md +0 -48
  372. package/template/.agents/skills/supabase-postgres-best-practices/references/query-missing-indexes.md +0 -43
  373. package/template/.agents/skills/supabase-postgres-best-practices/references/query-partial-indexes.md +0 -45
  374. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-constraints.md +0 -80
  375. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-data-types.md +0 -46
  376. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-foreign-key-indexes.md +0 -59
  377. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-lowercase-identifiers.md +0 -55
  378. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-partitioning.md +0 -55
  379. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-primary-keys.md +0 -61
  380. package/template/.agents/skills/supabase-postgres-best-practices/references/security-privileges.md +0 -54
  381. package/template/.agents/skills/supabase-postgres-best-practices/references/security-rls-basics.md +0 -50
  382. package/template/.agents/skills/supabase-postgres-best-practices/references/security-rls-performance.md +0 -63
  383. package/template/.agents/skills/writing/humanizer/SKILL.md +0 -621
  384. package/template/.agents/upstreams.json +0 -116
@@ -1,2100 +0,0 @@
1
- /**
2
- * Shared library for the Impeccable design hook.
3
- *
4
- * Pure-ish helpers split out from `hook.mjs` so unit tests can exercise
5
- * config parsing, finding filtering, dedup, render, and cache logic without
6
- * spawning a subprocess. `hook.mjs` itself is the thin stdin/stdout shim.
7
- *
8
- * Public surface (everything exported is part of the contract):
9
- * ENVELOPE_PREFIX, ALLOWED_EXTS, ACK_EXTS, SENSITIVE_PATH, GENERATED_PATH, TRUTHY
10
- * truthy(value)
11
- * readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd)
12
- * resolveProjectPlatform(cwd) / isNativePlatform(platform)
13
- * normalizeIgnoreValue(value)
14
- * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd)
15
- * bumpEditCount(cache, sessionId, filePath) -> number
16
- * touchFile(cache, sessionId, filePath)
17
- * suppressionNotice(filePath)
18
- * filterFindings(findings, content, ext, config)
19
- * ADVISORY_RULES / isAdvisoryFinding(finding)
20
- * IMMEDIATE_TIER_RULES / splitFindingsByTier(findings) / perEditTieringActive(config, harness)
21
- * matchConfiguredExtension(filePath, extensions)
22
- * dedupeAgainstCache(findings, cache, sessionId, filePath)
23
- * renderTemplate(findings, filePath, config, opts)
24
- * renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts)
25
- * shouldEmitAckForFile(filePath, config?)
26
- * writeAuditLog(env, entry)
27
- * loadDetector() -> Promise<{ detectText, detectHtml }>
28
- * matchesAnyGlob(filePath, globs)
29
- * normalizeScanTargets(primaryTargets, projectCwd)
30
- * runHook(deps) -> { exitCode, stdout, audit, reason? }
31
- * runStopHook(deps) -> { exitCode, stdout, audit, emission? }
32
- *
33
- * Design notes:
34
- * - All errors are swallowed at the runHook seam. The detector throwing must
35
- * never break a turn. See PRD §5 "Failure modes".
36
- * - Cache shape is JSON-friendly; we gc the oldest sessions when there are
37
- * more than 8 to keep file size predictable across long-lived projects.
38
- * - The detector loader looks for `detector/detect-antipatterns.mjs` next to
39
- * this file first (built skill layout) and falls back to the repo root's
40
- * `cli/engine/detect-antipatterns.mjs` (running from source).
41
- */
42
-
43
- import fs from 'node:fs';
44
- import os from 'node:os';
45
- import path from 'node:path';
46
- import { pathToFileURL, fileURLToPath } from 'node:url';
47
- import { extractPlatform, loadContext } from './context.mjs';
48
- import { IMPECCABLE_COMMAND } from './lib/provider.mjs';
49
- // `detector.extensions` (issue #316) is shared with Live's source search, which
50
- // needs the same answer for `.heex` / `.blade.php` when it hunts for session
51
- // markers. lib/template-extensions.mjs owns the shape; re-exported here because
52
- // hook-lib has been the import site for matchConfiguredExtension since #347.
53
- import {
54
- matchConfiguredExtension,
55
- mergeExtensions,
56
- } from './lib/template-extensions.mjs';
57
-
58
- export { matchConfiguredExtension };
59
-
60
- const __filename = fileURLToPath(import.meta.url);
61
- const __dirname = path.dirname(__filename);
62
-
63
- export const ENVELOPE_PREFIX = '[impeccable@1]';
64
-
65
- export const ALLOWED_EXTS = new Set([
66
- '.tsx', '.jsx', '.html', '.htm', '.vue', '.svelte', '.astro',
67
- '.css', '.scss', '.sass', '.less', '.ts', '.js',
68
- ]);
69
-
70
- export const ACK_EXTS = new Set([
71
- '.tsx', '.jsx', '.html', '.htm', '.vue', '.svelte', '.astro',
72
- '.css', '.scss', '.sass', '.less',
73
- ]);
74
-
75
- // Hard-skip regex for sensitive files. Cannot be turned off via config.
76
- // Match tokenized secret/credential filenames, not UI names such as
77
- // CredentialForm.tsx, SecretPage.jsx, or secretary-dashboard.vue.
78
- export const SENSITIVE_PATH = new RegExp([
79
- String.raw`(?:^|[/\\])\.env(?:\.|$)`,
80
- String.raw`(?:^|[/\\])\.git(?:[/\\]|$)`,
81
- String.raw`(?:^|[/\\])id_rsa(?:$|[._-])[^/\\]*$`,
82
- String.raw`(?:^|[/\\])[^/\\]*\.pem$`,
83
- String.raw`(?:^|[/\\])(?:[^/\\]*[._-])?(?:secret|secrets|credential|credentials)(?=[._-])[^/\\]*\.(?:json|ya?ml|toml|ini|conf|config|env|txt|key|cert|crt|pem|js|ts)$`,
84
- ].join('|'), 'i');
85
-
86
- // Hard-skip regex for generated, lock, minified, and build-output paths.
87
- // `generated` is matched as a whole path segment so authored names such as
88
- // `generated-utils.ts` or `CodeGenerator.tsx` still get scanned.
89
- export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\]generated[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i;
90
-
91
- export const TRUTHY = /^(1|true|yes|on)$/i;
92
-
93
- // ── Two-tier rule surfacing ──────────────────────────────────────────────
94
- // The per-edit PostToolUse pass surfaces only this "immediate" tier: rules
95
- // that are mechanical, unambiguous, and worth interrupting an edit for —
96
- // broken output the user would see (broken images, overflow, clipped
97
- // popovers, text on the viewport edge), objective contrast/legibility
98
- // failures, single-property slop that is trivial to fix in place (gradient
99
- // text, glow shadows), and design-system drift (which compounds with every
100
- // further edit if left uncorrected). Everything else — copy-cadence rules,
101
- // palette/typography taste, layout rhythm — is deferred to the Stop-event
102
- // deep pass (`runStopHook`), which runs the FULL rule set over every file
103
- // touched this session and surfaces the remainder once.
104
- //
105
- // Rationale (measured in the eval harness): the per-edit stream fires
106
- // overwhelmingly on copy-level rules, and that steady nag stream makes
107
- // models more conservative, while a single full pass at completion fixes
108
- // contrast/padding/glow just as reliably. Restore the old full per-edit
109
- // behavior with `.impeccable/config.json` → `hook: { "perEditRules": "all" }`.
110
- export const IMMEDIATE_TIER_RULES = new Set([
111
- // Broken output.
112
- 'broken-image',
113
- 'text-overflow',
114
- 'clipped-overflow-container',
115
- 'body-text-viewport-edge',
116
- // Objective contrast / legibility failures.
117
- 'low-contrast',
118
- 'gray-on-color',
119
- 'tiny-text',
120
- // Single-property mechanical slop, trivial to fix at the edit site.
121
- 'gradient-text',
122
- 'dark-glow',
123
- // Design-system drift compounds if not corrected at edit time.
124
- 'design-system-font',
125
- 'design-system-color',
126
- 'design-system-radius',
127
- 'design-system-font-size',
128
- ]);
129
-
130
- // ── Advisory rules ────────────────────────────────────────────────────────
131
- // Advisory rules are opt-in noise: the CLI reports them in a separate section
132
- // and they never count as failures. The design hook skips them entirely by
133
- // default — in both the per-edit PostToolUse pass and the Stop deep pass — so
134
- // the agent is never nagged about a taste call a human might make on purpose.
135
- // A project opts back in with `.impeccable/config.json`:
136
- // { "detector": { "advisoryRules": "include" } }
137
- // This set is the hook's own copy of the registry's `advisory: true` rules,
138
- // mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
139
- // self-contained and testable without loading the detector. Keep it in sync
140
- // with the registry (cli/engine/registry/antipatterns.mjs).
141
- export const ADVISORY_RULES = new Set([
142
- 'em-dash-overuse',
143
- ]);
144
-
145
- export function isAdvisoryFinding(finding) {
146
- const id = finding && normalizeIgnoreRule(finding.antipattern);
147
- return Boolean(id && (ADVISORY_RULES.has(id) || finding.advisory === true));
148
- }
149
-
150
- export const DEFAULT_CONFIG = Object.freeze({
151
- enabled: true,
152
- quiet: false,
153
- auditLog: null,
154
- designSystem: { enabled: true },
155
- ignoreRules: [],
156
- ignoreFiles: [],
157
- ignoreValues: [],
158
- extensions: [],
159
- perEditRules: 'immediate',
160
- // Advisory rules are skipped unless a project sets detector.advisoryRules to
161
- // "include". See ADVISORY_RULES above.
162
- advisoryRules: 'exclude',
163
- // maxFileBytes: not every generated artifact lives under a path we can
164
- // recognize. Committed browser bundles and vendored detector copies sit
165
- // next to source and run 200KB+, while genuinely authored stylesheets in
166
- // this codebase top out under 90KB. A single file past the ceiling is a
167
- // bundle, and findings against a bundle are never actionable.
168
- limits: { maxFindings: 5, maxChars: 8000, maxFileBytes: 131072 },
169
- });
170
-
171
- export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([
172
- '.impeccable/hook.cache.json',
173
- '.impeccable/hook.pending.json',
174
- '.impeccable/config.local.json',
175
- ]);
176
-
177
- const HOOK_IGNORE_MARKER_OPEN = '# impeccable-hook-ignore-start';
178
- const HOOK_IGNORE_MARKER_CLOSE = '# impeccable-hook-ignore-end';
179
- const CACHE_MAX_SESSIONS = 8;
180
- export const EDIT_COUNT_THRESHOLD = 6;
181
-
182
- export function truthy(value) {
183
- return typeof value === 'string' && TRUTHY.test(value);
184
- }
185
-
186
- function depthIsSet(value) {
187
- if (value === undefined || value === null) return false;
188
- const text = String(value).trim();
189
- if (!text) return false;
190
- if (TRUTHY.test(text)) return true;
191
- return /^\d+$/.test(text) && Number(text) > 0;
192
- }
193
-
194
- function safeReadJson(filePath) {
195
- try {
196
- return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
197
- } catch {
198
- return null;
199
- }
200
- }
201
-
202
- export function getConfigPath(cwd) {
203
- return path.join(cwd, '.impeccable', 'config.json');
204
- }
205
-
206
- export function getLocalConfigPath(cwd) {
207
- return path.join(cwd, '.impeccable', 'config.local.json');
208
- }
209
-
210
- export function getCachePath(cwd) {
211
- return path.join(cwd, '.impeccable', 'hook.cache.json');
212
- }
213
-
214
- export function getPendingPath(cwd) {
215
- return path.join(cwd, '.impeccable', 'hook.pending.json');
216
- }
217
-
218
- export function resolveProjectCwd(event, fallback = process.cwd()) {
219
- return event?.cwd
220
- || (Array.isArray(event?.workspace_roots) && event.workspace_roots[0])
221
- || envProjectDir(fallback)
222
- || fallback;
223
- }
224
-
225
- function looksLikeProjectRoot(dir) {
226
- return ['.git', 'package.json', '.impeccable'].some((marker) => {
227
- try { return fs.existsSync(path.join(dir, marker)); } catch { return false; }
228
- });
229
- }
230
-
231
- // Where `.impeccable/` (cache + config) lives for this event. Normally the
232
- // session cwd, untouched. But when the agent was launched from an umbrella
233
- // directory that is not itself a project (no .git, package.json, or
234
- // .impeccable), key to the edited file's nearest project root instead, so a
235
- // multi-project launch dir doesn't accumulate a shared cross-project cache
236
- // (issue #305). Climbing stops at the home dir, falling back to the session
237
- // cwd when no marker is found.
238
- export function resolveCacheCwd(primaryFile, sessionCwd) {
239
- const base = path.resolve(sessionCwd || process.cwd());
240
- if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base;
241
- if (looksLikeProjectRoot(base)) return base;
242
- let dir;
243
- try {
244
- dir = path.dirname(path.resolve(primaryFile));
245
- } catch {
246
- return base;
247
- }
248
- const home = path.resolve(os.homedir());
249
- while (true) {
250
- if (dir === home) return base;
251
- if (looksLikeProjectRoot(dir)) return dir;
252
- const parent = path.dirname(dir);
253
- if (parent === dir) return base;
254
- dir = parent;
255
- }
256
- }
257
-
258
- // The detector's rules are web rules (HTML/CSS shapes), but a React Native or
259
- // Flutter project is made of the exact extensions the hook watches (.tsx, .ts,
260
- // .js), so without this gate every native screen edit would draw web-shaped
261
- // findings that contradict the native platform references. PRODUCT.md's
262
- // `## Platform` field decides: `ios` / `android` / `adaptive` projects skip
263
- // the scan entirely. Resolution goes through loadContext so the hook reads the
264
- // same PRODUCT.md the skill does (alternate context dirs, monorepo fallback).
265
- export function resolveProjectPlatform(cwd) {
266
- try {
267
- const ctx = loadContext(cwd);
268
- return extractPlatform(ctx && ctx.product);
269
- } catch {
270
- return null;
271
- }
272
- }
273
-
274
- export function isNativePlatform(platform) {
275
- return platform === 'ios' || platform === 'android' || platform === 'adaptive';
276
- }
277
-
278
- export function readConfig(cwd) {
279
- const config = cloneDefaultConfig();
280
- // Hook runtime settings live under `hook`; detector filters live under
281
- // `detector`. Back-compat: older configs stored detector filters in `hook`,
282
- // so read those first and let canonical `detector` settings win.
283
- for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
284
- const raw = safeReadJson(filePath);
285
- applyConfigSource(config, hookSection(raw));
286
- applyDetectorConfigSource(config, detectorSection(raw));
287
- }
288
- return config;
289
- }
290
-
291
- // The hook settings subtree of a unified config.json / config.local.json.
292
- function hookSection(raw) {
293
- if (!raw || typeof raw !== 'object') return null;
294
- return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
295
- }
296
-
297
- function detectorSection(raw) {
298
- if (!raw || typeof raw !== 'object') return null;
299
- return raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
300
- }
301
-
302
- function numberOr(value, fallback) {
303
- return Number.isFinite(value) && value > 0 ? value : fallback;
304
- }
305
-
306
- function cloneDefaultConfig() {
307
- return {
308
- ...DEFAULT_CONFIG,
309
- ignoreRules: [],
310
- ignoreFiles: [],
311
- ignoreValues: [],
312
- extensions: [],
313
- designSystem: { ...DEFAULT_CONFIG.designSystem },
314
- limits: { ...DEFAULT_CONFIG.limits },
315
- };
316
- }
317
-
318
- function applyDetectorConfigSource(config, raw) {
319
- if (!raw || typeof raw !== 'object') return config;
320
- // `detector.advisoryRules: "include"` opts the hook into advisory rules
321
- // (em-dash overuse, etc.). Any other value keeps the default "exclude".
322
- if (raw.advisoryRules === 'include' || raw.advisoryRules === 'exclude') {
323
- config.advisoryRules = raw.advisoryRules;
324
- }
325
- if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
326
- config.designSystem = {
327
- ...config.designSystem,
328
- enabled: raw.designSystem.enabled === false ? false : true,
329
- };
330
- }
331
- if (Array.isArray(raw.ignoreRules)) {
332
- config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
333
- }
334
- if (Array.isArray(raw.ignoreFiles)) {
335
- config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
336
- }
337
- if (Array.isArray(raw.ignoreValues)) {
338
- config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
339
- }
340
- if (Array.isArray(raw.extensions)) {
341
- config.extensions = mergeExtensions(config.extensions, raw.extensions);
342
- }
343
- return config;
344
- }
345
-
346
- function applyConfigSource(config, raw) {
347
- if (!raw || typeof raw !== 'object') return config;
348
- if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
349
- config.enabled = raw.enabled === false ? false : true;
350
- }
351
- if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) {
352
- config.quiet = raw.quiet === true;
353
- }
354
- if (raw.perEditRules === 'all' || raw.perEditRules === 'immediate') {
355
- config.perEditRules = raw.perEditRules;
356
- }
357
- if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
358
- config.auditLog = raw.auditLog.trim();
359
- }
360
- applyDetectorConfigSource(config, raw);
361
- if (raw.limits && typeof raw.limits === 'object') {
362
- config.limits = {
363
- maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings),
364
- maxChars: numberOr(raw.limits.maxChars, config.limits.maxChars),
365
- maxFileBytes: numberOr(raw.limits.maxFileBytes, config.limits.maxFileBytes),
366
- };
367
- }
368
- return config;
369
- }
370
-
371
- function uniqueStrings(values) {
372
- return Array.from(new Set(values.map(String)));
373
- }
374
-
375
- export function normalizeIgnoreValue(value) {
376
- return String(value || '')
377
- .trim()
378
- .replace(/^["']|["']$/g, '')
379
- .replace(/\+/g, ' ')
380
- .replace(/\s+/g, ' ')
381
- .toLowerCase();
382
- }
383
-
384
- function normalizeIgnoreRule(rule) {
385
- return String(rule || '').trim().toLowerCase();
386
- }
387
-
388
- function colorIgnoreKey(value) {
389
- const color = parseIgnoreColor(value);
390
- if (!color) return '';
391
- return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
392
- }
393
-
394
- function parseIgnoreColor(value) {
395
- const text = String(value || '').trim().toLowerCase();
396
- if (!text) return null;
397
-
398
- const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
399
- if (hex) return parseHexIgnoreColor(hex[1]);
400
-
401
- const rgb = text.match(/^rgba?\((.*)\)$/i);
402
- if (rgb) {
403
- const parts = splitColorArgs(rgb[1]);
404
- if (parts.length < 3 || parts.length > 4) return null;
405
- const r = parseRgbChannel(parts[0]);
406
- const g = parseRgbChannel(parts[1]);
407
- const b = parseRgbChannel(parts[2]);
408
- const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
409
- if ([r, g, b, a].some((v) => v === null)) return null;
410
- return { r, g, b, a };
411
- }
412
-
413
- const hsl = text.match(/^hsla?\((.*)\)$/i);
414
- if (hsl) {
415
- const parts = splitColorArgs(hsl[1]);
416
- if (parts.length < 3 || parts.length > 4) return null;
417
- const h = parseHueChannel(parts[0]);
418
- const s = parsePercentChannel(parts[1]);
419
- const l = parsePercentChannel(parts[2]);
420
- const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
421
- if ([h, s, l, a].some((v) => v === null)) return null;
422
- return hslToRgb(h, s, l, a);
423
- }
424
-
425
- return null;
426
- }
427
-
428
- function parseHexIgnoreColor(hex) {
429
- if (hex.length === 3 || hex.length === 4) {
430
- const r = parseInt(hex[0] + hex[0], 16);
431
- const g = parseInt(hex[1] + hex[1], 16);
432
- const b = parseInt(hex[2] + hex[2], 16);
433
- const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
434
- return { r, g, b, a };
435
- }
436
- const r = parseInt(hex.slice(0, 2), 16);
437
- const g = parseInt(hex.slice(2, 4), 16);
438
- const b = parseInt(hex.slice(4, 6), 16);
439
- const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
440
- return { r, g, b, a };
441
- }
442
-
443
- function splitColorArgs(body) {
444
- const text = String(body || '').trim();
445
- if (!text) return [];
446
- if (text.includes(',')) {
447
- const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
448
- const last = parts[parts.length - 1];
449
- if (last && last.includes('/')) {
450
- const split = last.split('/').map((part) => part.trim()).filter(Boolean);
451
- return [...parts.slice(0, -1), ...split];
452
- }
453
- return parts;
454
- }
455
- return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
456
- }
457
-
458
- function parseRgbChannel(raw) {
459
- const text = String(raw || '').trim();
460
- const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
461
- if (!match) return null;
462
- const value = Number.parseFloat(match[1]);
463
- if (!Number.isFinite(value)) return null;
464
- const scaled = match[2] ? value * 2.55 : value;
465
- if (scaled < 0 || scaled > 255) return null;
466
- return Math.round(scaled);
467
- }
468
-
469
- function parseAlphaChannel(raw) {
470
- const text = String(raw || '').trim();
471
- const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
472
- if (!match) return null;
473
- const value = Number.parseFloat(match[1]);
474
- if (!Number.isFinite(value)) return null;
475
- const alpha = match[2] ? value / 100 : value;
476
- return alpha >= 0 && alpha <= 1 ? alpha : null;
477
- }
478
-
479
- function parseHueChannel(raw) {
480
- const text = String(raw || '').trim();
481
- const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
482
- if (!match) return null;
483
- const value = Number.parseFloat(match[1]);
484
- if (!Number.isFinite(value)) return null;
485
- const unit = match[2] || 'deg';
486
- if (unit === 'turn') return value * 360;
487
- if (unit === 'rad') return value * (180 / Math.PI);
488
- if (unit === 'grad') return value * 0.9;
489
- return value;
490
- }
491
-
492
- function parsePercentChannel(raw) {
493
- const text = String(raw || '').trim();
494
- const match = text.match(/^(-?\d*\.?\d+)%$/);
495
- if (!match) return null;
496
- const value = Number.parseFloat(match[1]);
497
- if (!Number.isFinite(value)) return null;
498
- return value >= 0 && value <= 100 ? value / 100 : null;
499
- }
500
-
501
- function hslToRgb(hue, saturation, lightness, alpha) {
502
- const h = (((hue % 360) + 360) % 360) / 360;
503
- if (saturation === 0) {
504
- const gray = clampByte(Math.round(lightness * 255));
505
- return { r: gray, g: gray, b: gray, a: alpha };
506
- }
507
- const q = lightness < 0.5
508
- ? lightness * (1 + saturation)
509
- : lightness + saturation - lightness * saturation;
510
- const p = 2 * lightness - q;
511
- const toRgb = (t) => {
512
- let channel = t;
513
- if (channel < 0) channel += 1;
514
- if (channel > 1) channel -= 1;
515
- if (channel < 1 / 6) return p + (q - p) * 6 * channel;
516
- if (channel < 1 / 2) return q;
517
- if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
518
- return p;
519
- };
520
- return {
521
- r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
522
- g: clampByte(Math.round(toRgb(h) * 255)),
523
- b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
524
- a: alpha,
525
- };
526
- }
527
-
528
- function clampByte(value) {
529
- return Math.min(255, Math.max(0, value));
530
- }
531
-
532
- function ignoreValueMatches(rule, entryValue, findingValue) {
533
- if (entryValue === findingValue) return true;
534
- if (rule !== 'design-system-color') return false;
535
- const entryColor = colorIgnoreKey(entryValue);
536
- return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
537
- }
538
-
539
- export function normalizeIgnoreValueEntries(entries) {
540
- if (!Array.isArray(entries)) return [];
541
- const out = [];
542
- for (const entry of entries) {
543
- if (!entry || typeof entry !== 'object') continue;
544
- const rule = normalizeIgnoreRule(entry.rule);
545
- const value = normalizeIgnoreValue(entry.value);
546
- if (!rule || !value) continue;
547
- const normalized = { rule, value };
548
- const files = uniqueStrings([
549
- ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
550
- ...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
551
- ]);
552
- if (files.length > 0) normalized.files = files;
553
- // Key order is rule, value, files, createdAt, reason and must stay that way:
554
- // normalizing runs on every write, so emitting a different order than the one
555
- // already on disk rewrites every untouched entry and churns the diff.
556
- if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
557
- normalized.createdAt = entry.createdAt.trim();
558
- }
559
- if (typeof entry.reason === 'string' && entry.reason.trim()) {
560
- normalized.reason = entry.reason.trim();
561
- }
562
- out.push(normalized);
563
- }
564
- return out;
565
- }
566
-
567
- function mergeIgnoreValues(existing, incoming) {
568
- const map = new Map();
569
- for (const entry of normalizeIgnoreValueEntries(existing)) {
570
- map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
571
- }
572
- for (const entry of normalizeIgnoreValueEntries(incoming)) {
573
- map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
574
- }
575
- return Array.from(map.values());
576
- }
577
-
578
- function ignoreValueFilesKey(files) {
579
- // Sort before joining: a scope is a set, so an entry already on disk in another
580
- // order must compare equal rather than dedup as two distinct entries.
581
- return Array.isArray(files) && files.length > 0 ? [...files].sort().join('\x1f') : '';
582
- }
583
-
584
- export function readCache(cwd) {
585
- const raw = safeReadJson(getCachePath(cwd));
586
- if (!raw || typeof raw !== 'object' || raw.version !== 1) {
587
- return { version: 1, sessions: {} };
588
- }
589
- return {
590
- version: 1,
591
- sessions: raw.sessions && typeof raw.sessions === 'object' ? raw.sessions : {},
592
- };
593
- }
594
-
595
- export function persistCache(cwd, cache) {
596
- const sessions = cache.sessions || {};
597
- const ids = Object.keys(sessions);
598
- if (ids.length > CACHE_MAX_SESSIONS) {
599
- // Garbage-collect oldest sessions by updatedAt.
600
- const ordered = ids
601
- .map((id) => [id, sessions[id]?.updatedAt || 0])
602
- .sort((a, b) => b[1] - a[1])
603
- .slice(0, CACHE_MAX_SESSIONS);
604
- const next = {};
605
- for (const [id] of ordered) next[id] = sessions[id];
606
- cache = { ...cache, sessions: next };
607
- }
608
- const target = getCachePath(cwd);
609
- try {
610
- ensureHookGitExcludes(cwd);
611
- fs.mkdirSync(path.dirname(target), { recursive: true });
612
- fs.writeFileSync(target, JSON.stringify(cache));
613
- return true;
614
- } catch {
615
- return false;
616
- }
617
- }
618
-
619
- export function ensureHookGitExcludes(cwd = process.cwd()) {
620
- try {
621
- const target = resolveHookGitExcludeTarget(cwd);
622
- if (!target) {
623
- return { mode: 'none', changed: false, patterns: [...HOOK_LOCAL_IGNORE_PATTERNS] };
624
- }
625
-
626
- const patterns = target.patternPrefix
627
- ? HOOK_LOCAL_IGNORE_PATTERNS.map((pattern) => `${target.patternPrefix}/${pattern}`)
628
- : [...HOOK_LOCAL_IGNORE_PATTERNS];
629
- const markerSuffix = target.patternPrefix || '.';
630
- const markerOpen = `${HOOK_IGNORE_MARKER_OPEN} ${markerSuffix}`;
631
- const markerClose = `${HOOK_IGNORE_MARKER_CLOSE} ${markerSuffix}`;
632
- const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
633
- const block = [markerOpen, ...patterns, markerClose].join('\n');
634
- const markerRe = new RegExp(`${escapeRegExp(markerOpen)}[\\s\\S]*?${escapeRegExp(markerClose)}`);
635
-
636
- let updated;
637
- if (markerRe.test(existing)) {
638
- updated = existing.replace(markerRe, block);
639
- } else {
640
- const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
641
- updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`;
642
- }
643
-
644
- if (updated !== existing) {
645
- fs.mkdirSync(path.dirname(target.path), { recursive: true });
646
- fs.writeFileSync(target.path, updated, 'utf-8');
647
- }
648
-
649
- return {
650
- mode: 'git-info-exclude',
651
- file: path.relative(path.resolve(cwd), target.path).split(path.sep).join('/'),
652
- changed: updated !== existing,
653
- patterns,
654
- };
655
- } catch {
656
- return { mode: 'error', changed: false, patterns: [...HOOK_LOCAL_IGNORE_PATTERNS] };
657
- }
658
- }
659
-
660
- function resolveHookGitExcludeTarget(cwd) {
661
- const start = path.resolve(cwd);
662
- let dir = start;
663
- while (true) {
664
- const dotGit = path.join(dir, '.git');
665
- if (fs.existsSync(dotGit)) {
666
- const gitDir = resolveGitDir(dotGit, dir);
667
- if (!gitDir) return null;
668
- const relPrefix = path.relative(dir, start).split(path.sep).join('/');
669
- return {
670
- path: path.join(gitDir, 'info', 'exclude'),
671
- patternPrefix: relPrefix && relPrefix !== '.' ? relPrefix : '',
672
- };
673
- }
674
- const parent = path.dirname(dir);
675
- if (parent === dir) return null;
676
- dir = parent;
677
- }
678
- }
679
-
680
- function resolveGitDir(dotGit, worktreeDir) {
681
- const stat = fs.statSync(dotGit);
682
- if (stat.isDirectory()) return dotGit;
683
- if (!stat.isFile()) return null;
684
-
685
- const body = fs.readFileSync(dotGit, 'utf-8').trim();
686
- const match = body.match(/^gitdir:\s*(.+)$/i);
687
- if (!match) return null;
688
- return path.isAbsolute(match[1]) ? match[1] : path.resolve(worktreeDir, match[1]);
689
- }
690
-
691
- function escapeRegExp(value) {
692
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
693
- }
694
-
695
- function ensureSession(cache, sessionId) {
696
- if (!cache.sessions[sessionId]) {
697
- cache.sessions[sessionId] = { updatedAt: Date.now(), files: {} };
698
- }
699
- return cache.sessions[sessionId];
700
- }
701
-
702
- function ensureFile(cache, sessionId, filePath) {
703
- const session = ensureSession(cache, sessionId);
704
- if (!session.files[filePath]) {
705
- session.files[filePath] = { editCount: 0, findings: [] };
706
- }
707
- return session.files[filePath];
708
- }
709
-
710
- export function bumpEditCount(cache, sessionId, filePath) {
711
- const fileEntry = ensureFile(cache, sessionId, filePath);
712
- fileEntry.editCount = (fileEntry.editCount || 0) + 1;
713
- ensureSession(cache, sessionId).updatedAt = Date.now();
714
- return fileEntry.editCount;
715
- }
716
-
717
- // Record that a file was scanned this session without bumping its edit count.
718
- // The Stop deep pass reads the session's file list to know what to re-scan,
719
- // so a file whose per-edit findings were all deferred still needs an entry.
720
- export function touchFile(cache, sessionId, filePath) {
721
- ensureFile(cache, sessionId, filePath);
722
- ensureSession(cache, sessionId).updatedAt = Date.now();
723
- }
724
-
725
- export function suppressionNotice(filePath) {
726
- return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run ${IMPECCABLE_COMMAND} audit to revisit.`;
727
- }
728
-
729
- // Glob → RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
730
- function globToRegex(glob) {
731
- let re = '^';
732
- let i = 0;
733
- while (i < glob.length) {
734
- const c = glob[i];
735
- if (c === '*') {
736
- if (glob[i + 1] === '*') {
737
- re += '.*';
738
- i += 2;
739
- if (glob[i] === '/') i += 1;
740
- } else {
741
- re += '[^/]*';
742
- i += 1;
743
- }
744
- } else if (c === '?') {
745
- re += '[^/]';
746
- i += 1;
747
- } else if (c === '{') {
748
- const end = glob.indexOf('}', i);
749
- if (end === -1) { re += '\\{'; i += 1; continue; }
750
- const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
751
- re += `(?:${parts.join('|')})`;
752
- i = end + 1;
753
- } else if (/[.+^$()|[\]\\]/.test(c)) {
754
- re += `\\${c}`;
755
- i += 1;
756
- } else {
757
- re += c;
758
- i += 1;
759
- }
760
- }
761
- re += '$';
762
- return new RegExp(re);
763
- }
764
-
765
- export function matchesAnyGlob(filePath, globs) {
766
- if (!Array.isArray(globs) || globs.length === 0) return false;
767
- const normalized = filePath.split(path.sep).join('/');
768
- for (const glob of globs) {
769
- try {
770
- const re = globToRegex(String(glob));
771
- if (re.test(normalized)) return true;
772
- // Match against basename too for convenience: `*.generated.tsx` should
773
- // catch `src/foo.generated.tsx` without requiring `**/`.
774
- const base = normalized.split('/').pop();
775
- if (re.test(base)) return true;
776
- } catch {
777
- /* malformed glob, skip */
778
- }
779
- }
780
- return false;
781
- }
782
-
783
- export function filterFindings(findings, _content, _ext, config) {
784
- if (!Array.isArray(findings) || findings.length === 0) return [];
785
- const ignoreRules = new Set((config.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
786
- const ignoreValues = normalizeIgnoreValueEntries(config.ignoreValues || []);
787
- // Advisory rules are skipped by default so the hook never nags about them;
788
- // a project opts in with detector.advisoryRules: "include".
789
- const includeAdvisory = (config?.advisoryRules || DEFAULT_CONFIG.advisoryRules) === 'include';
790
- return findings.filter((f) => {
791
- if (!f || typeof f !== 'object') return false;
792
- if (!includeAdvisory && isAdvisoryFinding(f)) return false;
793
- if (ignoreRules.has(normalizeIgnoreRule(f.antipattern))) return false;
794
- if (isIgnoredFindingValue(f, ignoreValues)) return false;
795
- return true;
796
- });
797
- }
798
-
799
- // Split filtered findings into the per-edit "immediate" tier and the tier
800
- // deferred to the Stop deep pass. See IMMEDIATE_TIER_RULES for the tiering
801
- // rationale.
802
- export function splitFindingsByTier(findings) {
803
- const immediate = [];
804
- const deferred = [];
805
- for (const f of Array.isArray(findings) ? findings : []) {
806
- if (f && IMMEDIATE_TIER_RULES.has(normalizeIgnoreRule(f.antipattern))) {
807
- immediate.push(f);
808
- } else {
809
- deferred.push(f);
810
- }
811
- }
812
- return { immediate, deferred };
813
- }
814
-
815
- // Whether the per-edit pass for this harness should defer non-immediate
816
- // findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop
817
- // hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for
818
- // them would silently drop the non-immediate rules entirely.
819
- export function perEditTieringActive(config, harness) {
820
- if (harness === 'cursor' || harness === 'github') return false;
821
- return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
822
- }
823
-
824
- function isIgnoredFindingValue(finding, ignoreValues) {
825
- if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
826
- const rule = normalizeIgnoreRule(finding.antipattern);
827
- if (!rule) return false;
828
- // File-scoped wildcards suppress rules with no extractable value, such as side-tab.
829
- const value = extractFindingIgnoreValue(finding);
830
- return ignoreValues.some((entry) => {
831
- if (entry.rule !== rule) return false;
832
- const wildcardValue = entry.value === '*';
833
- if (!wildcardValue && (!value || !ignoreValueMatches(rule, entry.value, value))) return false;
834
- if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
835
- return findingMatchesScopedIgnoreFile(finding, entry.files);
836
- });
837
- }
838
-
839
- function findingMatchesScopedIgnoreFile(finding, globs) {
840
- const filePath = String(finding?.file || '').trim();
841
- if (!filePath) return false;
842
- if (matchesAnyGlob(filePath, globs)) return true;
843
-
844
- const normalized = filePath.split(path.sep).join('/');
845
- const parts = normalized.split('/').filter(Boolean);
846
- for (let i = 0; i < parts.length; i++) {
847
- const suffix = parts.slice(i).join('/');
848
- if (matchesAnyGlob(suffix, globs)) return true;
849
- }
850
- return false;
851
- }
852
-
853
- export function extractFindingIgnoreValue(finding) {
854
- if (!finding || typeof finding !== 'object') return '';
855
- const rule = normalizeIgnoreRule(finding.antipattern);
856
- const directValueRules = new Set([
857
- 'overused-font',
858
- 'bounce-easing',
859
- 'design-system-font',
860
- 'design-system-color',
861
- 'design-system-radius',
862
- 'design-system-font-size',
863
- ]);
864
- if (!directValueRules.has(rule)) return '';
865
- return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
866
- }
867
-
868
- function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
869
- const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
870
- if (direct) return direct;
871
-
872
- const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
873
- for (const text of candidates) {
874
- if (rule === 'bounce-easing') {
875
- const motion = extractMotionIgnoreValue(text);
876
- if (motion) return motion;
877
- continue;
878
- }
879
-
880
- const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
881
- if (primary) return cleanIgnoreValueDisplay(primary[1]);
882
-
883
- const googleLabel = text.match(/Google Fonts:\s*([^()\n;]+)/i);
884
- if (googleLabel) return cleanIgnoreValueDisplay(googleLabel[1]);
885
-
886
- const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
887
- if (family) return cleanIgnoreValueDisplay(family[1]);
888
-
889
- const google = text.match(/[?&]family=([^&:;\n]+)/i);
890
- if (google) {
891
- try {
892
- return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
893
- } catch {
894
- return cleanIgnoreValueDisplay(google[1]);
895
- }
896
- }
897
- }
898
-
899
- return '';
900
- }
901
-
902
- function extractMotionIgnoreValue(text) {
903
- const tailwind = text.match(/\banimate-bounce\b/i);
904
- if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
905
-
906
- const bezier = text.match(/cubic-bezier\([^)]+\)/i);
907
- if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
908
-
909
- const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
910
- if (animation) {
911
- const token = animation[1]
912
- .split(/[,\s]+/)
913
- .find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
914
- if (token) return cleanIgnoreValueDisplay(token);
915
- }
916
-
917
- return '';
918
- }
919
-
920
- function cleanIgnoreValueDisplay(value) {
921
- return String(value || '')
922
- .trim()
923
- .replace(/^["']|["']$/g, '')
924
- .replace(/\+/g, ' ')
925
- .replace(/\s+/g, ' ');
926
- }
927
-
928
- export function dedupeAgainstCache(findings, cache, sessionId, filePath) {
929
- if (!Array.isArray(findings) || findings.length === 0) return [];
930
- const fileEntry = ensureFile(cache, sessionId, filePath);
931
- const known = new Set(fileEntry.findings || []);
932
- const fresh = [];
933
- for (const f of findings) {
934
- const key = findingCacheKey(f);
935
- if (known.has(key)) continue;
936
- known.add(key);
937
- fresh.push(f);
938
- }
939
- return fresh;
940
- }
941
-
942
- // Sync the remembered set to the findings present in the scan just performed.
943
- //
944
- // This replaces rather than accumulates, and that is the whole point. An
945
- // append-only set made the hook lie twice over: the pending ack counted
946
- // history instead of the live scan, so it kept naming findings the agent had
947
- // already fixed, and a finding that was fixed and later reintroduced was
948
- // deduped against a stale memory and never re-reported. Forgetting what is no
949
- // longer there is what lets the count shrink and a regression fire again.
950
- //
951
- // Callers must pass the complete current finding set, not just the fresh ones.
952
- export function rememberFindings(cache, sessionId, filePath, findings) {
953
- const fileEntry = ensureFile(cache, sessionId, filePath);
954
- const keys = new Set((findings || []).map(f => findingCacheKey(f)));
955
- fileEntry.findings = Array.from(keys);
956
- ensureSession(cache, sessionId).updatedAt = Date.now();
957
- }
958
-
959
- function findingCacheKey(finding) {
960
- const line = finding?.line || 0;
961
- const value = extractFindingIgnoreValue(finding);
962
- if (line > 0 && value) return `${finding.antipattern}:${line}:${value}`;
963
- if (line > 0) return `${finding.antipattern}:${line}`;
964
- if (value) return `${finding.antipattern}:0:${value}`;
965
- const snippet = String(finding?.snippet || '').trim().slice(0, 80);
966
- return snippet ? `${finding.antipattern}:0:${snippet}` : `${finding.antipattern}:0`;
967
- }
968
-
969
- export function renderTemplate(findings, filePath, config, opts = {}) {
970
- if (!Array.isArray(findings) || findings.length === 0) return '';
971
- const limits = config?.limits || DEFAULT_CONFIG.limits;
972
- const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
973
- const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
974
-
975
- const cwd = opts.cwd || process.cwd();
976
- const display = relativize(filePath, cwd);
977
- const total = findings.length;
978
- const shown = findings.slice(0, cap);
979
- const remaining = total - shown.length;
980
-
981
- const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
982
- const lines = shown.map((f) => formatFindingLine(f));
983
- const more = remaining > 0
984
- ? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).`
985
- : null;
986
- const footer = directiveFooter(display);
987
-
988
- const blocks = [header, ...lines];
989
- if (more) blocks.push(more);
990
- blocks.push('');
991
- blocks.push(footer);
992
- let text = blocks.join('\n');
993
-
994
- if (text.length > maxChars) {
995
- text = clampToBudget(header, lines, more, footer, maxChars);
996
- }
997
- return text;
998
- }
999
-
1000
- function renderGroupedTemplate(groups, config, opts = {}) {
1001
- const realGroups = groups.filter((group) => Array.isArray(group.findings) && group.findings.length > 0);
1002
- if (realGroups.length === 0) return '';
1003
- if (realGroups.length === 1) {
1004
- const [group] = realGroups;
1005
- return renderTemplate(group.findings, group.filePath, config, opts);
1006
- }
1007
-
1008
- const limits = config?.limits || DEFAULT_CONFIG.limits;
1009
- const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
1010
- const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
1011
- const cwd = opts.cwd || process.cwd();
1012
- const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
1013
- const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
1014
- const lines = [];
1015
- let shownCount = 0;
1016
-
1017
- for (const group of realGroups) {
1018
- const display = relativize(group.filePath, cwd);
1019
- lines.push(`${display} (${group.findings.length} issue(s)):`);
1020
- const remainingCap = Math.max(0, cap - shownCount);
1021
- const shown = group.findings.slice(0, remainingCap);
1022
- for (const finding of shown) {
1023
- lines.push(formatFindingLine(finding));
1024
- }
1025
- shownCount += shown.length;
1026
- const hidden = group.findings.length - shown.length;
1027
- if (hidden > 0) {
1028
- lines.push(`- ... ${hidden} more in ${display} (see ${IMPECCABLE_COMMAND} audit).`);
1029
- }
1030
- }
1031
-
1032
- const footer = directiveFooter('the affected files', { grouped: true });
1033
- let text = [header, ...lines, '', footer].join('\n');
1034
- if (text.length > maxChars) {
1035
- text = clampGroupedToBudget(header, lines, footer, maxChars);
1036
- }
1037
- return text;
1038
- }
1039
-
1040
- function clampGroupedToBudget(header, lines, footer, maxChars) {
1041
- const assemble = (linesArr, omitted) => [
1042
- header,
1043
- ...linesArr,
1044
- ...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []),
1045
- '',
1046
- footer,
1047
- ].join('\n');
1048
-
1049
- let working = lines.slice();
1050
- let omitted = false;
1051
- let assembled = assemble(working, omitted);
1052
- while (assembled.length > maxChars && working.length > 1) {
1053
- working.pop();
1054
- omitted = true;
1055
- assembled = assemble(working, omitted);
1056
- }
1057
- if (assembled.length > maxChars) {
1058
- assembled = `${assembled.slice(0, maxChars - 1)}…`;
1059
- }
1060
- return assembled;
1061
- }
1062
-
1063
- function clampToBudget(header, lines, more, footer, maxChars) {
1064
- const assemble = (linesArr, moreText) => {
1065
- const blocks = [header, ...linesArr];
1066
- if (moreText) blocks.push(moreText);
1067
- blocks.push('');
1068
- blocks.push(footer);
1069
- return blocks.join('\n');
1070
- };
1071
-
1072
- let working = lines.slice();
1073
- let moreText = more;
1074
- let assembled = assemble(working, moreText);
1075
- while (assembled.length > maxChars && working.length > 1) {
1076
- working.pop();
1077
- moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`;
1078
- assembled = assemble(working, moreText);
1079
- }
1080
- if (assembled.length > maxChars) {
1081
- assembled = `${assembled.slice(0, maxChars - 1)}…`;
1082
- }
1083
- return assembled;
1084
- }
1085
-
1086
- function formatFindingLine(f) {
1087
- const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-';
1088
- const desc = (f.description || '').trim();
1089
- const name = (f.name || '').trim();
1090
- // Description from the registry already ends in punctuation; join with a
1091
- // single space. `name` may have a trailing period already, keep it clean.
1092
- const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : '';
1093
- const ignoreCommand = formatFindingIgnoreCommand(f);
1094
- const ignoreSegment = ignoreCommand
1095
- ? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.`
1096
- : '';
1097
- return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim();
1098
- }
1099
-
1100
- function formatFindingIgnoreCommand(finding) {
1101
- if (!finding || typeof finding !== 'object') return '';
1102
- const rule = normalizeIgnoreRule(finding.antipattern);
1103
- if (!rule) return '';
1104
- const normalizedValue = extractFindingIgnoreValue(finding);
1105
- if (!normalizedValue) return '';
1106
- const value = extractFindingIgnoreValueRaw(finding);
1107
- const valueArg = quoteCommandArg(value);
1108
- const reason = quoteCommandArg(`User confirmed ${value} is intentional`);
1109
- return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
1110
- }
1111
-
1112
- function quoteCommandArg(value) {
1113
- const text = String(value || '').trim();
1114
- if (/^[A-Za-z0-9._:-]+$/.test(text)) return text;
1115
- return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
1116
- }
1117
-
1118
- function relativize(filePath, cwd) {
1119
- try {
1120
- const rel = path.relative(cwd, filePath);
1121
- if (!rel || rel.startsWith('..')) return filePath;
1122
- return rel.split(path.sep).join('/');
1123
- } catch {
1124
- return filePath;
1125
- }
1126
- }
1127
-
1128
- // Codex `apply_patch` exposes the raw patch in `tool_input.command`, not
1129
- // `tool_input.file_path`. Claude Code may send both; parse the patch body
1130
- // so we can scan the file(s) the tool actually touched.
1131
- // https://developers.openai.com/codex/hooks#posttooluse
1132
- const APPLY_PATCH_FILE_RE = /^\*\*\* (?:Update|Add) File: (.+)$/gm;
1133
-
1134
- export function parseApplyPatchPaths(command, projectCwd) {
1135
- if (!command || typeof command !== 'string') return [];
1136
- const out = [];
1137
- for (const m of command.matchAll(APPLY_PATCH_FILE_RE)) {
1138
- let p = (m[1] || '').trim();
1139
- if (!p) continue;
1140
- if (!path.isAbsolute(p)) p = path.resolve(projectCwd, p);
1141
- out.push(p);
1142
- }
1143
- return out;
1144
- }
1145
-
1146
- export function resolveTargetFiles(event, projectCwd) {
1147
- const ti = event?.tool_input;
1148
- const out = [];
1149
- const add = (filePath) => {
1150
- if (typeof filePath !== 'string' || !filePath) return;
1151
- if (!out.includes(filePath)) out.push(filePath);
1152
- };
1153
-
1154
- if (event?.tool_name === 'apply_patch' && ti && typeof ti.command === 'string') {
1155
- for (const filePath of parseApplyPatchPaths(ti.command, projectCwd)) add(filePath);
1156
- }
1157
- if (ti && typeof ti.file_path === 'string' && ti.file_path) {
1158
- add(ti.file_path);
1159
- }
1160
- // Cursor Write / StrReplace use `path`, not `file_path`.
1161
- if (ti && typeof ti.path === 'string' && ti.path) {
1162
- add(ti.path);
1163
- }
1164
- if (typeof event?.file_path === 'string' && event.file_path) {
1165
- add(event.file_path);
1166
- }
1167
- return out;
1168
- }
1169
-
1170
- export function resolveHarness(env = {}, event = null) {
1171
- const explicit = env?.IMPECCABLE_HOOK_HARNESS;
1172
- if (explicit === 'cursor') return 'cursor';
1173
- if (explicit === 'github') return 'github';
1174
- if (explicit === 'claude' || explicit === 'codex') return 'claude';
1175
- // GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
1176
- // has no `tool_name`/`tool_input`. That shape is the discriminator.
1177
- if (event && typeof event === 'object'
1178
- && (typeof event.toolName === 'string' || event.toolArgs !== undefined)
1179
- && event.tool_name === undefined && event.tool_input === undefined) {
1180
- return 'github';
1181
- }
1182
- if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
1183
- return 'claude';
1184
- }
1185
-
1186
- // GitHub Copilot's postToolUse payload is
1187
- // { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
1188
- // mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
1189
- // `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
1190
- // JSON *string* (double-encoded) carrying the file under `path`, e.g.
1191
- // "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
1192
- // while `apply_patch` sends a raw OpenAI-format patch string (handled below in
1193
- // normalizeGitHubEvent). The detector reads the file from disk after the tool
1194
- // ran, so only the path (not the proposed content) is needed here.
1195
- export function parseGitHubToolArgs(toolArgs) {
1196
- if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
1197
- if (typeof toolArgs === 'string' && toolArgs.trim()) {
1198
- try {
1199
- const parsed = JSON.parse(toolArgs);
1200
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
1201
- } catch {
1202
- return {};
1203
- }
1204
- }
1205
- return {};
1206
- }
1207
-
1208
- // Copilot's `apply_patch` tool (used by interactive sessions and the cloud
1209
- // agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
1210
- // *** Begin Patch
1211
- // *** Add File: /abs/app.css
1212
- // +body { ... }
1213
- // *** End Patch
1214
- // The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
1215
- // JSON string with the path under `path`. Both must map onto the internal shape.
1216
- const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
1217
-
1218
- function looksLikeApplyPatch(rawArgs) {
1219
- if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
1220
- // Guard against an edit/create payload whose edited *content* happens to
1221
- // contain patch markers: that payload is a JSON object string, whereas a real
1222
- // apply_patch payload is a raw patch string that does not parse as JSON. Only
1223
- // treat non-JSON-object strings as apply_patch so edit events still get their
1224
- // `path` extracted.
1225
- try {
1226
- const parsed = JSON.parse(rawArgs);
1227
- if (parsed && typeof parsed === 'object') return false;
1228
- } catch { /* not JSON → genuine raw patch */ }
1229
- return true;
1230
- }
1231
-
1232
- function applyPatchText(rawArgs) {
1233
- if (typeof rawArgs === 'string') {
1234
- if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
1235
- // Defensive: a future Copilot build might JSON-wrap the patch.
1236
- const parsed = parseGitHubToolArgs(rawArgs);
1237
- return parsed.patch || parsed.input || parsed.command || '';
1238
- }
1239
- if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
1240
- return rawArgs.patch || rawArgs.input || rawArgs.command || '';
1241
- }
1242
- return '';
1243
- }
1244
-
1245
- function normalizeGitHubEvent(event, projectCwd) {
1246
- const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
1247
- const sessionId = event.sessionId || event.session_id || 'unknown';
1248
- const toolName = event.toolName || event.tool_name || null;
1249
- const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
1250
- const rawArgs = event.toolArgs;
1251
-
1252
- let normalizedToolName = toolName;
1253
- if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
1254
- // resolveTargetFiles() reads the touched paths from tool_input.command when
1255
- // tool_name is 'apply_patch', so normalize the name even if a future build
1256
- // sends the patch under a different tool label.
1257
- const patch = applyPatchText(rawArgs);
1258
- if (patch) {
1259
- toolInput.command = patch;
1260
- normalizedToolName = 'apply_patch';
1261
- }
1262
- } else {
1263
- const args = parseGitHubToolArgs(rawArgs);
1264
- const filePath = args.path || args.file_path || args.filePath || args.target_file;
1265
- if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
1266
- }
1267
-
1268
- return {
1269
- ...event,
1270
- cwd,
1271
- session_id: sessionId,
1272
- tool_name: normalizedToolName,
1273
- tool_input: toolInput,
1274
- };
1275
- }
1276
-
1277
- export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
1278
- if (!event || typeof event !== 'object') return event;
1279
- if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
1280
- if (harness !== 'cursor') return event;
1281
-
1282
- const cwd = event.cwd
1283
- || (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
1284
- || envProjectDir(projectCwd)
1285
- || projectCwd;
1286
- const sessionId = event.session_id || event.conversation_id || 'unknown';
1287
-
1288
- const ti = event.tool_input && typeof event.tool_input === 'object' ? event.tool_input : {};
1289
- const filePath = ti.file_path || ti.path || event.file_path;
1290
- if (filePath) {
1291
- return {
1292
- ...event,
1293
- cwd,
1294
- session_id: sessionId,
1295
- tool_input: { ...ti, file_path: filePath },
1296
- };
1297
- }
1298
-
1299
- return { ...event, cwd, session_id: sessionId };
1300
- }
1301
-
1302
- function envProjectDir(fallback) {
1303
- if (typeof process.env.CURSOR_PROJECT_DIR === 'string' && process.env.CURSOR_PROJECT_DIR) {
1304
- return process.env.CURSOR_PROJECT_DIR;
1305
- }
1306
- return fallback;
1307
- }
1308
-
1309
- // UI components often keep slop in a sibling/co-located stylesheet while the
1310
- // JSX edit is what triggered PostToolUse. Scan those styles too so an App.jsx
1311
- // patch doesn't report "clean" while styles.css still has Inter/bounce/etc.
1312
- const UI_CODE_EXTS = new Set(['.jsx', '.tsx', '.vue', '.svelte', '.astro']);
1313
- const STYLE_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
1314
- const CO_SCAN_STYLE_NAMES = [
1315
- 'styles.css', 'styles.scss', 'styles.sass', 'styles.less',
1316
- 'index.css', 'index.scss', 'index.sass', 'index.less',
1317
- 'global.css', 'global.scss', 'global.sass', 'global.less',
1318
- 'globals.css', 'globals.scss', 'globals.sass', 'globals.less',
1319
- ];
1320
- const MAX_SCAN_TARGETS = 6;
1321
-
1322
- const STATIC_STYLE_IMPORT_RE = /import\s+(?:[\w*{}\s,$]+\s+from\s+)?['"]([^'"]+\.(?:css|scss|sass|less))['"]/gi;
1323
-
1324
- function hasPathTraversal(filePath) {
1325
- return typeof filePath === 'string' && filePath.includes('..');
1326
- }
1327
-
1328
- function isInsideProject(filePath, projectCwd) {
1329
- if (!filePath || !projectCwd || hasPathTraversal(filePath)) return false;
1330
- try {
1331
- const rel = path.relative(projectCwd, filePath);
1332
- return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
1333
- } catch {
1334
- return false;
1335
- }
1336
- }
1337
-
1338
- export function parseStaticStyleImports(content, fromFile, projectCwd) {
1339
- if (!content || typeof content !== 'string') return [];
1340
- const dir = path.dirname(fromFile);
1341
- const out = [];
1342
- for (const m of content.matchAll(STATIC_STYLE_IMPORT_RE)) {
1343
- let p = (m[1] || '').trim();
1344
- if (!p) continue;
1345
- if (p.startsWith('.')) p = path.resolve(dir, p);
1346
- else if (!path.isAbsolute(p)) p = path.resolve(projectCwd, p);
1347
- if (!isInsideProject(p, projectCwd)) continue;
1348
- out.push(p);
1349
- }
1350
- return out;
1351
- }
1352
-
1353
- export function coLocatedStylesheets(filePath) {
1354
- const dir = path.dirname(filePath);
1355
- const base = path.basename(filePath, path.extname(filePath));
1356
- const candidates = new Set([
1357
- path.join(dir, `${base}.css`),
1358
- path.join(dir, `${base}.module.css`),
1359
- path.join(dir, `${base}.scss`),
1360
- path.join(dir, `${base}.module.scss`),
1361
- path.join(dir, `${base}.sass`),
1362
- path.join(dir, `${base}.module.sass`),
1363
- path.join(dir, `${base}.less`),
1364
- path.join(dir, `${base}.module.less`),
1365
- ]);
1366
- for (const name of CO_SCAN_STYLE_NAMES) {
1367
- candidates.add(path.join(dir, name));
1368
- }
1369
- return [...candidates].filter((p) => fs.existsSync(p));
1370
- }
1371
-
1372
- export function normalizeScanTargets(primaryTargets, projectCwd) {
1373
- if (!Array.isArray(primaryTargets) || primaryTargets.length === 0) return [];
1374
- const ordered = [];
1375
- const seen = new Set();
1376
- const baseCwd = projectCwd || process.cwd();
1377
- const normalizeTarget = (p) => {
1378
- // Preserve literal `..` segments so downstream sensitive-path checks
1379
- // still fire. path.resolve would collapse `/foo/../etc/passwd`.
1380
- if (hasPathTraversal(p)) return p;
1381
- return path.isAbsolute(p) ? p : path.resolve(baseCwd, p);
1382
- };
1383
- const add = (p) => {
1384
- if (ordered.length >= MAX_SCAN_TARGETS) return;
1385
- const abs = normalizeTarget(p);
1386
- if (seen.has(abs)) return;
1387
- seen.add(abs);
1388
- ordered.push(abs);
1389
- return abs;
1390
- };
1391
-
1392
- for (const p of primaryTargets) add(p);
1393
- return ordered;
1394
- }
1395
-
1396
- export function expandScanTargets(primaryTargets, projectCwd) {
1397
- const ordered = normalizeScanTargets(primaryTargets, projectCwd);
1398
- if (ordered.length === 0) return [];
1399
- const seen = new Set(ordered);
1400
- const baseCwd = projectCwd || process.cwd();
1401
- const add = (p) => {
1402
- if (ordered.length >= MAX_SCAN_TARGETS) return;
1403
- const abs = hasPathTraversal(p) ? p : (path.isAbsolute(p) ? p : path.resolve(baseCwd, p));
1404
- if (seen.has(abs)) return;
1405
- seen.add(abs);
1406
- ordered.push(abs);
1407
- return abs;
1408
- };
1409
-
1410
- const normalizedPrimaries = [];
1411
- for (const p of ordered) normalizedPrimaries.push(p);
1412
-
1413
- for (const p of normalizedPrimaries) {
1414
- if (ordered.length >= MAX_SCAN_TARGETS) break;
1415
- if (!isInsideProject(p, baseCwd)) continue;
1416
- const ext = path.extname(p).toLowerCase();
1417
- if (STYLE_EXTS.has(ext) || !UI_CODE_EXTS.has(ext)) continue;
1418
-
1419
- let content = '';
1420
- try { content = fs.readFileSync(p, 'utf-8'); } catch { /* unreadable primary */ }
1421
-
1422
- for (const imp of parseStaticStyleImports(content, p, projectCwd)) {
1423
- add(imp);
1424
- if (ordered.length >= MAX_SCAN_TARGETS) break;
1425
- }
1426
- for (const col of coLocatedStylesheets(p)) {
1427
- add(col);
1428
- if (ordered.length >= MAX_SCAN_TARGETS) break;
1429
- }
1430
- }
1431
-
1432
- return ordered;
1433
- }
1434
-
1435
- export function writeAuditLog(env, entry, cwd = process.cwd()) {
1436
- // The event's project root (entry.cwd) when present, else the passed cwd. Both
1437
- // config reads and relative log paths resolve against this, since the hook
1438
- // process cwd can differ from the project being edited.
1439
- const baseCwd = entry && typeof entry.cwd === 'string' && entry.cwd ? entry.cwd : cwd;
1440
- // Env wins; otherwise fall back to the unified config's hook.auditLog path.
1441
- let target = env?.IMPECCABLE_HOOK_LOG;
1442
- if (!target || typeof target !== 'string') {
1443
- try { target = readConfig(baseCwd).auditLog; } catch { target = null; }
1444
- }
1445
- if (!target || typeof target !== 'string') return false;
1446
- try {
1447
- let expanded;
1448
- if (target.startsWith('~/')) {
1449
- expanded = path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2));
1450
- } else if (path.isAbsolute(target)) {
1451
- expanded = target;
1452
- } else {
1453
- expanded = path.resolve(baseCwd, target);
1454
- }
1455
- fs.mkdirSync(path.dirname(expanded), { recursive: true });
1456
- const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
1457
- fs.appendFileSync(expanded, line);
1458
- return true;
1459
- } catch {
1460
- return false;
1461
- }
1462
- }
1463
-
1464
- const DETECTOR_CANDIDATES = [
1465
- path.join(__dirname, 'detector', 'detect-antipatterns.mjs'),
1466
- path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
1467
- path.join(__dirname, '..', '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
1468
- ];
1469
-
1470
- let detectorCache = null;
1471
- export async function loadDetector(candidates = DETECTOR_CANDIDATES) {
1472
- if (detectorCache) return detectorCache;
1473
- const found = candidates.find((c) => fs.existsSync(c));
1474
- if (!found) return null;
1475
- const mod = await import(pathToFileURL(found));
1476
- detectorCache = {
1477
- detectText: mod.detectText,
1478
- detectHtml: mod.detectHtml,
1479
- loadDesignSystemForCwd: mod.loadDesignSystemForCwd,
1480
- };
1481
- return detectorCache;
1482
- }
1483
-
1484
- // For tests: allow injecting a detector implementation.
1485
- export function setDetectorForTesting(impl) {
1486
- detectorCache = impl;
1487
- }
1488
-
1489
- // ────────────────────────────────────────────────────────────────────────
1490
- // Nudge/steer messages for the no-silent-fires policy.
1491
- //
1492
- // The hook is designed to be a conversational presence: every fire that
1493
- // actually scans a file emits a developer-role message into the model's
1494
- // next turn. Three states map to three templates:
1495
- //
1496
- // 1. **Fresh findings** → `renderTemplate` (existing, imperative).
1497
- // 2. **Pending findings** → `renderPendingAck` (re-nudge for issues the
1498
- // model was already told about in this
1499
- // session but hasn't fixed yet).
1500
- // 3. **Truly clean** → `renderCleanAck` (short positive nudge that
1501
- // keeps the design discipline in context).
1502
- //
1503
- // All three are short (≤ ~40 tokens each) so the cumulative cost stays
1504
- // bounded across a long active editing session. Users who explicitly want
1505
- // silence-on-clean can set `IMPECCABLE_HOOK_QUIET=1` — runHook checks that
1506
- // env before emitting #2 or #3.
1507
- //
1508
- // Why not stay silent on dedup-clean? Earlier versions did. The model
1509
- // quickly forgets the prior reminder once tool output scrolls past it, so
1510
- // re-nudging on the same file with a short "still pending" line keeps the
1511
- // pressure on. The wording deliberately points back to "earlier this
1512
- // session" so the model knows it's a re-mind, not a new finding.
1513
- // ────────────────────────────────────────────────────────────────────────
1514
-
1515
- const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.';
1516
-
1517
- export function renderCleanAck(filePath, opts = {}) {
1518
- const cwd = opts.cwd || process.cwd();
1519
- const display = relativize(filePath, cwd);
1520
- return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`;
1521
- }
1522
-
1523
- export function renderPendingAck(filePath, knownFindings, opts = {}) {
1524
- const cwd = opts.cwd || process.cwd();
1525
- const display = relativize(filePath, cwd);
1526
- const count = knownFindings.length;
1527
- // `knownFindings` here are the cache strings like "side-tab:3".
1528
- const sample = knownFindings.slice(0, 3).join(', ');
1529
- const more = count > 3 ? `, +${count - 3} more` : '';
1530
- return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`;
1531
- }
1532
-
1533
- export function shouldEmitAckForFile(filePath, config = null) {
1534
- if (ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase())) return true;
1535
- // Configured html-engine extensions are declared UI markup, so they get the
1536
- // clean/pending acks; text-engine ones stay quiet like plain .ts/.js.
1537
- const configured = matchConfiguredExtension(filePath, config?.extensions);
1538
- return Boolean(configured && configured.engine === 'html');
1539
- }
1540
-
1541
- export function designSystemOptions(config, detector, projectCwd) {
1542
- if (config?.designSystem?.enabled === false) return {};
1543
- if (!detector || typeof detector.loadDesignSystemForCwd !== 'function') return {};
1544
- try {
1545
- const designSystem = detector.loadDesignSystemForCwd(projectCwd);
1546
- return designSystem ? { designSystem } : {};
1547
- } catch {
1548
- return {};
1549
- }
1550
- }
1551
-
1552
- export function appendDesignSystemNote(text, scanOptions) {
1553
- if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
1554
- return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`;
1555
- }
1556
-
1557
- // The directive footer is the part of the hook output that steers model
1558
- // behavior. Three intentional moves:
1559
- // 1. **Imperative, not advisory.** "Handle these..." beats "Consider
1560
- // revising..." which the model treats as a soft suggestion it can
1561
- // override when the user asked for any kind of throwaway / demo UI.
1562
- // 2. **Explicit judgment clause.** Without it, the model will try to
1563
- // "fix" intentional motion, bad fixtures, anti-pattern examples in
1564
- // docs, or test cases. Naming the judgment inline beats hoping the
1565
- // model infers it from context.
1566
- // 3. **Acknowledgement instruction.** Hook output is injected as
1567
- // developer-role context, not a chat turn, so the user never sees the
1568
- // raw envelope. Asking the model to surface the resolution in its
1569
- // reply is the cheapest way to make the feedback loop visible.
1570
- function directiveFooter(display, opts = {}) {
1571
- // Offer the rule-scoped-to-file form first. `ignore-file` silences every rule
1572
- // for the path forever, which is far more than one noisy rule on a real UI
1573
- // surface justifies, and it was previously the only option named here.
1574
- const target = opts.grouped ? '<path>' : quoteCommandArg(display);
1575
- const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value <id> "*" --file ${target}\` to scope just that rule to the file, or \`${IMPECCABLE_COMMAND} hooks ignore-file ${target}\` only when the whole file is out of scope for design review (a fixture, a generated artifact, a deliberate demo)`;
1576
- return [
1577
- 'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
1578
- '',
1579
- '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.',
1580
- '',
1581
- `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_COMMAND} 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_COMMAND} hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For a finding whose line shows no exact ignore-value command, such as \`side-tab\`, ${fileIgnoreGuidance}; use \`${IMPECCABLE_COMMAND} hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`,
1582
- ].join('\n');
1583
- }
1584
-
1585
- /**
1586
- * Run the hook with explicit dependencies. Returns a result object:
1587
- * { exitCode, stdout, audit, reason? }
1588
- *
1589
- * Never throws. All errors are converted to `exitCode: 0` + audit entry.
1590
- */
1591
- export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
1592
- const audit = { ts: new Date(now()).toISOString(), event: 'PostToolUse' };
1593
- const result = (extra) => ({ exitCode: 0, stdout: '', audit: { ...audit, ...extra } });
1594
-
1595
- try {
1596
- // Re-entrancy guard.
1597
- if (depthIsSet(env.IMPECCABLE_HOOK_DEPTH) || depthIsSet(env.CLAUDE_HOOK_DEPTH)) {
1598
- return result({ reentrant: true, durationMs: 0 });
1599
- }
1600
-
1601
- if (truthy(env.IMPECCABLE_HOOK_DISABLED)) {
1602
- return result({ skipped: 'env-disabled', durationMs: 0 });
1603
- }
1604
-
1605
- const started = Date.now();
1606
-
1607
- let event;
1608
- try {
1609
- event = typeof stdinJson === 'string' ? JSON.parse(stdinJson) : stdinJson;
1610
- } catch {
1611
- return result({ skipped: 'stdin-malformed', durationMs: Date.now() - started });
1612
- }
1613
- if (!event || typeof event !== 'object') {
1614
- return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
1615
- }
1616
-
1617
- const harness = resolveHarness(env, event);
1618
- event = normalizeHookEvent(event, cwd, harness);
1619
- audit.harness = harness;
1620
-
1621
- const sessionCwd = event.cwd || cwd;
1622
- const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd);
1623
- const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd);
1624
- audit.cwd = projectCwd;
1625
- const primaryFileSet = new Set(primaryFiles);
1626
- const targetFiles = expandScanTargets(primaryFiles, projectCwd);
1627
- audit.session = event.session_id || null;
1628
- if (event.tool_name) audit.tool = event.tool_name;
1629
-
1630
- if (targetFiles.length === 0) {
1631
- return result({ skipped: 'no-file-path', durationMs: Date.now() - started });
1632
- }
1633
-
1634
- const config = readConfig(projectCwd);
1635
- if (config.enabled === false) {
1636
- return result({ skipped: 'config-disabled', durationMs: Date.now() - started });
1637
- }
1638
-
1639
- const platform = resolveProjectPlatform(projectCwd);
1640
- if (isNativePlatform(platform)) {
1641
- return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started });
1642
- }
1643
-
1644
- const cache = readCache(projectCwd);
1645
- const sessionId = event.session_id || 'unknown';
1646
- const det = detector || await loadDetector();
1647
- if (!det || typeof det.detectText !== 'function') {
1648
- // Cache is not mutated yet at this point; nothing to persist.
1649
- return result({ skipped: 'detector-missing', durationMs: Date.now() - started });
1650
- }
1651
- const scanOptions = designSystemOptions(config, det, projectCwd);
1652
- const tiered = perEditTieringActive(config, harness);
1653
-
1654
- let pendingWinner = null;
1655
- let cleanWinner = null;
1656
- const freshGroups = [];
1657
- let suppressionWinner = null;
1658
- let cleanAckDeduped = false;
1659
- let skippedBytes = 0;
1660
- const quietMode = truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true;
1661
- let detectorThrewAny = false;
1662
- let lastSkip = 'no-scannable-file';
1663
- let suppressedHit = false;
1664
- let cacheDirty = false;
1665
- let deferredTotal = 0;
1666
-
1667
- for (const filePath of targetFiles) {
1668
- audit.file = filePath;
1669
-
1670
- if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) {
1671
- lastSkip = 'sensitive';
1672
- continue;
1673
- }
1674
- if (GENERATED_PATH.test(filePath)) {
1675
- lastSkip = 'generated';
1676
- continue;
1677
- }
1678
-
1679
- const ext = path.extname(filePath).toLowerCase();
1680
- const configuredExt = matchConfiguredExtension(filePath, config.extensions);
1681
- audit.ext = configuredExt ? configuredExt.ext : ext;
1682
- if (!ALLOWED_EXTS.has(ext) && !configuredExt) {
1683
- lastSkip = 'extension';
1684
- continue;
1685
- }
1686
-
1687
- const relForMatch = relativize(filePath, projectCwd);
1688
- if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) {
1689
- lastSkip = 'config-ignore-file';
1690
- continue;
1691
- }
1692
- if (!fs.existsSync(filePath)) {
1693
- lastSkip = 'file-missing';
1694
- continue;
1695
- }
1696
-
1697
- const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes;
1698
- if (maxFileBytes > 0) {
1699
- let size = 0;
1700
- try { size = fs.statSync(filePath).size; } catch { size = 0; }
1701
- if (size > maxFileBytes) {
1702
- skippedBytes = size;
1703
- lastSkip = 'too-large';
1704
- continue;
1705
- }
1706
- }
1707
-
1708
- if (primaryFileSet.has(filePath)) {
1709
- const editCount = bumpEditCount(cache, sessionId, filePath);
1710
- cacheDirty = true;
1711
- audit.editCount = editCount;
1712
-
1713
- if (editCount > EDIT_COUNT_THRESHOLD) {
1714
- const wasJustCrossed = editCount === EDIT_COUNT_THRESHOLD + 1;
1715
- if (wasJustCrossed && !suppressionWinner) {
1716
- suppressionWinner = { filePath };
1717
- }
1718
- lastSkip = 'suppressed';
1719
- suppressedHit = true;
1720
- continue;
1721
- }
1722
- }
1723
-
1724
- const content = fs.readFileSync(filePath, 'utf-8');
1725
- let findings;
1726
- let detectorThrew = false;
1727
- const useHtmlEngine = configuredExt
1728
- ? configuredExt.engine === 'html'
1729
- : (ext === '.html' || ext === '.htm');
1730
- if (useHtmlEngine && typeof det.detectHtml === 'function') {
1731
- try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
1732
- } else {
1733
- try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
1734
- }
1735
-
1736
- const filtered = filterFindings(findings || [], content, ext, config);
1737
- // Per-edit only surfaces the immediate tier; the rest waits for the
1738
- // Stop deep pass. The file is still marked touched so the deep pass
1739
- // knows to re-scan it.
1740
- const { immediate, deferred } = tiered
1741
- ? splitFindingsByTier(filtered)
1742
- : { immediate: filtered, deferred: [] };
1743
- if (deferred.length > 0) {
1744
- touchFile(cache, sessionId, filePath);
1745
- cacheDirty = true;
1746
- deferredTotal += deferred.length;
1747
- }
1748
- const fresh = dedupeAgainstCache(immediate, cache, sessionId, filePath);
1749
- audit.findings = (findings || []).length;
1750
- audit.freshFindings = fresh.length;
1751
- if (deferredTotal > 0) audit.deferred = deferredTotal;
1752
-
1753
- // A detector failure tells us nothing about the file, so leave whatever
1754
- // was remembered alone rather than recording an empty scan as truth.
1755
- if (detectorThrew) {
1756
- detectorThrewAny = true;
1757
- continue;
1758
- }
1759
-
1760
- // Sync the cache to this scan before deciding what to emit, so fixed
1761
- // findings stop being remembered and a reintroduced one reads as fresh.
1762
- // Only the immediate tier is remembered: a deferred finding the per-edit
1763
- // pass never reported must still read as fresh to the Stop deep pass.
1764
- rememberFindings(cache, sessionId, filePath, immediate);
1765
- cacheDirty = true;
1766
-
1767
- if (fresh.length > 0) {
1768
- freshGroups.push({ filePath, findings: fresh });
1769
- continue;
1770
- }
1771
-
1772
- if (immediate.length > 0 && !pendingWinner) {
1773
- // Count the live scan, not the session's history.
1774
- pendingWinner = { filePath, known: immediate.map(f => findingCacheKey(f)) };
1775
- } else if (immediate.length === 0 && !cleanWinner) {
1776
- // The clean ack carries no finding, only the standing steer that a
1777
- // silent hook is not a verdict on the design. Repeating it on every
1778
- // clean edit spends context to say nothing, so it fires once per file
1779
- // per session. The pending ack, which names real unresolved work, is
1780
- // deliberately left to repeat.
1781
- //
1782
- // Quiet mode emits nothing, so it must not consume the ack and leave a
1783
- // later non-quiet run in this session silent.
1784
- if (quietMode || !shouldEmitAckForFile(filePath, config)) {
1785
- cleanWinner = { filePath };
1786
- } else if (ensureFile(cache, sessionId, filePath).cleanAcked) {
1787
- // Spent for this file. Remember it for the audit trail, but keep
1788
- // scanning: another target in this same event may still be owed an
1789
- // ack, and dropping out here would lose it.
1790
- cleanAckDeduped = true;
1791
- } else {
1792
- ensureFile(cache, sessionId, filePath).cleanAcked = true;
1793
- cleanWinner = { filePath };
1794
- cleanAckDeduped = false;
1795
- }
1796
- }
1797
- }
1798
-
1799
- // Persist only when the write is earned: fresh findings justify creating
1800
- // `.impeccable/` (dedup and suppression need it), deferred findings do
1801
- // too (the Stop deep pass needs the touched-file list to surface them),
1802
- // and an already-present `.impeccable/` dir marks a project that opted
1803
- // in. A non-UI edit, or a clean UI edit in a project with no Impeccable
1804
- // footprint, must be a no-op on disk (issues #344, #305).
1805
- if (freshGroups.length > 0 || deferredTotal > 0
1806
- || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) {
1807
- persistCache(projectCwd, cache);
1808
- }
1809
-
1810
- if (freshGroups.length > 0) {
1811
- const firstGroup = freshGroups[0];
1812
- const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions);
1813
- const allFindings = freshGroups.flatMap((group) => group.findings);
1814
- return {
1815
- exitCode: 0,
1816
- stdout: payload(text, 'PostToolUse', harness),
1817
- emission: {
1818
- kind: 'fresh',
1819
- file: firstGroup.filePath,
1820
- findings: firstGroup.findings,
1821
- groups: freshGroups,
1822
- },
1823
- audit: {
1824
- ...audit,
1825
- file: firstGroup.filePath,
1826
- emitted: true,
1827
- freshFiles: freshGroups.length,
1828
- freshFindings: allFindings.length,
1829
- chars: text.length,
1830
- durationMs: Date.now() - started,
1831
- },
1832
- };
1833
- }
1834
-
1835
- if (detectorThrewAny && !pendingWinner && !cleanWinner) {
1836
- return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
1837
- }
1838
-
1839
- if (quietMode) {
1840
- return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
1841
- }
1842
-
1843
- if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) {
1844
- const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions);
1845
- return {
1846
- exitCode: 0,
1847
- stdout: payload(text, 'PostToolUse', harness),
1848
- emission: { kind: 'pending', file: pendingWinner.filePath, known: pendingWinner.known },
1849
- audit: {
1850
- ...audit,
1851
- file: pendingWinner.filePath,
1852
- emitted: true,
1853
- kind: 'pending',
1854
- pending: pendingWinner.known.length,
1855
- chars: text.length,
1856
- durationMs: Date.now() - started,
1857
- },
1858
- };
1859
- }
1860
-
1861
- if (suppressionWinner) {
1862
- const text = suppressionNotice(relativize(suppressionWinner.filePath, projectCwd));
1863
- return {
1864
- exitCode: 0,
1865
- stdout: payload(text, 'PostToolUse', harness),
1866
- emission: { kind: 'suppression', file: suppressionWinner.filePath },
1867
- audit: {
1868
- ...audit,
1869
- file: suppressionWinner.filePath,
1870
- suppressed: true,
1871
- emitted: true,
1872
- durationMs: Date.now() - started,
1873
- },
1874
- };
1875
- }
1876
-
1877
- if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) {
1878
- const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions);
1879
- return {
1880
- exitCode: 0,
1881
- stdout: payload(text, 'PostToolUse', harness),
1882
- emission: { kind: 'clean', file: cleanWinner.filePath },
1883
- audit: {
1884
- ...audit,
1885
- file: cleanWinner.filePath,
1886
- emitted: true,
1887
- kind: 'clean',
1888
- chars: text.length,
1889
- durationMs: Date.now() - started,
1890
- },
1891
- };
1892
- }
1893
-
1894
- if (pendingWinner) {
1895
- return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started });
1896
- }
1897
-
1898
- // Distinct from non-ui-ack so the audit log shows noise being suppressed on
1899
- // purpose rather than a file the hook could not classify.
1900
- if (cleanWinner) {
1901
- return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started });
1902
- }
1903
-
1904
- if (cleanAckDeduped) {
1905
- return result({ emitted: false, skipped: 'clean-ack-deduped', durationMs: Date.now() - started });
1906
- }
1907
-
1908
- if (suppressedHit) {
1909
- return result({ suppressed: true, emitted: false, durationMs: Date.now() - started });
1910
- }
1911
-
1912
- return result({
1913
- skipped: lastSkip,
1914
- ...(lastSkip === 'too-large' ? { bytes: skippedBytes } : {}),
1915
- durationMs: Date.now() - started,
1916
- });
1917
- } catch (err) {
1918
- return {
1919
- exitCode: 0,
1920
- stdout: '',
1921
- audit: { ...audit, error: String(err && err.message ? err.message : err) },
1922
- };
1923
- }
1924
- }
1925
-
1926
- // Cap on files the Stop deep pass will scan. The touched-file list is
1927
- // session-scoped and already capped per edit, but a very long session could
1928
- // accumulate more than the 30s hook timeout comfortably covers.
1929
- export const STOP_MAX_FILES = 20;
1930
-
1931
- /**
1932
- * Run the Stop-event deep pass: the FULL detector rule set over every UI
1933
- * file touched this session, surfaced once, deduped against everything the
1934
- * per-edit hook already reported. Same result contract as runHook():
1935
- * { exitCode, stdout, audit, emission? }
1936
- *
1937
- * Never throws; exits silent (and fast) when the session touched no UI
1938
- * files. Output uses the Stop hookSpecificOutput channel: additionalContext
1939
- * is delivered to the model and the conversation continues so it can act.
1940
- */
1941
- export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
1942
- const audit = { ts: new Date(now()).toISOString(), event: 'Stop' };
1943
- const result = (extra) => ({ exitCode: 0, stdout: '', audit: { ...audit, ...extra } });
1944
-
1945
- try {
1946
- // Re-entrancy guard, same as the per-edit pass.
1947
- if (depthIsSet(env.IMPECCABLE_HOOK_DEPTH) || depthIsSet(env.CLAUDE_HOOK_DEPTH)) {
1948
- return result({ reentrant: true, durationMs: 0 });
1949
- }
1950
- if (truthy(env.IMPECCABLE_HOOK_DISABLED)) {
1951
- return result({ skipped: 'env-disabled', durationMs: 0 });
1952
- }
1953
-
1954
- const started = Date.now();
1955
-
1956
- let event;
1957
- try {
1958
- event = typeof stdinJson === 'string' ? JSON.parse(stdinJson) : stdinJson;
1959
- } catch {
1960
- return result({ skipped: 'stdin-malformed', durationMs: Date.now() - started });
1961
- }
1962
- if (!event || typeof event !== 'object') {
1963
- return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
1964
- }
1965
-
1966
- // Claude Code's Stop-hook contract: `stop_hook_active` is true when this
1967
- // hook is being re-invoked only because a prior invocation kept the turn
1968
- // alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
1969
- // re-blocking now would loop until Claude Code's consecutive-block cap
1970
- // force-ends the turn (issue #400). The prior fire already surfaced the
1971
- // findings; whether to act on them is the agent's call. Exit fast with no
1972
- // output before any scan. Only Claude Code sends this field; other
1973
- // harnesses omit it, so the strict `=== true` is a no-op for them. This
1974
- // guard makes the loop impossible regardless of the finding cache key's
1975
- // line-number sensitivity (out of scope here; see findingCacheKey).
1976
- if (event.stop_hook_active === true) {
1977
- return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
1978
- }
1979
-
1980
- const harness = resolveHarness(env, event);
1981
- audit.harness = harness;
1982
-
1983
- // A Stop event carries no file, so the session cwd is the project.
1984
- // Umbrella-dir launches keyed their per-edit cache to the edited file's
1985
- // project root (resolveCacheCwd); those sessions no-op here rather than
1986
- // guessing which child project the session was about.
1987
- const projectCwd = path.resolve(event.cwd || cwd);
1988
- audit.cwd = projectCwd;
1989
- const sessionId = event.session_id || 'unknown';
1990
- audit.session = sessionId;
1991
-
1992
- const config = readConfig(projectCwd);
1993
- if (config.enabled === false) {
1994
- return result({ skipped: 'config-disabled', durationMs: Date.now() - started });
1995
- }
1996
-
1997
- const cache = readCache(projectCwd);
1998
- const touched = Object.keys(cache.sessions?.[sessionId]?.files || {});
1999
- if (touched.length === 0) {
2000
- return result({ skipped: 'no-touched-files', durationMs: Date.now() - started });
2001
- }
2002
-
2003
- const platform = resolveProjectPlatform(projectCwd);
2004
- if (isNativePlatform(platform)) {
2005
- return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started });
2006
- }
2007
-
2008
- const det = detector || await loadDetector();
2009
- if (!det || typeof det.detectText !== 'function') {
2010
- return result({ skipped: 'detector-missing', durationMs: Date.now() - started });
2011
- }
2012
- const scanOptions = designSystemOptions(config, det, projectCwd);
2013
-
2014
- const freshGroups = [];
2015
- let scanned = 0;
2016
- for (const filePath of touched) {
2017
- if (scanned >= STOP_MAX_FILES) break;
2018
- if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
2019
- if (GENERATED_PATH.test(filePath)) continue;
2020
- const ext = path.extname(filePath).toLowerCase();
2021
- const configuredExt = matchConfiguredExtension(filePath, config.extensions);
2022
- if (!ALLOWED_EXTS.has(ext) && !configuredExt) continue;
2023
- const relForMatch = relativize(filePath, projectCwd);
2024
- if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue;
2025
- if (!fs.existsSync(filePath)) continue;
2026
-
2027
- scanned += 1;
2028
- let content = '';
2029
- try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
2030
-
2031
- let findings;
2032
- const useHtmlEngine = configuredExt
2033
- ? configuredExt.engine === 'html'
2034
- : (ext === '.html' || ext === '.htm');
2035
-
2036
- if (useHtmlEngine && typeof det.detectHtml === 'function') {
2037
- try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
2038
- } else {
2039
- try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; }
2040
- }
2041
-
2042
- // Full rule set: no tier split here. Config/inline ignores still apply,
2043
- // and the session dedupe drops everything the per-edit pass (or an
2044
- // earlier Stop pass) already surfaced.
2045
- const filtered = filterFindings(findings || [], content, ext, config);
2046
- const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
2047
- if (fresh.length > 0) {
2048
- rememberFindings(cache, sessionId, filePath, fresh);
2049
- freshGroups.push({ filePath, findings: fresh });
2050
- }
2051
- }
2052
- audit.scannedFiles = scanned;
2053
-
2054
- if (freshGroups.length === 0) {
2055
- return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
2056
- }
2057
-
2058
- // Fresh findings earn the cache write so the next Stop fire is silent
2059
- // unless new issues appear.
2060
- persistCache(projectCwd, cache);
2061
-
2062
- const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions);
2063
- return {
2064
- exitCode: 0,
2065
- stdout: payload(text, 'Stop', harness),
2066
- emission: {
2067
- kind: 'stop-deep-pass',
2068
- groups: freshGroups,
2069
- },
2070
- audit: {
2071
- ...audit,
2072
- emitted: true,
2073
- freshFiles: freshGroups.length,
2074
- freshFindings: freshGroups.reduce((sum, group) => sum + group.findings.length, 0),
2075
- chars: text.length,
2076
- durationMs: Date.now() - started,
2077
- },
2078
- };
2079
- } catch (err) {
2080
- return {
2081
- exitCode: 0,
2082
- stdout: '',
2083
- audit: { ...audit, error: String(err && err.message ? err.message : err) },
2084
- };
2085
- }
2086
- }
2087
-
2088
- export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
2089
- if (harness === 'cursor') {
2090
- return JSON.stringify({ additional_context: text });
2091
- }
2092
- // GitHub Copilot's postToolUse hook injects context via a top-level
2093
- // `additionalContext` string (alongside an optional `modifiedResult`).
2094
- if (harness === 'github') {
2095
- return JSON.stringify({ additionalContext: text });
2096
- }
2097
- return JSON.stringify({
2098
- hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
2099
- });
2100
- }