quiver-cli 0.8.0 → 1.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.
- package/README.md +110 -45
- package/bin/quiver-cli.mjs +3 -1
- package/dist/cli.js +546 -458
- package/package.json +2 -2
- package/template/.agents/AGENTS.md +4 -3
- package/template/.agents/config.json +7 -0
- package/template/.agents/plugins/opencode/rtk.ts +34 -0
- package/template/.agents/skills/agent-browser/SKILL.md +1 -0
- package/template/.agents/skills/apps/skybridge/SKILL.md +4 -0
- package/template/.agents/skills/design/impeccable/SKILL.md +36 -118
- package/template/.agents/skills/design/impeccable/reference/adapt.md +1 -0
- package/template/.agents/skills/design/impeccable/reference/adapt.native.md +58 -0
- package/template/.agents/skills/design/impeccable/reference/android.md +40 -0
- package/template/.agents/skills/design/impeccable/reference/animate.md +73 -188
- package/template/.agents/skills/design/impeccable/reference/audit.md +12 -9
- package/template/.agents/skills/design/impeccable/reference/audit.native.md +139 -0
- package/template/.agents/skills/design/impeccable/reference/bolder.md +19 -101
- package/template/.agents/skills/design/impeccable/reference/clarify.md +59 -253
- package/template/.agents/skills/design/impeccable/reference/colorize.md +51 -222
- package/template/.agents/skills/design/impeccable/reference/craft-floor.md +42 -0
- package/template/.agents/skills/design/impeccable/reference/craft.md +3 -121
- package/template/.agents/skills/design/impeccable/reference/critique.md +44 -23
- package/template/.agents/skills/design/impeccable/reference/degraded/asset-producer.md +90 -0
- package/template/.agents/skills/design/impeccable/reference/degraded/documenter.md +24 -0
- package/template/.agents/skills/design/impeccable/reference/degraded/finish-reviewer.md +37 -0
- package/template/.agents/skills/design/impeccable/reference/degraded/manual-edit-applier.md +92 -0
- package/template/.agents/skills/design/impeccable/reference/delight.md +47 -279
- package/template/.agents/skills/design/impeccable/reference/distill.md +2 -2
- package/template/.agents/skills/design/impeccable/reference/doctor.md +53 -0
- package/template/.agents/skills/design/impeccable/reference/document.md +60 -73
- package/template/.agents/skills/design/impeccable/reference/harden.md +1 -12
- package/template/.agents/skills/design/impeccable/reference/hooks.md +20 -5
- package/template/.agents/skills/design/impeccable/reference/init.md +72 -119
- package/template/.agents/skills/design/impeccable/reference/ios.md +45 -0
- package/template/.agents/skills/design/impeccable/reference/layout.md +54 -131
- package/template/.agents/skills/design/impeccable/reference/live-setup.md +102 -0
- package/template/.agents/skills/design/impeccable/reference/live.md +116 -511
- package/template/.agents/skills/design/impeccable/reference/new-work.md +105 -0
- package/template/.agents/skills/design/impeccable/reference/{product.md → operate.md} +6 -5
- package/template/.agents/skills/design/impeccable/reference/optimize.md +4 -4
- package/template/.agents/skills/design/impeccable/reference/overdrive.md +1 -4
- package/template/.agents/skills/design/impeccable/reference/polish.md +68 -212
- package/template/.agents/skills/design/impeccable/reference/quieter.md +3 -3
- package/template/.agents/skills/design/impeccable/reference/routing.md +18 -0
- package/template/.agents/skills/design/impeccable/reference/shape.md +38 -144
- package/template/.agents/skills/design/impeccable/reference/typeset.md +51 -250
- package/template/.agents/skills/design/impeccable/reference/visualize.md +47 -0
- package/template/.agents/skills/design/impeccable/scripts/command-metadata.json +1 -1
- package/template/.agents/skills/design/impeccable/scripts/concept-seed.mjs +558 -0
- package/template/.agents/skills/design/impeccable/scripts/context-signals.mjs +119 -10
- package/template/.agents/skills/design/impeccable/scripts/context.mjs +534 -45
- package/template/.agents/skills/design/impeccable/scripts/critique-storage.mjs +18 -47
- package/template/.agents/skills/design/impeccable/scripts/detector/browser/injected/index.mjs +96 -10
- package/template/.agents/skills/design/impeccable/scripts/detector/cli/main.mjs +174 -26
- package/template/.agents/skills/design/impeccable/scripts/detector/design-system.mjs +233 -0
- package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js +3348 -203
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/browser/detect-url.mjs +102 -7
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/regex/detect-text.mjs +297 -97
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +187 -16
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/detect-html.mjs +41 -11
- package/template/.agents/skills/design/impeccable/scripts/detector/findings.mjs +7 -1
- package/template/.agents/skills/design/impeccable/scripts/detector/node/file-system.mjs +16 -2
- package/template/.agents/skills/design/impeccable/scripts/detector/registry/antipatterns.mjs +207 -38
- package/template/.agents/skills/design/impeccable/scripts/detector/rules/checks.mjs +3082 -173
- package/template/.agents/skills/design/impeccable/scripts/detector/shared/constants.mjs +11 -0
- package/template/.agents/skills/design/impeccable/scripts/detector/shared/fonts.mjs +30 -0
- package/template/.agents/skills/design/impeccable/scripts/doctor.mjs +336 -0
- package/template/.agents/skills/design/impeccable/scripts/embed-prompt.mjs +133 -0
- package/template/.agents/skills/design/impeccable/scripts/generate-image.mjs +240 -0
- package/template/.agents/skills/design/impeccable/scripts/hook-admin.mjs +98 -18
- package/template/.agents/skills/design/impeccable/scripts/hook-before-edit.mjs +46 -6
- package/template/.agents/skills/design/impeccable/scripts/hook-lib.mjs +518 -50
- package/template/.agents/skills/design/impeccable/scripts/hook.mjs +25 -8
- package/template/.agents/skills/design/impeccable/scripts/lib/artifact-schema.mjs +93 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/composition-catalog.mjs +200 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/concept-catalog.mjs +357 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-config.mjs +27 -7
- package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-paths.mjs +17 -8
- package/template/.agents/skills/design/impeccable/scripts/lib/provider.mjs +5 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/roll-selection.mjs +362 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/staleness-deep.mjs +457 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/staleness-notice.mjs +169 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/staleness.mjs +457 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/surface-briefs.mjs +151 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/target-slug.mjs +33 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/template-extensions.mjs +146 -0
- package/template/.agents/skills/design/impeccable/scripts/live/accept-css.mjs +617 -0
- package/template/.agents/skills/design/impeccable/scripts/live/accept-verify.mjs +60 -0
- package/template/.agents/skills/design/impeccable/scripts/live/browser-script-parts.mjs +7 -1
- package/template/.agents/skills/design/impeccable/scripts/live/completion.mjs +10 -1
- package/template/.agents/skills/design/impeccable/scripts/live/event-validation.mjs +67 -5
- package/template/.agents/skills/design/impeccable/scripts/live/frameworks/astro.mjs +47 -0
- package/template/.agents/skills/design/impeccable/scripts/live/frameworks/detect-utils.mjs +73 -0
- package/template/.agents/skills/design/impeccable/scripts/live/frameworks/index.mjs +143 -0
- package/template/.agents/skills/design/impeccable/scripts/live/frameworks/journal.mjs +197 -0
- package/template/.agents/skills/design/impeccable/scripts/live/frameworks/nextjs.mjs +49 -0
- package/template/.agents/skills/design/impeccable/scripts/live/frameworks/nuxt.mjs +161 -0
- package/template/.agents/skills/design/impeccable/scripts/live/frameworks/script-src.mjs +17 -0
- package/template/.agents/skills/design/impeccable/scripts/live/frameworks/static-html.mjs +26 -0
- package/template/.agents/skills/design/impeccable/scripts/live/frameworks/sveltekit.mjs +71 -0
- package/template/.agents/skills/design/impeccable/scripts/live/frameworks/tag-strategy.mjs +247 -0
- package/template/.agents/skills/design/impeccable/scripts/live/frameworks/tanstack-start.mjs +70 -0
- package/template/.agents/skills/design/impeccable/scripts/live/frameworks/vite-generic.mjs +42 -0
- package/template/.agents/skills/design/impeccable/scripts/live/generation-preflight.mjs +149 -0
- package/template/.agents/skills/design/impeccable/scripts/live/instructions.mjs +142 -0
- package/template/.agents/skills/design/impeccable/scripts/live/poll-lanes.mjs +14 -0
- package/template/.agents/skills/design/impeccable/scripts/live/roots.mjs +508 -0
- package/template/.agents/skills/design/impeccable/scripts/live/session-store.mjs +324 -50
- package/template/.agents/skills/design/impeccable/scripts/live/source-lock.mjs +105 -0
- package/template/.agents/skills/design/impeccable/scripts/live/source-search.mjs +105 -0
- package/template/.agents/skills/design/impeccable/scripts/live/svelte-ast.mjs +961 -0
- package/template/.agents/skills/design/impeccable/scripts/live/svelte-component.mjs +588 -72
- package/template/.agents/skills/design/impeccable/scripts/live/sveltekit-adapter.mjs +59 -17
- package/template/.agents/skills/design/impeccable/scripts/live/tanstack-adapter.mjs +280 -0
- package/template/.agents/skills/design/impeccable/scripts/live/vocabulary.mjs +135 -0
- package/template/.agents/skills/design/impeccable/scripts/live-accept.mjs +210 -68
- package/template/.agents/skills/design/impeccable/scripts/live-browser.js +1562 -223
- package/template/.agents/skills/design/impeccable/scripts/live-commit-manual-edits.mjs +3 -0
- package/template/.agents/skills/design/impeccable/scripts/live-complete.mjs +33 -1
- package/template/.agents/skills/design/impeccable/scripts/live-inject.mjs +175 -255
- package/template/.agents/skills/design/impeccable/scripts/live-insert.mjs +26 -6
- package/template/.agents/skills/design/impeccable/scripts/live-manual-edit-evidence.mjs +6 -1
- package/template/.agents/skills/design/impeccable/scripts/live-poll.mjs +61 -16
- package/template/.agents/skills/design/impeccable/scripts/live-resume.mjs +39 -10
- package/template/.agents/skills/design/impeccable/scripts/live-server.mjs +573 -47
- package/template/.agents/skills/design/impeccable/scripts/live-status.mjs +17 -7
- package/template/.agents/skills/design/impeccable/scripts/live-wrap.mjs +124 -91
- package/template/.agents/skills/design/impeccable/scripts/live.mjs +88 -26
- package/template/.agents/skills/design/impeccable/scripts/palette.mjs +76 -81
- package/template/.agents/skills/design/impeccable/scripts/pin.mjs +18 -11
- package/template/.agents/skills/design/impeccable/scripts/serve-question.mjs +932 -0
- package/template/.agents/skills/design/impeccable/scripts/surface-brief.mjs +74 -0
- package/template/.agents/skills/design/shadcn/SKILL.md +46 -11
- package/template/.agents/skills/design/shadcn/cli.md +49 -16
- package/template/.agents/skills/design/shadcn/customization.md +14 -7
- package/template/.agents/skills/design/shadcn/evals/evals.json +30 -0
- package/template/.agents/skills/design/shadcn/mcp.md +27 -16
- package/template/.agents/skills/design/shadcn/registry.md +277 -0
- package/template/.agents/skills/design/shadcn/rules/chat.md +224 -0
- package/template/.agents/skills/design/shadcn/rules/composition.md +20 -2
- package/template/.agents/skills/design/shadcn/rules/styling.md +23 -0
- package/template/.agents/skills/find-skills/SKILL.md +2 -3
- package/template/.agents/skills/hono/SKILL.md +579 -0
- package/template/.agents/skills/integrations/langfuse/SKILL.md +10 -6
- package/template/.agents/skills/integrations/langfuse/references/ci-cd.md +41 -0
- package/template/.agents/skills/integrations/langfuse/references/cli.md +8 -0
- package/template/.agents/skills/integrations/langfuse/references/error-analysis.md +15 -27
- package/template/.agents/skills/integrations/langfuse/references/instrumentation.md +39 -52
- package/template/.agents/skills/integrations/langfuse/references/judge-calibration.md +5 -2
- package/template/.agents/skills/integrations/langfuse/references/prompt-engineering.md +35 -0
- package/template/.agents/skills/integrations/langfuse/references/prompt-migration.md +41 -196
- package/template/.agents/skills/integrations/langfuse/references/skill-feedback.md +3 -0
- package/template/.agents/skills/integrations/langfuse/references/trace-evaluator-upgrade.md +76 -0
- package/template/.agents/skills/integrations/langfuse/references/user-feedback.md +4 -0
- package/template/.agents/skills/integrations/langfuse/references/v4-project-migration.md +73 -0
- package/template/.agents/skills/supabase/CHANGELOG.md +71 -0
- package/template/.agents/skills/supabase/SKILL.md +145 -0
- package/template/.agents/skills/supabase/assets/feedback-issue-template.md +17 -0
- package/template/.agents/skills/supabase/references/skill-feedback.md +17 -0
- package/template/.agents/skills/supabase-postgres-best-practices/CHANGELOG.md +73 -0
- package/template/.agents/skills/supabase-postgres-best-practices/SKILL.md +64 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/_contributing.md +170 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/_sections.md +39 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/_template.md +34 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/advanced-full-text-search.md +55 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/advanced-jsonb-indexing.md +49 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/conn-idle-timeout.md +46 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/conn-limits.md +44 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/conn-pooling.md +41 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/conn-prepared-statements.md +46 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/data-batch-inserts.md +54 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/data-n-plus-one.md +53 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/data-pagination.md +50 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/data-upsert.md +50 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/lock-advisory.md +56 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/lock-deadlock-prevention.md +68 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/lock-short-transactions.md +50 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/lock-skip-locked.md +54 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-explain-analyze.md +45 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-pg-stat-statements.md +55 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-vacuum-analyze.md +55 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/query-composite-indexes.md +44 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/query-covering-indexes.md +40 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/query-index-types.md +48 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/query-missing-indexes.md +43 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/query-partial-indexes.md +45 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/schema-constraints.md +80 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/schema-data-types.md +46 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/schema-foreign-key-indexes.md +59 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/schema-lowercase-identifiers.md +55 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/schema-partitioning.md +55 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/schema-primary-keys.md +61 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/security-privileges.md +54 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/security-rls-basics.md +50 -0
- package/template/.agents/skills/supabase-postgres-best-practices/references/security-rls-performance.md +63 -0
- package/template/.agents/upstreams.json +34 -12
- package/template/.agents/skills/design/impeccable/reference/brand.md +0 -108
- package/template/.agents/skills/design/impeccable/reference/codex.md +0 -105
- package/template/.agents/skills/design/impeccable/reference/interaction-design.md +0 -189
- package/template/.agents/skills/integrations/langfuse/references/sdk-upgrade.md +0 -175
|
@@ -9,20 +9,26 @@
|
|
|
9
9
|
* ENVELOPE_PREFIX, ALLOWED_EXTS, ACK_EXTS, SENSITIVE_PATH, GENERATED_PATH, TRUTHY
|
|
10
10
|
* truthy(value)
|
|
11
11
|
* readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd)
|
|
12
|
+
* resolveProjectPlatform(cwd) / isNativePlatform(platform)
|
|
12
13
|
* normalizeIgnoreValue(value)
|
|
13
|
-
* readCache(cwd) / persistCache(cwd, cache)
|
|
14
|
+
* readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd)
|
|
14
15
|
* bumpEditCount(cache, sessionId, filePath) -> number
|
|
16
|
+
* touchFile(cache, sessionId, filePath)
|
|
15
17
|
* suppressionNotice(filePath)
|
|
16
18
|
* filterFindings(findings, content, ext, config)
|
|
19
|
+
* ADVISORY_RULES / isAdvisoryFinding(finding)
|
|
20
|
+
* IMMEDIATE_TIER_RULES / splitFindingsByTier(findings) / perEditTieringActive(config, harness)
|
|
21
|
+
* matchConfiguredExtension(filePath, extensions)
|
|
17
22
|
* dedupeAgainstCache(findings, cache, sessionId, filePath)
|
|
18
23
|
* renderTemplate(findings, filePath, config, opts)
|
|
19
24
|
* renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts)
|
|
20
|
-
* shouldEmitAckForFile(filePath)
|
|
25
|
+
* shouldEmitAckForFile(filePath, config?)
|
|
21
26
|
* writeAuditLog(env, entry)
|
|
22
27
|
* loadDetector() -> Promise<{ detectText, detectHtml }>
|
|
23
28
|
* matchesAnyGlob(filePath, globs)
|
|
24
29
|
* normalizeScanTargets(primaryTargets, projectCwd)
|
|
25
30
|
* runHook(deps) -> { exitCode, stdout, audit, reason? }
|
|
31
|
+
* runStopHook(deps) -> { exitCode, stdout, audit, emission? }
|
|
26
32
|
*
|
|
27
33
|
* Design notes:
|
|
28
34
|
* - All errors are swallowed at the runHook seam. The detector throwing must
|
|
@@ -35,8 +41,21 @@
|
|
|
35
41
|
*/
|
|
36
42
|
|
|
37
43
|
import fs from 'node:fs';
|
|
44
|
+
import os from 'node:os';
|
|
38
45
|
import path from 'node:path';
|
|
39
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 };
|
|
40
59
|
|
|
41
60
|
const __filename = fileURLToPath(import.meta.url);
|
|
42
61
|
const __dirname = path.dirname(__filename);
|
|
@@ -65,10 +84,69 @@ export const SENSITIVE_PATH = new RegExp([
|
|
|
65
84
|
].join('|'), 'i');
|
|
66
85
|
|
|
67
86
|
// Hard-skip regex for generated, lock, minified, and build-output paths.
|
|
68
|
-
|
|
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;
|
|
69
90
|
|
|
70
91
|
export const TRUTHY = /^(1|true|yes|on)$/i;
|
|
71
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
|
+
|
|
72
150
|
export const DEFAULT_CONFIG = Object.freeze({
|
|
73
151
|
enabled: true,
|
|
74
152
|
quiet: false,
|
|
@@ -77,7 +155,17 @@ export const DEFAULT_CONFIG = Object.freeze({
|
|
|
77
155
|
ignoreRules: [],
|
|
78
156
|
ignoreFiles: [],
|
|
79
157
|
ignoreValues: [],
|
|
80
|
-
|
|
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 },
|
|
81
169
|
});
|
|
82
170
|
|
|
83
171
|
export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([
|
|
@@ -134,6 +222,59 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
|
|
|
134
222
|
|| fallback;
|
|
135
223
|
}
|
|
136
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
|
+
|
|
137
278
|
export function readConfig(cwd) {
|
|
138
279
|
const config = cloneDefaultConfig();
|
|
139
280
|
// Hook runtime settings live under `hook`; detector filters live under
|
|
@@ -168,6 +309,7 @@ function cloneDefaultConfig() {
|
|
|
168
309
|
ignoreRules: [],
|
|
169
310
|
ignoreFiles: [],
|
|
170
311
|
ignoreValues: [],
|
|
312
|
+
extensions: [],
|
|
171
313
|
designSystem: { ...DEFAULT_CONFIG.designSystem },
|
|
172
314
|
limits: { ...DEFAULT_CONFIG.limits },
|
|
173
315
|
};
|
|
@@ -175,6 +317,11 @@ function cloneDefaultConfig() {
|
|
|
175
317
|
|
|
176
318
|
function applyDetectorConfigSource(config, raw) {
|
|
177
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
|
+
}
|
|
178
325
|
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
|
179
326
|
config.designSystem = {
|
|
180
327
|
...config.designSystem,
|
|
@@ -190,6 +337,9 @@ function applyDetectorConfigSource(config, raw) {
|
|
|
190
337
|
if (Array.isArray(raw.ignoreValues)) {
|
|
191
338
|
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
|
|
192
339
|
}
|
|
340
|
+
if (Array.isArray(raw.extensions)) {
|
|
341
|
+
config.extensions = mergeExtensions(config.extensions, raw.extensions);
|
|
342
|
+
}
|
|
193
343
|
return config;
|
|
194
344
|
}
|
|
195
345
|
|
|
@@ -201,6 +351,9 @@ function applyConfigSource(config, raw) {
|
|
|
201
351
|
if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) {
|
|
202
352
|
config.quiet = raw.quiet === true;
|
|
203
353
|
}
|
|
354
|
+
if (raw.perEditRules === 'all' || raw.perEditRules === 'immediate') {
|
|
355
|
+
config.perEditRules = raw.perEditRules;
|
|
356
|
+
}
|
|
204
357
|
if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
|
|
205
358
|
config.auditLog = raw.auditLog.trim();
|
|
206
359
|
}
|
|
@@ -209,6 +362,7 @@ function applyConfigSource(config, raw) {
|
|
|
209
362
|
config.limits = {
|
|
210
363
|
maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings),
|
|
211
364
|
maxChars: numberOr(raw.limits.maxChars, config.limits.maxChars),
|
|
365
|
+
maxFileBytes: numberOr(raw.limits.maxFileBytes, config.limits.maxFileBytes),
|
|
212
366
|
};
|
|
213
367
|
}
|
|
214
368
|
return config;
|
|
@@ -396,12 +550,15 @@ export function normalizeIgnoreValueEntries(entries) {
|
|
|
396
550
|
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
|
|
397
551
|
]);
|
|
398
552
|
if (files.length > 0) normalized.files = files;
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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.
|
|
402
556
|
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
|
|
403
557
|
normalized.createdAt = entry.createdAt.trim();
|
|
404
558
|
}
|
|
559
|
+
if (typeof entry.reason === 'string' && entry.reason.trim()) {
|
|
560
|
+
normalized.reason = entry.reason.trim();
|
|
561
|
+
}
|
|
405
562
|
out.push(normalized);
|
|
406
563
|
}
|
|
407
564
|
return out;
|
|
@@ -419,7 +576,9 @@ function mergeIgnoreValues(existing, incoming) {
|
|
|
419
576
|
}
|
|
420
577
|
|
|
421
578
|
function ignoreValueFilesKey(files) {
|
|
422
|
-
|
|
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') : '';
|
|
423
582
|
}
|
|
424
583
|
|
|
425
584
|
export function readCache(cwd) {
|
|
@@ -555,8 +714,16 @@ export function bumpEditCount(cache, sessionId, filePath) {
|
|
|
555
714
|
return fileEntry.editCount;
|
|
556
715
|
}
|
|
557
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
|
+
|
|
558
725
|
export function suppressionNotice(filePath) {
|
|
559
|
-
return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run
|
|
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.`;
|
|
560
727
|
}
|
|
561
728
|
|
|
562
729
|
// Glob → RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
|
|
@@ -617,22 +784,53 @@ export function filterFindings(findings, _content, _ext, config) {
|
|
|
617
784
|
if (!Array.isArray(findings) || findings.length === 0) return [];
|
|
618
785
|
const ignoreRules = new Set((config.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
|
|
619
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';
|
|
620
790
|
return findings.filter((f) => {
|
|
621
791
|
if (!f || typeof f !== 'object') return false;
|
|
792
|
+
if (!includeAdvisory && isAdvisoryFinding(f)) return false;
|
|
622
793
|
if (ignoreRules.has(normalizeIgnoreRule(f.antipattern))) return false;
|
|
623
794
|
if (isIgnoredFindingValue(f, ignoreValues)) return false;
|
|
624
795
|
return true;
|
|
625
796
|
});
|
|
626
797
|
}
|
|
627
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
|
+
|
|
628
824
|
function isIgnoredFindingValue(finding, ignoreValues) {
|
|
629
825
|
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
|
|
630
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.
|
|
631
829
|
const value = extractFindingIgnoreValue(finding);
|
|
632
|
-
if (!rule || !value) return false;
|
|
633
830
|
return ignoreValues.some((entry) => {
|
|
831
|
+
if (entry.rule !== rule) return false;
|
|
634
832
|
const wildcardValue = entry.value === '*';
|
|
635
|
-
if (
|
|
833
|
+
if (!wildcardValue && (!value || !ignoreValueMatches(rule, entry.value, value))) return false;
|
|
636
834
|
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
|
|
637
835
|
return findingMatchesScopedIgnoreFile(finding, entry.files);
|
|
638
836
|
});
|
|
@@ -661,6 +859,7 @@ export function extractFindingIgnoreValue(finding) {
|
|
|
661
859
|
'design-system-font',
|
|
662
860
|
'design-system-color',
|
|
663
861
|
'design-system-radius',
|
|
862
|
+
'design-system-font-size',
|
|
664
863
|
]);
|
|
665
864
|
if (!directValueRules.has(rule)) return '';
|
|
666
865
|
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
|
|
@@ -681,6 +880,9 @@ function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(findin
|
|
|
681
880
|
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
|
|
682
881
|
if (primary) return cleanIgnoreValueDisplay(primary[1]);
|
|
683
882
|
|
|
883
|
+
const googleLabel = text.match(/Google Fonts:\s*([^()\n;]+)/i);
|
|
884
|
+
if (googleLabel) return cleanIgnoreValueDisplay(googleLabel[1]);
|
|
885
|
+
|
|
684
886
|
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
|
|
685
887
|
if (family) return cleanIgnoreValueDisplay(family[1]);
|
|
686
888
|
|
|
@@ -737,11 +939,20 @@ export function dedupeAgainstCache(findings, cache, sessionId, filePath) {
|
|
|
737
939
|
return fresh;
|
|
738
940
|
}
|
|
739
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.
|
|
740
952
|
export function rememberFindings(cache, sessionId, filePath, findings) {
|
|
741
953
|
const fileEntry = ensureFile(cache, sessionId, filePath);
|
|
742
|
-
const
|
|
743
|
-
|
|
744
|
-
fileEntry.findings = Array.from(known);
|
|
954
|
+
const keys = new Set((findings || []).map(f => findingCacheKey(f)));
|
|
955
|
+
fileEntry.findings = Array.from(keys);
|
|
745
956
|
ensureSession(cache, sessionId).updatedAt = Date.now();
|
|
746
957
|
}
|
|
747
958
|
|
|
@@ -770,7 +981,7 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
|
|
|
770
981
|
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
|
|
771
982
|
const lines = shown.map((f) => formatFindingLine(f));
|
|
772
983
|
const more = remaining > 0
|
|
773
|
-
? `... and ${remaining} more (see
|
|
984
|
+
? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).`
|
|
774
985
|
: null;
|
|
775
986
|
const footer = directiveFooter(display);
|
|
776
987
|
|
|
@@ -814,7 +1025,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
|
|
|
814
1025
|
shownCount += shown.length;
|
|
815
1026
|
const hidden = group.findings.length - shown.length;
|
|
816
1027
|
if (hidden > 0) {
|
|
817
|
-
lines.push(`- ... ${hidden} more in ${display} (see
|
|
1028
|
+
lines.push(`- ... ${hidden} more in ${display} (see ${IMPECCABLE_COMMAND} audit).`);
|
|
818
1029
|
}
|
|
819
1030
|
}
|
|
820
1031
|
|
|
@@ -830,7 +1041,7 @@ function clampGroupedToBudget(header, lines, footer, maxChars) {
|
|
|
830
1041
|
const assemble = (linesArr, omitted) => [
|
|
831
1042
|
header,
|
|
832
1043
|
...linesArr,
|
|
833
|
-
...(omitted ? [
|
|
1044
|
+
...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []),
|
|
834
1045
|
'',
|
|
835
1046
|
footer,
|
|
836
1047
|
].join('\n');
|
|
@@ -863,7 +1074,7 @@ function clampToBudget(header, lines, more, footer, maxChars) {
|
|
|
863
1074
|
let assembled = assemble(working, moreText);
|
|
864
1075
|
while (assembled.length > maxChars && working.length > 1) {
|
|
865
1076
|
working.pop();
|
|
866
|
-
moreText =
|
|
1077
|
+
moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`;
|
|
867
1078
|
assembled = assemble(working, moreText);
|
|
868
1079
|
}
|
|
869
1080
|
if (assembled.length > maxChars) {
|
|
@@ -895,7 +1106,7 @@ function formatFindingIgnoreCommand(finding) {
|
|
|
895
1106
|
const value = extractFindingIgnoreValueRaw(finding);
|
|
896
1107
|
const valueArg = quoteCommandArg(value);
|
|
897
1108
|
const reason = quoteCommandArg(`User confirmed ${value} is intentional`);
|
|
898
|
-
return
|
|
1109
|
+
return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
|
|
899
1110
|
}
|
|
900
1111
|
|
|
901
1112
|
function quoteCommandArg(value) {
|
|
@@ -1319,8 +1530,12 @@ export function renderPendingAck(filePath, knownFindings, opts = {}) {
|
|
|
1319
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.`;
|
|
1320
1531
|
}
|
|
1321
1532
|
|
|
1322
|
-
export function shouldEmitAckForFile(filePath) {
|
|
1323
|
-
|
|
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');
|
|
1324
1539
|
}
|
|
1325
1540
|
|
|
1326
1541
|
export function designSystemOptions(config, detector, projectCwd) {
|
|
@@ -1336,7 +1551,7 @@ export function designSystemOptions(config, detector, projectCwd) {
|
|
|
1336
1551
|
|
|
1337
1552
|
export function appendDesignSystemNote(text, scanOptions) {
|
|
1338
1553
|
if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
|
|
1339
|
-
return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run
|
|
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.`;
|
|
1340
1555
|
}
|
|
1341
1556
|
|
|
1342
1557
|
// The directive footer is the part of the hook output that steers model
|
|
@@ -1353,16 +1568,17 @@ export function appendDesignSystemNote(text, scanOptions) {
|
|
|
1353
1568
|
// raw envelope. Asking the model to surface the resolution in its
|
|
1354
1569
|
// reply is the cheapest way to make the feedback loop visible.
|
|
1355
1570
|
function directiveFooter(display, opts = {}) {
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
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)`;
|
|
1360
1576
|
return [
|
|
1361
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.',
|
|
1362
1578
|
'',
|
|
1363
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.',
|
|
1364
1580
|
'',
|
|
1365
|
-
`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
|
|
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.`,
|
|
1366
1582
|
].join('\n');
|
|
1367
1583
|
}
|
|
1368
1584
|
|
|
@@ -1402,9 +1618,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
|
|
1402
1618
|
event = normalizeHookEvent(event, cwd, harness);
|
|
1403
1619
|
audit.harness = harness;
|
|
1404
1620
|
|
|
1405
|
-
const
|
|
1621
|
+
const sessionCwd = event.cwd || cwd;
|
|
1622
|
+
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd);
|
|
1623
|
+
const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd);
|
|
1406
1624
|
audit.cwd = projectCwd;
|
|
1407
|
-
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
|
|
1408
1625
|
const primaryFileSet = new Set(primaryFiles);
|
|
1409
1626
|
const targetFiles = expandScanTargets(primaryFiles, projectCwd);
|
|
1410
1627
|
audit.session = event.session_id || null;
|
|
@@ -1419,22 +1636,33 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
|
|
1419
1636
|
return result({ skipped: 'config-disabled', durationMs: Date.now() - started });
|
|
1420
1637
|
}
|
|
1421
1638
|
|
|
1639
|
+
const platform = resolveProjectPlatform(projectCwd);
|
|
1640
|
+
if (isNativePlatform(platform)) {
|
|
1641
|
+
return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started });
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1422
1644
|
const cache = readCache(projectCwd);
|
|
1423
1645
|
const sessionId = event.session_id || 'unknown';
|
|
1424
1646
|
const det = detector || await loadDetector();
|
|
1425
1647
|
if (!det || typeof det.detectText !== 'function') {
|
|
1426
|
-
|
|
1648
|
+
// Cache is not mutated yet at this point; nothing to persist.
|
|
1427
1649
|
return result({ skipped: 'detector-missing', durationMs: Date.now() - started });
|
|
1428
1650
|
}
|
|
1429
1651
|
const scanOptions = designSystemOptions(config, det, projectCwd);
|
|
1652
|
+
const tiered = perEditTieringActive(config, harness);
|
|
1430
1653
|
|
|
1431
1654
|
let pendingWinner = null;
|
|
1432
1655
|
let cleanWinner = null;
|
|
1433
1656
|
const freshGroups = [];
|
|
1434
1657
|
let suppressionWinner = null;
|
|
1658
|
+
let cleanAckDeduped = false;
|
|
1659
|
+
let skippedBytes = 0;
|
|
1660
|
+
const quietMode = truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true;
|
|
1435
1661
|
let detectorThrewAny = false;
|
|
1436
1662
|
let lastSkip = 'no-scannable-file';
|
|
1437
1663
|
let suppressedHit = false;
|
|
1664
|
+
let cacheDirty = false;
|
|
1665
|
+
let deferredTotal = 0;
|
|
1438
1666
|
|
|
1439
1667
|
for (const filePath of targetFiles) {
|
|
1440
1668
|
audit.file = filePath;
|
|
@@ -1449,8 +1677,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
|
|
1449
1677
|
}
|
|
1450
1678
|
|
|
1451
1679
|
const ext = path.extname(filePath).toLowerCase();
|
|
1452
|
-
|
|
1453
|
-
|
|
1680
|
+
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
|
|
1681
|
+
audit.ext = configuredExt ? configuredExt.ext : ext;
|
|
1682
|
+
if (!ALLOWED_EXTS.has(ext) && !configuredExt) {
|
|
1454
1683
|
lastSkip = 'extension';
|
|
1455
1684
|
continue;
|
|
1456
1685
|
}
|
|
@@ -1465,8 +1694,20 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
|
|
1465
1694
|
continue;
|
|
1466
1695
|
}
|
|
1467
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
|
+
|
|
1468
1708
|
if (primaryFileSet.has(filePath)) {
|
|
1469
1709
|
const editCount = bumpEditCount(cache, sessionId, filePath);
|
|
1710
|
+
cacheDirty = true;
|
|
1470
1711
|
audit.editCount = editCount;
|
|
1471
1712
|
|
|
1472
1713
|
if (editCount > EDIT_COUNT_THRESHOLD) {
|
|
@@ -1483,37 +1724,88 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
|
|
1483
1724
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1484
1725
|
let findings;
|
|
1485
1726
|
let detectorThrew = false;
|
|
1486
|
-
|
|
1727
|
+
const useHtmlEngine = configuredExt
|
|
1728
|
+
? configuredExt.engine === 'html'
|
|
1729
|
+
: (ext === '.html' || ext === '.htm');
|
|
1730
|
+
if (useHtmlEngine && typeof det.detectHtml === 'function') {
|
|
1487
1731
|
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
|
1488
1732
|
} else {
|
|
1489
1733
|
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
|
1490
1734
|
}
|
|
1491
1735
|
|
|
1492
1736
|
const filtered = filterFindings(findings || [], content, ext, config);
|
|
1493
|
-
|
|
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);
|
|
1494
1749
|
audit.findings = (findings || []).length;
|
|
1495
1750
|
audit.freshFindings = fresh.length;
|
|
1751
|
+
if (deferredTotal > 0) audit.deferred = deferredTotal;
|
|
1496
1752
|
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
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;
|
|
1500
1757
|
continue;
|
|
1501
1758
|
}
|
|
1502
1759
|
|
|
1503
|
-
|
|
1504
|
-
|
|
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 });
|
|
1505
1769
|
continue;
|
|
1506
1770
|
}
|
|
1507
1771
|
|
|
1508
|
-
if (
|
|
1509
|
-
|
|
1510
|
-
pendingWinner = { filePath, known };
|
|
1511
|
-
} else if (
|
|
1512
|
-
|
|
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
|
+
}
|
|
1513
1796
|
}
|
|
1514
1797
|
}
|
|
1515
1798
|
|
|
1516
|
-
|
|
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
|
+
}
|
|
1517
1809
|
|
|
1518
1810
|
if (freshGroups.length > 0) {
|
|
1519
1811
|
const firstGroup = freshGroups[0];
|
|
@@ -1544,11 +1836,11 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
|
|
1544
1836
|
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
|
|
1545
1837
|
}
|
|
1546
1838
|
|
|
1547
|
-
if (
|
|
1839
|
+
if (quietMode) {
|
|
1548
1840
|
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
|
|
1549
1841
|
}
|
|
1550
1842
|
|
|
1551
|
-
if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath)) {
|
|
1843
|
+
if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) {
|
|
1552
1844
|
const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions);
|
|
1553
1845
|
return {
|
|
1554
1846
|
exitCode: 0,
|
|
@@ -1582,7 +1874,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
|
|
1582
1874
|
};
|
|
1583
1875
|
}
|
|
1584
1876
|
|
|
1585
|
-
if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath)) {
|
|
1877
|
+
if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) {
|
|
1586
1878
|
const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions);
|
|
1587
1879
|
return {
|
|
1588
1880
|
exitCode: 0,
|
|
@@ -1599,15 +1891,191 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
|
|
1599
1891
|
};
|
|
1600
1892
|
}
|
|
1601
1893
|
|
|
1602
|
-
if (pendingWinner
|
|
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) {
|
|
1603
1901
|
return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started });
|
|
1604
1902
|
}
|
|
1605
1903
|
|
|
1904
|
+
if (cleanAckDeduped) {
|
|
1905
|
+
return result({ emitted: false, skipped: 'clean-ack-deduped', durationMs: Date.now() - started });
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1606
1908
|
if (suppressedHit) {
|
|
1607
1909
|
return result({ suppressed: true, emitted: false, durationMs: Date.now() - started });
|
|
1608
1910
|
}
|
|
1609
1911
|
|
|
1610
|
-
return result({
|
|
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
|
+
};
|
|
1611
2079
|
} catch (err) {
|
|
1612
2080
|
return {
|
|
1613
2081
|
exitCode: 0,
|