@monoes/monodesign 1.2.20 → 1.2.22
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 +2 -2
- package/cli/engine/design-system-checks.mjs +381 -0
- package/cli/engine/design-system-parse.mjs +433 -0
- package/cli/engine/design-system.mjs +20 -736
- package/cli/engine/engines/regex/detect-text-matchers.mjs +371 -0
- package/cli/engine/engines/regex/detect-text-run.mjs +186 -0
- package/cli/engine/engines/regex/detect-text.mjs +11 -523
- package/cli/engine/engines/static-html/css-cascade-color.mjs +219 -0
- package/cli/engine/engines/static-html/css-cascade-static-data.mjs +138 -0
- package/cli/engine/engines/static-html/css-cascade-static-dom.mjs +132 -0
- package/cli/engine/engines/static-html/css-cascade-static-parse.mjs +393 -0
- package/cli/engine/engines/static-html/css-cascade.mjs +24 -837
- package/cli/lib/monodesign-config-consent.mjs +93 -0
- package/cli/lib/monodesign-config-core.mjs +37 -0
- package/cli/lib/monodesign-config-detection.mjs +133 -0
- package/cli/lib/monodesign-config-filter.mjs +198 -0
- package/cli/lib/monodesign-config-ignore-value.mjs +212 -0
- package/cli/lib/monodesign-config.mjs +21 -623
- package/package.json +2 -2
- package/skill/scripts/context-fields.mjs +65 -0
- package/skill/scripts/context-resolve.mjs +425 -0
- package/skill/scripts/context-update.mjs +151 -0
- package/skill/scripts/context-utils.mjs +68 -0
- package/skill/scripts/context-workspace.mjs +245 -0
- package/skill/scripts/context.mjs +17 -891
- package/skill/scripts/hook-admin-config.mjs +152 -0
- package/skill/scripts/hook-admin-ignore.mjs +131 -0
- package/skill/scripts/hook-admin-manifest.mjs +292 -0
- package/skill/scripts/hook-admin.mjs +8 -541
- package/skill/scripts/hook-lib-cache.mjs +145 -0
- package/skill/scripts/hook-lib-config.mjs +292 -0
- package/skill/scripts/hook-lib-detector.mjs +72 -0
- package/skill/scripts/hook-lib-events.mjs +182 -0
- package/skill/scripts/hook-lib-findings.mjs +201 -0
- package/skill/scripts/hook-lib-ignore.mjs +205 -0
- package/skill/scripts/hook-lib-render.mjs +273 -0
- package/skill/scripts/hook-lib-run.mjs +281 -0
- package/skill/scripts/hook-lib-scan-targets.mjs +128 -0
- package/skill/scripts/hook-lib.mjs +71 -1747
- package/skill/scripts/lib/design-parser-colors.mjs +182 -0
- package/skill/scripts/lib/design-parser-extract.mjs +333 -0
- package/skill/scripts/lib/design-parser-sections.mjs +214 -0
- package/skill/scripts/lib/design-parser-yaml.mjs +135 -0
- package/skill/scripts/lib/design-parser.mjs +10 -783
- package/skill/scripts/live/manual-apply-chunk.mjs +151 -0
- package/skill/scripts/live/manual-apply-compact.mjs +122 -0
- package/skill/scripts/live/manual-apply-evidence.mjs +115 -0
- package/skill/scripts/live/manual-apply-log.mjs +179 -0
- package/skill/scripts/live/manual-apply-transaction.mjs +122 -0
- package/skill/scripts/live/manual-apply.mjs +65 -640
- package/skill/scripts/live/svelte-component-accept.mjs +248 -0
- package/skill/scripts/live/svelte-component-css.mjs +230 -0
- package/skill/scripts/live/svelte-component-markup.mjs +104 -0
- package/skill/scripts/live/svelte-component-scaffold.mjs +118 -0
- package/skill/scripts/live/svelte-component-session.mjs +158 -0
- package/skill/scripts/live/svelte-component.mjs +32 -792
- package/skill/scripts/live-accept-mutate.mjs +152 -0
- package/skill/scripts/live-accept-parse.mjs +370 -0
- package/skill/scripts/live-accept-scrub.mjs +59 -0
- package/skill/scripts/live-accept-search.mjs +58 -0
- package/skill/scripts/live-accept.mjs +15 -631
- package/skill/scripts/live-commit-manual-edits-paths.mjs +27 -0
- package/skill/scripts/live-commit-manual-edits-repair.mjs +166 -0
- package/skill/scripts/live-commit-manual-edits-rollback.mjs +231 -0
- package/skill/scripts/live-commit-manual-edits-utils.mjs +167 -0
- package/skill/scripts/live-commit-manual-edits-verify.mjs +356 -0
- package/skill/scripts/live-commit-manual-edits.mjs +24 -881
- package/skill/scripts/live-copy-edit-agent.mjs +16 -551
- package/skill/scripts/live-copy-edit-compact.mjs +137 -0
- package/skill/scripts/live-copy-edit-post-apply.mjs +175 -0
- package/skill/scripts/live-copy-edit-runners.mjs +297 -0
- package/skill/scripts/live-inject-csp.mjs +132 -0
- package/skill/scripts/live-inject-files.mjs +229 -0
- package/skill/scripts/live-inject-tag.mjs +114 -0
- package/skill/scripts/live-inject.mjs +13 -440
- package/skill/scripts/live-server-poll.mjs +232 -0
- package/skill/scripts/live-server-routes.mjs +449 -0
- package/skill/scripts/live-server-state.mjs +361 -0
- package/skill/scripts/live-server.mjs +10 -946
- package/skill/scripts/live-wrap-css.mjs +132 -0
- package/skill/scripts/live-wrap-manual-edit.mjs +153 -0
- package/skill/scripts/live-wrap-search.mjs +247 -0
- package/skill/scripts/live-wrap.mjs +21 -484
- package/skill/scripts/palette-seeds.mjs +401 -0
- package/skill/scripts/palette.mjs +1 -395
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { normalizeIgnoreValue, normalizeIgnoreRule, ignoreValueMatches, normalizeIgnoreValueEntries } from './hook-lib-ignore.mjs';
|
|
3
|
+
import { ensureSession, ensureFile } from './hook-lib-cache.mjs';
|
|
4
|
+
|
|
5
|
+
// Glob → RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
|
|
6
|
+
function globToRegex(glob) {
|
|
7
|
+
let re = '^';
|
|
8
|
+
let i = 0;
|
|
9
|
+
while (i < glob.length) {
|
|
10
|
+
const c = glob[i];
|
|
11
|
+
if (c === '*') {
|
|
12
|
+
if (glob[i + 1] === '*') {
|
|
13
|
+
re += '.*';
|
|
14
|
+
i += 2;
|
|
15
|
+
if (glob[i] === '/') i += 1;
|
|
16
|
+
} else {
|
|
17
|
+
re += '[^/]*';
|
|
18
|
+
i += 1;
|
|
19
|
+
}
|
|
20
|
+
} else if (c === '?') {
|
|
21
|
+
re += '[^/]';
|
|
22
|
+
i += 1;
|
|
23
|
+
} else if (c === '{') {
|
|
24
|
+
const end = glob.indexOf('}', i);
|
|
25
|
+
if (end === -1) { re += '\\{'; i += 1; continue; }
|
|
26
|
+
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
|
|
27
|
+
re += `(?:${parts.join('|')})`;
|
|
28
|
+
i = end + 1;
|
|
29
|
+
} else if (/[.+^$()|[\]\\]/.test(c)) {
|
|
30
|
+
re += `\\${c}`;
|
|
31
|
+
i += 1;
|
|
32
|
+
} else {
|
|
33
|
+
re += c;
|
|
34
|
+
i += 1;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
re += '$';
|
|
38
|
+
return new RegExp(re);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function matchesAnyGlob(filePath, globs) {
|
|
42
|
+
if (!Array.isArray(globs) || globs.length === 0) return false;
|
|
43
|
+
const normalized = filePath.split(path.sep).join('/');
|
|
44
|
+
for (const glob of globs) {
|
|
45
|
+
try {
|
|
46
|
+
const re = globToRegex(String(glob));
|
|
47
|
+
if (re.test(normalized)) return true;
|
|
48
|
+
// Match against basename too for convenience: `*.generated.tsx` should
|
|
49
|
+
// catch `src/foo.generated.tsx` without requiring `**/`.
|
|
50
|
+
const base = normalized.split('/').pop();
|
|
51
|
+
if (re.test(base)) return true;
|
|
52
|
+
} catch {
|
|
53
|
+
/* malformed glob, skip */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function filterFindings(findings, _content, _ext, config) {
|
|
60
|
+
if (!Array.isArray(findings) || findings.length === 0) return [];
|
|
61
|
+
const ignoreRules = new Set((config.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
|
|
62
|
+
const ignoreValues = normalizeIgnoreValueEntries(config.ignoreValues || []);
|
|
63
|
+
return findings.filter((f) => {
|
|
64
|
+
if (!f || typeof f !== 'object') return false;
|
|
65
|
+
if (ignoreRules.has(normalizeIgnoreRule(f.antipattern))) return false;
|
|
66
|
+
if (isIgnoredFindingValue(f, ignoreValues)) return false;
|
|
67
|
+
return true;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isIgnoredFindingValue(finding, ignoreValues) {
|
|
72
|
+
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
|
|
73
|
+
const rule = normalizeIgnoreRule(finding.antipattern);
|
|
74
|
+
if (!rule) return false;
|
|
75
|
+
// File-scoped wildcards suppress rules with no extractable value, such as side-tab.
|
|
76
|
+
const value = extractFindingIgnoreValue(finding);
|
|
77
|
+
return ignoreValues.some((entry) => {
|
|
78
|
+
if (entry.rule !== rule) return false;
|
|
79
|
+
const wildcardValue = entry.value === '*';
|
|
80
|
+
if (!wildcardValue && (!value || !ignoreValueMatches(rule, entry.value, value))) return false;
|
|
81
|
+
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
|
|
82
|
+
return findingMatchesScopedIgnoreFile(finding, entry.files);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function findingMatchesScopedIgnoreFile(finding, globs) {
|
|
87
|
+
const filePath = String(finding?.file || '').trim();
|
|
88
|
+
if (!filePath) return false;
|
|
89
|
+
if (matchesAnyGlob(filePath, globs)) return true;
|
|
90
|
+
|
|
91
|
+
const normalized = filePath.split(path.sep).join('/');
|
|
92
|
+
const parts = normalized.split('/').filter(Boolean);
|
|
93
|
+
for (let i = 0; i < parts.length; i++) {
|
|
94
|
+
const suffix = parts.slice(i).join('/');
|
|
95
|
+
if (matchesAnyGlob(suffix, globs)) return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function extractFindingIgnoreValue(finding) {
|
|
101
|
+
if (!finding || typeof finding !== 'object') return '';
|
|
102
|
+
const rule = normalizeIgnoreRule(finding.antipattern);
|
|
103
|
+
const directValueRules = new Set([
|
|
104
|
+
'overused-font',
|
|
105
|
+
'bounce-easing',
|
|
106
|
+
'design-system-font',
|
|
107
|
+
'design-system-color',
|
|
108
|
+
'design-system-radius',
|
|
109
|
+
]);
|
|
110
|
+
if (!directValueRules.has(rule)) return '';
|
|
111
|
+
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
|
|
115
|
+
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
|
|
116
|
+
if (direct) return direct;
|
|
117
|
+
|
|
118
|
+
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
|
|
119
|
+
for (const text of candidates) {
|
|
120
|
+
if (rule === 'bounce-easing') {
|
|
121
|
+
const motion = extractMotionIgnoreValue(text);
|
|
122
|
+
if (motion) return motion;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
|
|
127
|
+
if (primary) return cleanIgnoreValueDisplay(primary[1]);
|
|
128
|
+
|
|
129
|
+
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
|
|
130
|
+
if (family) return cleanIgnoreValueDisplay(family[1]);
|
|
131
|
+
|
|
132
|
+
const google = text.match(/[?&]family=([^&:;\n]+)/i);
|
|
133
|
+
if (google) {
|
|
134
|
+
try {
|
|
135
|
+
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
|
|
136
|
+
} catch {
|
|
137
|
+
return cleanIgnoreValueDisplay(google[1]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return '';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function extractMotionIgnoreValue(text) {
|
|
146
|
+
const tailwind = text.match(/\banimate-bounce\b/i);
|
|
147
|
+
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
|
|
148
|
+
|
|
149
|
+
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
|
|
150
|
+
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
|
|
151
|
+
|
|
152
|
+
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
|
|
153
|
+
if (animation) {
|
|
154
|
+
const token = animation[1]
|
|
155
|
+
.split(/[,\s]+/)
|
|
156
|
+
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
|
157
|
+
if (token) return cleanIgnoreValueDisplay(token);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return '';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function cleanIgnoreValueDisplay(value) {
|
|
164
|
+
return String(value || '')
|
|
165
|
+
.trim()
|
|
166
|
+
.replace(/^["']|["']$/g, '')
|
|
167
|
+
.replace(/\+/g, ' ')
|
|
168
|
+
.replace(/\s+/g, ' ');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function dedupeAgainstCache(findings, cache, sessionId, filePath) {
|
|
172
|
+
if (!Array.isArray(findings) || findings.length === 0) return [];
|
|
173
|
+
const fileEntry = ensureFile(cache, sessionId, filePath);
|
|
174
|
+
const known = new Set(fileEntry.findings || []);
|
|
175
|
+
const fresh = [];
|
|
176
|
+
for (const f of findings) {
|
|
177
|
+
const key = findingCacheKey(f);
|
|
178
|
+
if (known.has(key)) continue;
|
|
179
|
+
known.add(key);
|
|
180
|
+
fresh.push(f);
|
|
181
|
+
}
|
|
182
|
+
return fresh;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function rememberFindings(cache, sessionId, filePath, findings) {
|
|
186
|
+
const fileEntry = ensureFile(cache, sessionId, filePath);
|
|
187
|
+
const known = new Set(fileEntry.findings || []);
|
|
188
|
+
for (const f of findings) known.add(findingCacheKey(f));
|
|
189
|
+
fileEntry.findings = Array.from(known);
|
|
190
|
+
ensureSession(cache, sessionId).updatedAt = Date.now();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function findingCacheKey(finding) {
|
|
194
|
+
const line = finding?.line || 0;
|
|
195
|
+
const value = extractFindingIgnoreValue(finding);
|
|
196
|
+
if (line > 0 && value) return `${finding.antipattern}:${line}:${value}`;
|
|
197
|
+
if (line > 0) return `${finding.antipattern}:${line}`;
|
|
198
|
+
if (value) return `${finding.antipattern}:0:${value}`;
|
|
199
|
+
const snippet = String(finding?.snippet || '').trim().slice(0, 80);
|
|
200
|
+
return snippet ? `${finding.antipattern}:0:${snippet}` : `${finding.antipattern}:0`;
|
|
201
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { uniqueStrings } from './hook-lib-config.mjs';
|
|
2
|
+
|
|
3
|
+
export function normalizeIgnoreValue(value) {
|
|
4
|
+
return String(value || '')
|
|
5
|
+
.trim()
|
|
6
|
+
.replace(/^["']|["']$/g, '')
|
|
7
|
+
.replace(/\+/g, ' ')
|
|
8
|
+
.replace(/\s+/g, ' ')
|
|
9
|
+
.toLowerCase();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function normalizeIgnoreRule(rule) {
|
|
13
|
+
return String(rule || '').trim().toLowerCase();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function colorIgnoreKey(value) {
|
|
17
|
+
const color = parseIgnoreColor(value);
|
|
18
|
+
if (!color) return '';
|
|
19
|
+
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseIgnoreColor(value) {
|
|
23
|
+
const text = String(value || '').trim().toLowerCase();
|
|
24
|
+
if (!text) return null;
|
|
25
|
+
|
|
26
|
+
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
|
|
27
|
+
if (hex) return parseHexIgnoreColor(hex[1]);
|
|
28
|
+
|
|
29
|
+
const rgb = text.match(/^rgba?\((.*)\)$/i);
|
|
30
|
+
if (rgb) {
|
|
31
|
+
const parts = splitColorArgs(rgb[1]);
|
|
32
|
+
if (parts.length < 3 || parts.length > 4) return null;
|
|
33
|
+
const r = parseRgbChannel(parts[0]);
|
|
34
|
+
const g = parseRgbChannel(parts[1]);
|
|
35
|
+
const b = parseRgbChannel(parts[2]);
|
|
36
|
+
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
|
37
|
+
if ([r, g, b, a].some((v) => v === null)) return null;
|
|
38
|
+
return { r, g, b, a };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const hsl = text.match(/^hsla?\((.*)\)$/i);
|
|
42
|
+
if (hsl) {
|
|
43
|
+
const parts = splitColorArgs(hsl[1]);
|
|
44
|
+
if (parts.length < 3 || parts.length > 4) return null;
|
|
45
|
+
const h = parseHueChannel(parts[0]);
|
|
46
|
+
const s = parsePercentChannel(parts[1]);
|
|
47
|
+
const l = parsePercentChannel(parts[2]);
|
|
48
|
+
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
|
49
|
+
if ([h, s, l, a].some((v) => v === null)) return null;
|
|
50
|
+
return hslToRgb(h, s, l, a);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseHexIgnoreColor(hex) {
|
|
57
|
+
if (hex.length === 3 || hex.length === 4) {
|
|
58
|
+
const r = parseInt(hex[0] + hex[0], 16);
|
|
59
|
+
const g = parseInt(hex[1] + hex[1], 16);
|
|
60
|
+
const b = parseInt(hex[2] + hex[2], 16);
|
|
61
|
+
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
|
|
62
|
+
return { r, g, b, a };
|
|
63
|
+
}
|
|
64
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
65
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
66
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
67
|
+
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
|
|
68
|
+
return { r, g, b, a };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function splitColorArgs(body) {
|
|
72
|
+
const text = String(body || '').trim();
|
|
73
|
+
if (!text) return [];
|
|
74
|
+
if (text.includes(',')) {
|
|
75
|
+
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
|
|
76
|
+
const last = parts[parts.length - 1];
|
|
77
|
+
if (last?.includes('/')) {
|
|
78
|
+
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
|
|
79
|
+
return [...parts.slice(0, -1), ...split];
|
|
80
|
+
}
|
|
81
|
+
return parts;
|
|
82
|
+
}
|
|
83
|
+
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseRgbChannel(raw) {
|
|
87
|
+
const text = String(raw || '').trim();
|
|
88
|
+
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
|
89
|
+
if (!match) return null;
|
|
90
|
+
const value = Number.parseFloat(match[1]);
|
|
91
|
+
if (!Number.isFinite(value)) return null;
|
|
92
|
+
const scaled = match[2] ? value * 2.55 : value;
|
|
93
|
+
if (scaled < 0 || scaled > 255) return null;
|
|
94
|
+
return Math.round(scaled);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseAlphaChannel(raw) {
|
|
98
|
+
const text = String(raw || '').trim();
|
|
99
|
+
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
|
100
|
+
if (!match) return null;
|
|
101
|
+
const value = Number.parseFloat(match[1]);
|
|
102
|
+
if (!Number.isFinite(value)) return null;
|
|
103
|
+
const alpha = match[2] ? value / 100 : value;
|
|
104
|
+
return alpha >= 0 && alpha <= 1 ? alpha : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseHueChannel(raw) {
|
|
108
|
+
const text = String(raw || '').trim();
|
|
109
|
+
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
|
|
110
|
+
if (!match) return null;
|
|
111
|
+
const value = Number.parseFloat(match[1]);
|
|
112
|
+
if (!Number.isFinite(value)) return null;
|
|
113
|
+
const unit = match[2] || 'deg';
|
|
114
|
+
if (unit === 'turn') return value * 360;
|
|
115
|
+
if (unit === 'rad') return value * (180 / Math.PI);
|
|
116
|
+
if (unit === 'grad') return value * 0.9;
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parsePercentChannel(raw) {
|
|
121
|
+
const text = String(raw || '').trim();
|
|
122
|
+
const match = text.match(/^(-?\d*\.?\d+)%$/);
|
|
123
|
+
if (!match) return null;
|
|
124
|
+
const value = Number.parseFloat(match[1]);
|
|
125
|
+
if (!Number.isFinite(value)) return null;
|
|
126
|
+
return value >= 0 && value <= 100 ? value / 100 : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function hslToRgb(hue, saturation, lightness, alpha) {
|
|
130
|
+
const h = (((hue % 360) + 360) % 360) / 360;
|
|
131
|
+
if (saturation === 0) {
|
|
132
|
+
const gray = clampByte(Math.round(lightness * 255));
|
|
133
|
+
return { r: gray, g: gray, b: gray, a: alpha };
|
|
134
|
+
}
|
|
135
|
+
const q = lightness < 0.5
|
|
136
|
+
? lightness * (1 + saturation)
|
|
137
|
+
: lightness + saturation - lightness * saturation;
|
|
138
|
+
const p = 2 * lightness - q;
|
|
139
|
+
const toRgb = (t) => {
|
|
140
|
+
let channel = t;
|
|
141
|
+
if (channel < 0) channel += 1;
|
|
142
|
+
if (channel > 1) channel -= 1;
|
|
143
|
+
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
|
|
144
|
+
if (channel < 1 / 2) return q;
|
|
145
|
+
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
|
|
146
|
+
return p;
|
|
147
|
+
};
|
|
148
|
+
return {
|
|
149
|
+
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
|
|
150
|
+
g: clampByte(Math.round(toRgb(h) * 255)),
|
|
151
|
+
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
|
|
152
|
+
a: alpha,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function clampByte(value) {
|
|
157
|
+
return Math.min(255, Math.max(0, value));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function ignoreValueMatches(rule, entryValue, findingValue) {
|
|
161
|
+
if (entryValue === findingValue) return true;
|
|
162
|
+
if (rule !== 'design-system-color') return false;
|
|
163
|
+
const entryColor = colorIgnoreKey(entryValue);
|
|
164
|
+
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function normalizeIgnoreValueEntries(entries) {
|
|
168
|
+
if (!Array.isArray(entries)) return [];
|
|
169
|
+
const out = [];
|
|
170
|
+
for (const entry of entries) {
|
|
171
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
172
|
+
const rule = normalizeIgnoreRule(entry.rule);
|
|
173
|
+
const value = normalizeIgnoreValue(entry.value);
|
|
174
|
+
if (!rule || !value) continue;
|
|
175
|
+
const normalized = { rule, value };
|
|
176
|
+
const files = uniqueStrings([
|
|
177
|
+
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
|
|
178
|
+
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
|
|
179
|
+
]);
|
|
180
|
+
if (files.length > 0) normalized.files = files;
|
|
181
|
+
if (typeof entry.reason === 'string' && entry.reason.trim()) {
|
|
182
|
+
normalized.reason = entry.reason.trim();
|
|
183
|
+
}
|
|
184
|
+
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
|
|
185
|
+
normalized.createdAt = entry.createdAt.trim();
|
|
186
|
+
}
|
|
187
|
+
out.push(normalized);
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function mergeIgnoreValues(existing, incoming) {
|
|
193
|
+
const map = new Map();
|
|
194
|
+
for (const entry of normalizeIgnoreValueEntries(existing)) {
|
|
195
|
+
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
|
196
|
+
}
|
|
197
|
+
for (const entry of normalizeIgnoreValueEntries(incoming)) {
|
|
198
|
+
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
|
199
|
+
}
|
|
200
|
+
return Array.from(map.values());
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function ignoreValueFilesKey(files) {
|
|
204
|
+
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
|
|
205
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { MONODESIGN_COMMAND } from './lib/provider.mjs';
|
|
3
|
+
import { ENVELOPE_PREFIX, ACK_EXTS, DEFAULT_CONFIG, matchConfiguredExtension } from './hook-lib-config.mjs';
|
|
4
|
+
import { normalizeIgnoreRule } from './hook-lib-ignore.mjs';
|
|
5
|
+
import { extractFindingIgnoreValue, extractFindingIgnoreValueRaw } from './hook-lib-findings.mjs';
|
|
6
|
+
|
|
7
|
+
export function renderTemplate(findings, filePath, config, opts = {}) {
|
|
8
|
+
if (!Array.isArray(findings) || findings.length === 0) return '';
|
|
9
|
+
const limits = config?.limits || DEFAULT_CONFIG.limits;
|
|
10
|
+
const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
|
|
11
|
+
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
|
|
12
|
+
|
|
13
|
+
const cwd = opts.cwd || process.cwd();
|
|
14
|
+
const display = relativize(filePath, cwd);
|
|
15
|
+
const total = findings.length;
|
|
16
|
+
const shown = findings.slice(0, cap);
|
|
17
|
+
const remaining = total - shown.length;
|
|
18
|
+
|
|
19
|
+
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
|
|
20
|
+
const lines = shown.map((f) => formatFindingLine(f));
|
|
21
|
+
const more = remaining > 0
|
|
22
|
+
? `... and ${remaining} more (see ${MONODESIGN_COMMAND} audit).`
|
|
23
|
+
: null;
|
|
24
|
+
const footer = directiveFooter(display);
|
|
25
|
+
|
|
26
|
+
const blocks = [header, ...lines];
|
|
27
|
+
if (more) blocks.push(more);
|
|
28
|
+
blocks.push('');
|
|
29
|
+
blocks.push(footer);
|
|
30
|
+
let text = blocks.join('\n');
|
|
31
|
+
|
|
32
|
+
if (text.length > maxChars) {
|
|
33
|
+
text = clampToBudget(header, lines, more, footer, maxChars);
|
|
34
|
+
}
|
|
35
|
+
return text;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function renderGroupedTemplate(groups, config, opts = {}) {
|
|
39
|
+
const realGroups = groups.filter((group) => Array.isArray(group.findings) && group.findings.length > 0);
|
|
40
|
+
if (realGroups.length === 0) return '';
|
|
41
|
+
if (realGroups.length === 1) {
|
|
42
|
+
const [group] = realGroups;
|
|
43
|
+
return renderTemplate(group.findings, group.filePath, config, opts);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const limits = config?.limits || DEFAULT_CONFIG.limits;
|
|
47
|
+
const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
|
|
48
|
+
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
|
|
49
|
+
const cwd = opts.cwd || process.cwd();
|
|
50
|
+
const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
|
|
51
|
+
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
|
|
52
|
+
const lines = [];
|
|
53
|
+
let shownCount = 0;
|
|
54
|
+
|
|
55
|
+
for (const group of realGroups) {
|
|
56
|
+
const display = relativize(group.filePath, cwd);
|
|
57
|
+
lines.push(`${display} (${group.findings.length} issue(s)):`);
|
|
58
|
+
const remainingCap = Math.max(0, cap - shownCount);
|
|
59
|
+
const shown = group.findings.slice(0, remainingCap);
|
|
60
|
+
for (const finding of shown) {
|
|
61
|
+
lines.push(formatFindingLine(finding));
|
|
62
|
+
}
|
|
63
|
+
shownCount += shown.length;
|
|
64
|
+
const hidden = group.findings.length - shown.length;
|
|
65
|
+
if (hidden > 0) {
|
|
66
|
+
lines.push(`- ... ${hidden} more in ${display} (see ${MONODESIGN_COMMAND} audit).`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const footer = directiveFooter('the affected files', { grouped: true });
|
|
71
|
+
let text = [header, ...lines, '', footer].join('\n');
|
|
72
|
+
if (text.length > maxChars) {
|
|
73
|
+
text = clampGroupedToBudget(header, lines, footer, maxChars);
|
|
74
|
+
}
|
|
75
|
+
return text;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function clampGroupedToBudget(header, lines, footer, maxChars) {
|
|
79
|
+
const assemble = (linesArr, omitted) => [
|
|
80
|
+
header,
|
|
81
|
+
...linesArr,
|
|
82
|
+
...(omitted ? [`... and more (see ${MONODESIGN_COMMAND} audit).`] : []),
|
|
83
|
+
'',
|
|
84
|
+
footer,
|
|
85
|
+
].join('\n');
|
|
86
|
+
|
|
87
|
+
const working = lines.slice();
|
|
88
|
+
let omitted = false;
|
|
89
|
+
let assembled = assemble(working, omitted);
|
|
90
|
+
while (assembled.length > maxChars && working.length > 1) {
|
|
91
|
+
working.pop();
|
|
92
|
+
omitted = true;
|
|
93
|
+
assembled = assemble(working, omitted);
|
|
94
|
+
}
|
|
95
|
+
if (assembled.length > maxChars) {
|
|
96
|
+
assembled = `${assembled.slice(0, maxChars - 1)}…`;
|
|
97
|
+
}
|
|
98
|
+
return assembled;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function clampToBudget(header, lines, more, footer, maxChars) {
|
|
102
|
+
const assemble = (linesArr, moreText) => {
|
|
103
|
+
const blocks = [header, ...linesArr];
|
|
104
|
+
if (moreText) blocks.push(moreText);
|
|
105
|
+
blocks.push('');
|
|
106
|
+
blocks.push(footer);
|
|
107
|
+
return blocks.join('\n');
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const working = lines.slice();
|
|
111
|
+
let moreText = more;
|
|
112
|
+
let assembled = assemble(working, moreText);
|
|
113
|
+
while (assembled.length > maxChars && working.length > 1) {
|
|
114
|
+
working.pop();
|
|
115
|
+
moreText = `... and more (see ${MONODESIGN_COMMAND} audit).`;
|
|
116
|
+
assembled = assemble(working, moreText);
|
|
117
|
+
}
|
|
118
|
+
if (assembled.length > maxChars) {
|
|
119
|
+
assembled = `${assembled.slice(0, maxChars - 1)}…`;
|
|
120
|
+
}
|
|
121
|
+
return assembled;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function formatFindingLine(f) {
|
|
125
|
+
const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-';
|
|
126
|
+
const desc = (f.description || '').trim();
|
|
127
|
+
const name = (f.name || '').trim();
|
|
128
|
+
// Description from the registry already ends in punctuation; join with a
|
|
129
|
+
// single space. `name` may have a trailing period already, keep it clean.
|
|
130
|
+
const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : '';
|
|
131
|
+
const ignoreCommand = formatFindingIgnoreCommand(f);
|
|
132
|
+
const ignoreSegment = ignoreCommand
|
|
133
|
+
? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.`
|
|
134
|
+
: '';
|
|
135
|
+
return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function formatFindingIgnoreCommand(finding) {
|
|
139
|
+
if (!finding || typeof finding !== 'object') return '';
|
|
140
|
+
const rule = normalizeIgnoreRule(finding.antipattern);
|
|
141
|
+
if (!rule) return '';
|
|
142
|
+
const normalizedValue = extractFindingIgnoreValue(finding);
|
|
143
|
+
if (!normalizedValue) return '';
|
|
144
|
+
const value = extractFindingIgnoreValueRaw(finding);
|
|
145
|
+
const valueArg = quoteCommandArg(value);
|
|
146
|
+
const reason = quoteCommandArg(`User confirmed ${value} is intentional`);
|
|
147
|
+
return `${MONODESIGN_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function quoteCommandArg(value) {
|
|
151
|
+
const text = String(value || '').trim();
|
|
152
|
+
if (/^[A-Za-z0-9._:-]+$/.test(text)) return text;
|
|
153
|
+
return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function relativize(filePath, cwd) {
|
|
157
|
+
try {
|
|
158
|
+
const rel = path.relative(cwd, filePath).split(path.sep).join('/');
|
|
159
|
+
if (!rel || rel.startsWith('..')) return filePath;
|
|
160
|
+
return rel.split(path.sep).join('/');
|
|
161
|
+
} catch {
|
|
162
|
+
return filePath;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
167
|
+
// Nudge/steer messages for the no-silent-fires policy.
|
|
168
|
+
//
|
|
169
|
+
// The hook is designed to be a conversational presence: every fire that
|
|
170
|
+
// actually scans a file emits a developer-role message into the model's
|
|
171
|
+
// next turn. Three states map to three templates:
|
|
172
|
+
//
|
|
173
|
+
// 1. **Fresh findings** → `renderTemplate` (existing, imperative).
|
|
174
|
+
// 2. **Pending findings** → `renderPendingAck` (re-nudge for issues the
|
|
175
|
+
// model was already told about in this
|
|
176
|
+
// session but hasn't fixed yet).
|
|
177
|
+
// 3. **Truly clean** → `renderCleanAck` (short positive nudge that
|
|
178
|
+
// keeps the design discipline in context).
|
|
179
|
+
//
|
|
180
|
+
// All three are short (≤ ~40 tokens each) so the cumulative cost stays
|
|
181
|
+
// bounded across a long active editing session. Users who explicitly want
|
|
182
|
+
// silence-on-clean can set `MONODESIGN_HOOK_QUIET=1` — runHook checks that
|
|
183
|
+
// env before emitting #2 or #3.
|
|
184
|
+
//
|
|
185
|
+
// Why not stay silent on dedup-clean? Earlier versions did. The model
|
|
186
|
+
// quickly forgets the prior reminder once tool output scrolls past it, so
|
|
187
|
+
// re-nudging on the same file with a short "still pending" line keeps the
|
|
188
|
+
// pressure on. The wording deliberately points back to "earlier this
|
|
189
|
+
// session" so the model knows it's a re-mind, not a new finding.
|
|
190
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the monodesign skill guidance.';
|
|
193
|
+
|
|
194
|
+
export function renderCleanAck(filePath, opts = {}) {
|
|
195
|
+
const cwd = opts.cwd || process.cwd();
|
|
196
|
+
const display = relativize(filePath, cwd);
|
|
197
|
+
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function renderPendingAck(filePath, knownFindings, opts = {}) {
|
|
201
|
+
const cwd = opts.cwd || process.cwd();
|
|
202
|
+
const display = relativize(filePath, cwd);
|
|
203
|
+
const count = knownFindings.length;
|
|
204
|
+
// `knownFindings` here are the cache strings like "side-tab:3".
|
|
205
|
+
const sample = knownFindings.slice(0, 3).join(', ');
|
|
206
|
+
const more = count > 3 ? `, +${count - 3} more` : '';
|
|
207
|
+
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.`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function shouldEmitAckForFile(filePath, config = null) {
|
|
211
|
+
if (ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase())) return true;
|
|
212
|
+
// Configured html-engine extensions are declared UI markup, so they get the
|
|
213
|
+
// clean/pending acks; text-engine ones stay quiet like plain .ts/.js.
|
|
214
|
+
const configured = matchConfiguredExtension(filePath, config?.extensions);
|
|
215
|
+
return Boolean(configured && configured.engine === 'html');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function designSystemOptions(config, detector, projectCwd) {
|
|
219
|
+
if (config?.designSystem?.enabled === false) return {};
|
|
220
|
+
if (!detector || typeof detector.loadDesignSystemForCwd !== 'function') return {};
|
|
221
|
+
try {
|
|
222
|
+
const designSystem = detector.loadDesignSystemForCwd(projectCwd);
|
|
223
|
+
return designSystem ? { designSystem } : {};
|
|
224
|
+
} catch {
|
|
225
|
+
return {};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function appendDesignSystemNote(text, scanOptions) {
|
|
230
|
+
if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
|
|
231
|
+
return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .monodesign/design.json. Run ${MONODESIGN_COMMAND} document to refresh the design-system sidecar.`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// The directive footer is the part of the hook output that steers model
|
|
235
|
+
// behavior. Three intentional moves:
|
|
236
|
+
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
|
|
237
|
+
// revising..." which the model treats as a soft suggestion it can
|
|
238
|
+
// override when the user asked for any kind of throwaway / demo UI.
|
|
239
|
+
// 2. **Explicit judgment clause.** Without it, the model will try to
|
|
240
|
+
// "fix" intentional motion, bad fixtures, anti-pattern examples in
|
|
241
|
+
// docs, or test cases. Naming the judgment inline beats hoping the
|
|
242
|
+
// model infers it from context.
|
|
243
|
+
// 3. **Acknowledgement instruction.** Hook output is injected as
|
|
244
|
+
// developer-role context, not a chat turn, so the user never sees the
|
|
245
|
+
// raw envelope. Asking the model to surface the resolution in its
|
|
246
|
+
// reply is the cheapest way to make the feedback loop visible.
|
|
247
|
+
function directiveFooter(display, opts = {}) {
|
|
248
|
+
const ignoreFileCommand = `${MONODESIGN_COMMAND} hooks ignore-file ${quoteCommandArg(display)}`;
|
|
249
|
+
const fileIgnoreGuidance = opts.grouped
|
|
250
|
+
? `run \`${MONODESIGN_COMMAND} hooks ignore-file <path>\` for the specific file`
|
|
251
|
+
: `run \`${ignoreFileCommand}\``;
|
|
252
|
+
return [
|
|
253
|
+
'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.',
|
|
254
|
+
'',
|
|
255
|
+
'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.',
|
|
256
|
+
'',
|
|
257
|
+
`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 \`monodesign-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 \`${MONODESIGN_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 \`${MONODESIGN_COMMAND} hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`${MONODESIGN_COMMAND} hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run ${MONODESIGN_COMMAND} audit for the full pass.`,
|
|
258
|
+
].join('\n');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
|
262
|
+
if (harness === 'cursor') {
|
|
263
|
+
return JSON.stringify({ additional_context: text });
|
|
264
|
+
}
|
|
265
|
+
// GitHub Copilot's postToolUse hook injects context via a top-level
|
|
266
|
+
// `additionalContext` string (alongside an optional `modifiedResult`).
|
|
267
|
+
if (harness === 'github') {
|
|
268
|
+
return JSON.stringify({ additionalContext: text });
|
|
269
|
+
}
|
|
270
|
+
return JSON.stringify({
|
|
271
|
+
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
|
272
|
+
});
|
|
273
|
+
}
|