@monoes/monodesign 1.2.20 → 1.2.23
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/cli/main.mjs +21 -1
- 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/detect-antipatterns-browser.js +28 -5
- package/cli/engine/detect-antipatterns.mjs +1 -0
- 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/engine/engines/static-html/detect-html.mjs +6 -1
- package/cli/engine/findings-group.mjs +94 -0
- package/cli/engine/fix/index.mjs +1 -26
- package/cli/engine/fix/unified-diff.mjs +107 -0
- package/cli/engine/rules/checks-background.mjs +17 -3
- package/cli/engine/rules/checks-element.mjs +2 -0
- package/cli/engine/rules/checks-pure.mjs +9 -2
- 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
|
@@ -176,7 +176,12 @@ async function detectHtml(filePath, options = {}) {
|
|
|
176
176
|
const tag = el.tagName.toLowerCase();
|
|
177
177
|
const style = window.getComputedStyle(el);
|
|
178
178
|
for (const f of runElementCheck(rule.id, () => rule.run(el, tag, style, window, customPropMap))) {
|
|
179
|
-
|
|
179
|
+
// Contrast the static cascade can't resolve (translucent colours,
|
|
180
|
+
// overlays, gradients) is not a definite failure: callers opt in (#424).
|
|
181
|
+
if (f.unverified && !options?.includeUnverified) continue;
|
|
182
|
+
const item = finding(f.id, filePath, f.snippet);
|
|
183
|
+
if (f.unverified) item.unverified = true;
|
|
184
|
+
findings.push(item);
|
|
180
185
|
}
|
|
181
186
|
}
|
|
182
187
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Collapse repeated findings into one entry per (rule, snippet). A single
|
|
2
|
+
// dark-theme token can fail contrast on hundreds of elements; reporting each
|
|
3
|
+
// occurrence floods agents and humans with the same line (#424).
|
|
4
|
+
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
const SEVERITY_RANK = { error: 3, warning: 2, advisory: 1, info: 0 };
|
|
8
|
+
|
|
9
|
+
function severityRank(severity) {
|
|
10
|
+
return SEVERITY_RANK[severity] ?? SEVERITY_RANK.warning;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizeSnippet(snippet) {
|
|
14
|
+
return String(snippet ?? '').replace(/\s+/g, ' ').trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Group findings by (antipattern, normalized snippet).
|
|
19
|
+
* Returns groups sorted by severity, then occurrence count (both descending),
|
|
20
|
+
* each carrying the first `maxLocations` locations as examples.
|
|
21
|
+
*/
|
|
22
|
+
function groupFindings(findings, { maxLocations = 3 } = {}) {
|
|
23
|
+
const groups = new Map();
|
|
24
|
+
for (const f of findings) {
|
|
25
|
+
const snippet = normalizeSnippet(f.snippet);
|
|
26
|
+
const key = `${f.antipattern}\u0000${snippet}`;
|
|
27
|
+
let g = groups.get(key);
|
|
28
|
+
if (!g) {
|
|
29
|
+
g = {
|
|
30
|
+
antipattern: f.antipattern,
|
|
31
|
+
name: f.name,
|
|
32
|
+
severity: f.severity,
|
|
33
|
+
description: f.description,
|
|
34
|
+
snippet,
|
|
35
|
+
count: 0,
|
|
36
|
+
files: new Set(),
|
|
37
|
+
seen: new Set(),
|
|
38
|
+
locations: [],
|
|
39
|
+
};
|
|
40
|
+
groups.set(key, g);
|
|
41
|
+
}
|
|
42
|
+
g.count += 1;
|
|
43
|
+
g.files.add(f.file);
|
|
44
|
+
// Static-HTML findings carry no line, so dedupe locations: one entry per
|
|
45
|
+
// distinct file:line rather than the same file repeated.
|
|
46
|
+
const loc = f.line ? { file: f.file, line: f.line } : { file: f.file };
|
|
47
|
+
if (f.importedBy?.length) loc.importedBy = f.importedBy;
|
|
48
|
+
const locKey = `${loc.file}:${loc.line ?? ''}`;
|
|
49
|
+
if (!g.seen.has(locKey) && g.locations.length < maxLocations) {
|
|
50
|
+
g.seen.add(locKey);
|
|
51
|
+
g.locations.push(loc);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Keep a rule's groups together: order rules by severity then total
|
|
55
|
+
// occurrences, and groups within a rule by their own count.
|
|
56
|
+
const ruleTotals = new Map();
|
|
57
|
+
for (const g of groups.values()) ruleTotals.set(g.antipattern, (ruleTotals.get(g.antipattern) ?? 0) + g.count);
|
|
58
|
+
return [...groups.values()]
|
|
59
|
+
.map(({ files, seen, ...g }) => ({ ...g, fileCount: files.size }))
|
|
60
|
+
.sort((a, b) =>
|
|
61
|
+
severityRank(b.severity) - severityRank(a.severity)
|
|
62
|
+
|| ruleTotals.get(b.antipattern) - ruleTotals.get(a.antipattern)
|
|
63
|
+
|| a.antipattern.localeCompare(b.antipattern)
|
|
64
|
+
|| b.count - a.count);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function formatLocation(loc, cwd) {
|
|
68
|
+
const rel = cwd && path.isAbsolute(loc.file) ? path.relative(cwd, loc.file) || loc.file : loc.file;
|
|
69
|
+
const where = loc.line ? `${rel}:${loc.line}` : rel;
|
|
70
|
+
return loc.importedBy?.length ? `${where} (imported by ${loc.importedBy.join(', ')})` : where;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Human-readable grouped report: one line per unique finding. */
|
|
74
|
+
function formatGroupedFindings(findings, cwd = process.cwd()) {
|
|
75
|
+
const groups = groupFindings(findings);
|
|
76
|
+
const out = [];
|
|
77
|
+
let lastRule = null;
|
|
78
|
+
for (const g of groups) {
|
|
79
|
+
if (g.antipattern !== lastRule) {
|
|
80
|
+
out.push(`\n[${g.antipattern}] ${g.name} (${g.severity})`);
|
|
81
|
+
out.push(` → ${g.description}`);
|
|
82
|
+
lastRule = g.antipattern;
|
|
83
|
+
}
|
|
84
|
+
const where = g.locations.map(loc => formatLocation(loc, cwd)).join(', ');
|
|
85
|
+
const more = g.fileCount > g.locations.length ? `, +${g.fileCount - g.locations.length} more file(s)` : '';
|
|
86
|
+
out.push(` ×${g.count} ${g.snippet}`);
|
|
87
|
+
out.push(` at ${where}${more}`);
|
|
88
|
+
}
|
|
89
|
+
const unique = groups.length;
|
|
90
|
+
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found (${unique} unique). Use --no-group to list every occurrence.`);
|
|
91
|
+
return out.join('\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export { groupFindings, formatGroupedFindings, severityRank };
|
package/cli/engine/fix/index.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from '../../lib/monodesign-config.mjs';
|
|
24
24
|
import { HTML_EXTENSIONS, walkDir } from '../node/file-system.mjs';
|
|
25
25
|
import { UNFIXABLE_REASONS, getFixer, fixableRuleIds, FIXERS } from './fixers.mjs';
|
|
26
|
+
import { unifiedDiff } from './unified-diff.mjs';
|
|
26
27
|
|
|
27
28
|
// ---------------------------------------------------------------------------
|
|
28
29
|
// Edit application
|
|
@@ -57,32 +58,6 @@ function writeFileAtomic(filePath, content) {
|
|
|
57
58
|
fs.renameSync(tmp, filePath);
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
// ---------------------------------------------------------------------------
|
|
61
|
-
// Unified diff (minimal single-hunk implementation for --dry-run)
|
|
62
|
-
// ---------------------------------------------------------------------------
|
|
63
|
-
|
|
64
|
-
function unifiedDiff(oldText, newText, label) {
|
|
65
|
-
if (oldText === newText) return '';
|
|
66
|
-
const a = oldText.split('\n');
|
|
67
|
-
const b = newText.split('\n');
|
|
68
|
-
let p = 0;
|
|
69
|
-
while (p < a.length && p < b.length && a[p] === b[p]) p++;
|
|
70
|
-
let s = 0;
|
|
71
|
-
while (s < a.length - p && s < b.length - p && a[a.length - 1 - s] === b[b.length - 1 - s]) s++;
|
|
72
|
-
const aEnd = a.length - s;
|
|
73
|
-
const bEnd = b.length - s;
|
|
74
|
-
const ctxStart = Math.max(0, p - 3);
|
|
75
|
-
const ctxEnd = Math.min(a.length, aEnd + 3);
|
|
76
|
-
const oldLen = ctxEnd - ctxStart;
|
|
77
|
-
const newLen = (p - ctxStart) + (bEnd - p) + (ctxEnd - aEnd);
|
|
78
|
-
const lines = [`--- a/${label}`, `+++ b/${label}`, `@@ -${ctxStart + 1},${oldLen} +${ctxStart + 1},${newLen} @@`];
|
|
79
|
-
for (let i = ctxStart; i < p; i++) lines.push(` ${a[i]}`);
|
|
80
|
-
for (let i = p; i < aEnd; i++) lines.push(`-${a[i]}`);
|
|
81
|
-
for (let i = p; i < bEnd; i++) lines.push(`+${b[i]}`);
|
|
82
|
-
for (let i = aEnd; i < ctxEnd; i++) lines.push(` ${a[i]}`);
|
|
83
|
-
return lines.join('\n');
|
|
84
|
-
}
|
|
85
|
-
|
|
86
61
|
// ---------------------------------------------------------------------------
|
|
87
62
|
// Linked stylesheet resolution (HTML findings whose declaration lives in CSS)
|
|
88
63
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Line-level unified diff for `fix --dry-run`. The old single-hunk version
|
|
2
|
+
// spanned the first to the last changed line, so 30 one-line edits spread
|
|
3
|
+
// across a big file printed nearly the whole file (#424). This uses Myers'
|
|
4
|
+
// O((N+M)D) algorithm — D (edited lines) is small for codemods — and emits
|
|
5
|
+
// one hunk per cluster of changes with 3 lines of context.
|
|
6
|
+
|
|
7
|
+
const CONTEXT = 3;
|
|
8
|
+
// Beyond this many edited lines the trace gets large; fall back to replacing
|
|
9
|
+
// the changed middle wholesale (still trimmed of the shared prefix/suffix).
|
|
10
|
+
const MAX_EDITS = 2000;
|
|
11
|
+
|
|
12
|
+
// Returns an edit script: [{ op: ' ' | '-' | '+', a, b }] where a/b index the
|
|
13
|
+
// old/new line arrays; null when the diff exceeds MAX_EDITS.
|
|
14
|
+
function myersDiff(a, b) {
|
|
15
|
+
const n = a.length;
|
|
16
|
+
const m = b.length;
|
|
17
|
+
const max = n + m;
|
|
18
|
+
const offset = max;
|
|
19
|
+
const v = new Array(2 * max + 2).fill(0);
|
|
20
|
+
const trace = [];
|
|
21
|
+
let found = false;
|
|
22
|
+
for (let d = 0; d <= max && !found; d++) {
|
|
23
|
+
if (d > MAX_EDITS) return null;
|
|
24
|
+
// Only diagonals -d..d can be read back for this step: O(D^2) memory.
|
|
25
|
+
trace.push(v.slice(offset - d, offset + d + 1));
|
|
26
|
+
for (let k = -d; k <= d; k += 2) {
|
|
27
|
+
let x = (k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1]))
|
|
28
|
+
? v[offset + k + 1]
|
|
29
|
+
: v[offset + k - 1] + 1;
|
|
30
|
+
let y = x - k;
|
|
31
|
+
while (x < n && y < m && a[x] === b[y]) { x++; y++; }
|
|
32
|
+
v[offset + k] = x;
|
|
33
|
+
if (x >= n && y >= m) { found = true; break; }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Backtrack through the saved frontiers.
|
|
37
|
+
const script = [];
|
|
38
|
+
let x = n;
|
|
39
|
+
let y = m;
|
|
40
|
+
for (let d = trace.length - 1; d >= 0; d--) {
|
|
41
|
+
const vd = trace[d];
|
|
42
|
+
const at = (kk) => vd[kk + d];
|
|
43
|
+
const k = x - y;
|
|
44
|
+
const prevK = (k === -d || (k !== d && at(k - 1) < at(k + 1))) ? k + 1 : k - 1;
|
|
45
|
+
const prevX = d === 0 ? 0 : at(prevK);
|
|
46
|
+
const prevY = prevX - prevK;
|
|
47
|
+
while (x > prevX && y > prevY) { script.push({ op: ' ', a: --x, b: --y }); }
|
|
48
|
+
if (d > 0) {
|
|
49
|
+
if (x === prevX) script.push({ op: '+', a: x, b: --y });
|
|
50
|
+
else script.push({ op: '-', a: --x, b: y });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return script.reverse();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function unifiedDiff(oldText, newText, label) {
|
|
57
|
+
if (oldText === newText) return '';
|
|
58
|
+
const a = oldText.split('\n');
|
|
59
|
+
const b = newText.split('\n');
|
|
60
|
+
// A trailing newline yields an empty last element, which is not a line.
|
|
61
|
+
if (a.length > 1 && b.length > 1 && a[a.length - 1] === '' && b[b.length - 1] === '') { a.pop(); b.pop(); }
|
|
62
|
+
// Trim the shared prefix/suffix first so Myers only sees the changed middle.
|
|
63
|
+
let p = 0;
|
|
64
|
+
while (p < a.length && p < b.length && a[p] === b[p]) p++;
|
|
65
|
+
let s = 0;
|
|
66
|
+
while (s < a.length - p && s < b.length - p && a[a.length - 1 - s] === b[b.length - 1 - s]) s++;
|
|
67
|
+
const oldMid = a.slice(p, a.length - s);
|
|
68
|
+
const newMid = b.slice(p, b.length - s);
|
|
69
|
+
const middle = myersDiff(oldMid, newMid) ?? [
|
|
70
|
+
...oldMid.map((_, i) => ({ op: '-', a: i, b: 0 })),
|
|
71
|
+
...newMid.map((_, i) => ({ op: '+', a: oldMid.length, b: i })),
|
|
72
|
+
];
|
|
73
|
+
const script = [];
|
|
74
|
+
for (let i = 0; i < p; i++) script.push({ op: ' ', a: i, b: i });
|
|
75
|
+
for (const e of middle) script.push({ op: e.op, a: e.a + p, b: e.b + p });
|
|
76
|
+
for (let i = 0; i < s; i++) script.push({ op: ' ', a: a.length - s + i, b: b.length - s + i });
|
|
77
|
+
|
|
78
|
+
const lines = [`--- a/${label}`, `+++ b/${label}`];
|
|
79
|
+
let i = 0;
|
|
80
|
+
while (i < script.length) {
|
|
81
|
+
while (i < script.length && script[i].op === ' ') i++;
|
|
82
|
+
if (i >= script.length) break;
|
|
83
|
+
const start = Math.max(0, i - CONTEXT);
|
|
84
|
+
// Extend the hunk until a run of more than 2*CONTEXT unchanged lines.
|
|
85
|
+
let end = i;
|
|
86
|
+
let j = i;
|
|
87
|
+
while (j < script.length) {
|
|
88
|
+
if (script[j].op !== ' ') { end = j; j++; continue; }
|
|
89
|
+
let run = 0;
|
|
90
|
+
while (j + run < script.length && script[j + run].op === ' ') run++;
|
|
91
|
+
if (j + run >= script.length || run > 2 * CONTEXT) break;
|
|
92
|
+
j += run;
|
|
93
|
+
}
|
|
94
|
+
const stop = Math.min(script.length, end + 1 + CONTEXT);
|
|
95
|
+
const hunk = script.slice(start, stop);
|
|
96
|
+
const oldStart = hunk.find(e => e.op !== '+')?.a ?? script[start].a;
|
|
97
|
+
const newStart = hunk.find(e => e.op !== '-')?.b ?? script[start].b;
|
|
98
|
+
const oldLen = hunk.filter(e => e.op !== '+').length;
|
|
99
|
+
const newLen = hunk.filter(e => e.op !== '-').length;
|
|
100
|
+
lines.push(`@@ -${oldStart + 1},${oldLen} +${newStart + 1},${newLen} @@`);
|
|
101
|
+
for (const e of hunk) lines.push(`${e.op}${e.op === '+' ? b[e.b] : a[e.a]}`);
|
|
102
|
+
i = stop;
|
|
103
|
+
}
|
|
104
|
+
return lines.join('\n');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export { unifiedDiff };
|
|
@@ -32,9 +32,22 @@ function readOwnBackgroundColor(el, computedStyle) {
|
|
|
32
32
|
return bg;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// Static mode marks the result `uncertain` when the colour is a guess: a
|
|
36
|
+
// translucent layer was stacked on (or skipped over) the surface we found, a
|
|
37
|
+
// body-level gradient was assumed white, or the walk never reached <body> and
|
|
38
|
+
// fell back to the default white (a fragment's real surface is unknown).
|
|
39
|
+
// Contrast measured against an uncertain surface is reported as unverified.
|
|
40
|
+
function markUncertain(bg, uncertain) {
|
|
41
|
+
if (!DETECTOR_IS_BROWSER && uncertain) bg.uncertain = true;
|
|
42
|
+
return bg;
|
|
43
|
+
}
|
|
44
|
+
|
|
35
45
|
function resolveBackground(el, win, customPropMap) {
|
|
36
46
|
let current = el;
|
|
47
|
+
let sawTranslucent = false;
|
|
48
|
+
let sawBody = false;
|
|
37
49
|
while (current && current.nodeType === 1) {
|
|
50
|
+
if (current.tagName === 'BODY' || current.tagName === 'HTML') sawBody = true;
|
|
38
51
|
const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
|
|
39
52
|
const bgImage = style.backgroundImage || '';
|
|
40
53
|
const hasGradientOrUrl = bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage));
|
|
@@ -66,8 +79,9 @@ function resolveBackground(el, win, customPropMap) {
|
|
|
66
79
|
}
|
|
67
80
|
|
|
68
81
|
if (bg && bg.a > 0.1) {
|
|
69
|
-
if (DETECTOR_IS_BROWSER || bg.a >= 0.5) return bg;
|
|
82
|
+
if (DETECTOR_IS_BROWSER || bg.a >= 0.5) return markUncertain(bg, sawTranslucent || bg.a < 1);
|
|
70
83
|
}
|
|
84
|
+
if (bg && bg.a > 0) sawTranslucent = true;
|
|
71
85
|
// No solid bg-color at this level. If THIS level has a gradient/url
|
|
72
86
|
// with no underlying solid color we can read:
|
|
73
87
|
// • on body/html: assume white. Body-level gradients are almost
|
|
@@ -82,13 +96,13 @@ function resolveBackground(el, win, customPropMap) {
|
|
|
82
96
|
// bgs worth checking against).
|
|
83
97
|
if (hasGradientOrUrl) {
|
|
84
98
|
if (current.tagName === 'BODY' || current.tagName === 'HTML') {
|
|
85
|
-
return { r: 255, g: 255, b: 255, a: 1 };
|
|
99
|
+
return markUncertain({ r: 255, g: 255, b: 255, a: 1 }, true);
|
|
86
100
|
}
|
|
87
101
|
return null;
|
|
88
102
|
}
|
|
89
103
|
current = current.parentElement;
|
|
90
104
|
}
|
|
91
|
-
return { r: 255, g: 255, b: 255 };
|
|
105
|
+
return markUncertain({ r: 255, g: 255, b: 255 }, sawTranslucent || !sawBody);
|
|
92
106
|
}
|
|
93
107
|
|
|
94
108
|
// Walk parents looking for a gradient background and return its color stops.
|
|
@@ -164,6 +164,8 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
|
|
|
164
164
|
bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
|
|
165
165
|
bgImage: style.backgroundImage || '',
|
|
166
166
|
classList: el.getAttribute?.('class') || el.className || '',
|
|
167
|
+
// SVG text paints with `fill`, not `color`, so its CSS colour says nothing.
|
|
168
|
+
isSvgText: tag === 'text' || tag === 'tspan' || tag === 'textpath',
|
|
167
169
|
});
|
|
168
170
|
}
|
|
169
171
|
|
|
@@ -60,7 +60,7 @@ function isEmojiOnlyText(text) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
function checkColors(opts) {
|
|
63
|
-
const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;
|
|
63
|
+
const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList, isSvgText } = opts;
|
|
64
64
|
if (SAFE_TAGS.has(tag)) {
|
|
65
65
|
// Exception for <a> and <button> elements styled as buttons. SAFE_TAGS
|
|
66
66
|
// exists to suppress contrast noise on inline links and unstyled controls,
|
|
@@ -108,7 +108,14 @@ function checkColors(opts) {
|
|
|
108
108
|
// like `text-paper/60` on `bg-ink` sections are the FP pattern.
|
|
109
109
|
const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1);
|
|
110
110
|
if (!isAlphaFallbackFP) {
|
|
111
|
-
|
|
111
|
+
const snippet = `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}`;
|
|
112
|
+
// Static analysis can't composite translucent text/surfaces, see
|
|
113
|
+
// through gradients, or read SVG `fill`, so those ratios are guesses:
|
|
114
|
+
// report them as unverified rather than definite failures (#424).
|
|
115
|
+
const unverified = !DETECTOR_IS_BROWSER && (
|
|
116
|
+
(textColor.a ?? 1) < 1 || !effectiveBg || (effectiveBg.a ?? 1) < 1 || effectiveBg.uncertain || isSvgText
|
|
117
|
+
);
|
|
118
|
+
findings.push(unverified ? { id: 'low-contrast', snippet, unverified: true } : { id: 'low-contrast', snippet });
|
|
112
119
|
}
|
|
113
120
|
}
|
|
114
121
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design-hook consent persistence for monodesign-config.mjs: the recorded
|
|
3
|
+
* per-developer accept/decline decision in config.local.json, and keeping
|
|
4
|
+
* that file out of git via `.git/info/exclude`.
|
|
5
|
+
*
|
|
6
|
+
* Split out of monodesign-config.mjs. See that file for context.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
|
9
|
+
import { join, dirname, isAbsolute } from 'node:path';
|
|
10
|
+
import { getConfigPath, getLocalConfigPath, hookSection, safeReadJson } from './monodesign-config-core.mjs';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
|
|
14
|
+
* config.local.json (per-developer) overrides config.json.
|
|
15
|
+
*/
|
|
16
|
+
export function getHookConsent(root) {
|
|
17
|
+
let consent;
|
|
18
|
+
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
|
|
19
|
+
const hook = hookSection(safeReadJson(filePath));
|
|
20
|
+
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
|
|
21
|
+
}
|
|
22
|
+
return consent;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Persist the per-developer decision to config.local.json, preserving any
|
|
27
|
+
* sibling keys, and ensure the file is gitignored.
|
|
28
|
+
*/
|
|
29
|
+
export function setHookConsent(root, value) {
|
|
30
|
+
const filePath = getLocalConfigPath(root);
|
|
31
|
+
const existing = safeReadJson(filePath) || {};
|
|
32
|
+
const hook = hookSection(existing) || {};
|
|
33
|
+
const next = { ...existing, hook: { ...hook, consent: value } };
|
|
34
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
35
|
+
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
|
|
36
|
+
ensureConfigGitExclude(root);
|
|
37
|
+
return filePath;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const EXCLUDE_OPEN = '# monodesign-config-ignore-start';
|
|
41
|
+
const EXCLUDE_CLOSE = '# monodesign-config-ignore-end';
|
|
42
|
+
const EXCLUDE_PATTERNS = ['.monodesign/config.local.json'];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Add config.local.json to `.git/info/exclude` so a developer's decision is
|
|
46
|
+
* never committed. Idempotent via marker comments. Best-effort; returns false
|
|
47
|
+
* when there is no resolvable git dir.
|
|
48
|
+
*/
|
|
49
|
+
export function ensureConfigGitExclude(root) {
|
|
50
|
+
try {
|
|
51
|
+
const gitDir = resolveGitDir(root);
|
|
52
|
+
if (!gitDir) return false;
|
|
53
|
+
const target = join(gitDir, 'info', 'exclude');
|
|
54
|
+
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
|
|
55
|
+
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
|
|
56
|
+
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
|
|
57
|
+
let updated;
|
|
58
|
+
if (markerRe.test(existing)) {
|
|
59
|
+
updated = existing.replace(markerRe, block);
|
|
60
|
+
} else {
|
|
61
|
+
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
|
|
62
|
+
updated = `${prefix}${block}\n`;
|
|
63
|
+
}
|
|
64
|
+
if (updated !== existing) {
|
|
65
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
66
|
+
writeFileSync(target, updated);
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolveGitDir(root) {
|
|
75
|
+
const dotGit = join(root, '.git');
|
|
76
|
+
if (!existsSync(dotGit)) return null;
|
|
77
|
+
try {
|
|
78
|
+
if (statSync(dotGit).isDirectory()) return dotGit;
|
|
79
|
+
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
|
|
80
|
+
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
|
|
81
|
+
if (match) {
|
|
82
|
+
const resolved = match[1].trim();
|
|
83
|
+
return isAbsolute(resolved) ? resolved : join(root, resolved);
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
/* fall through */
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function escapeRegExp(value) {
|
|
92
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
93
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config-file path layout and raw JSON section readers shared by the
|
|
3
|
+
* monodesign-config.mjs sibling modules.
|
|
4
|
+
*
|
|
5
|
+
* Split out of monodesign-config.mjs. See that file for context.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
export function getConfigPath(root) {
|
|
11
|
+
return join(root, '.monodesign', 'config.json');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getLocalConfigPath(root) {
|
|
15
|
+
return join(root, '.monodesign', 'config.local.json');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function safeReadJson(filePath) {
|
|
19
|
+
try {
|
|
20
|
+
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
21
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function hookSection(raw) {
|
|
28
|
+
return raw?.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function detectorSection(raw) {
|
|
32
|
+
return raw?.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function uniqueStrings(values) {
|
|
36
|
+
return Array.from(new Set(values.map(String)));
|
|
37
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading and writing the detector-filter subtree (`detector.*`, with
|
|
3
|
+
* back-compat fallback to legacy `hook.*`) of the unified `.monodesign`
|
|
4
|
+
* config for monodesign-config.mjs.
|
|
5
|
+
*
|
|
6
|
+
* Split out of monodesign-config.mjs. See that file for context.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { dirname } from 'node:path';
|
|
10
|
+
import { detectorSection, getConfigPath, getLocalConfigPath, hookSection, safeReadJson, uniqueStrings } from './monodesign-config-core.mjs';
|
|
11
|
+
import { mergeIgnoreValues, normalizeIgnoreRule, normalizeIgnoreValueEntries } from './monodesign-config-ignore-value.mjs';
|
|
12
|
+
import { ensureConfigGitExclude } from './monodesign-config-consent.mjs';
|
|
13
|
+
|
|
14
|
+
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
|
|
15
|
+
|
|
16
|
+
const DEFAULT_DETECTION_CONFIG = Object.freeze({
|
|
17
|
+
ignoreRules: [],
|
|
18
|
+
ignoreFiles: [],
|
|
19
|
+
ignoreValues: [],
|
|
20
|
+
designSystem: { enabled: true },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function cloneDetectionConfig() {
|
|
24
|
+
return {
|
|
25
|
+
ignoreRules: [],
|
|
26
|
+
ignoreFiles: [],
|
|
27
|
+
ignoreValues: [],
|
|
28
|
+
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function cloneRawDetectionConfig() {
|
|
33
|
+
return {
|
|
34
|
+
ignoreRules: [],
|
|
35
|
+
ignoreFiles: [],
|
|
36
|
+
ignoreValues: [],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function applyDetectionConfigSource(config, raw) {
|
|
41
|
+
if (!raw || typeof raw !== 'object') return config;
|
|
42
|
+
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
|
43
|
+
config.designSystem = {
|
|
44
|
+
...config.designSystem,
|
|
45
|
+
enabled: raw.designSystem.enabled !== false,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (Array.isArray(raw.ignoreRules)) {
|
|
49
|
+
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
|
|
50
|
+
}
|
|
51
|
+
if (Array.isArray(raw.ignoreFiles)) {
|
|
52
|
+
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
|
|
53
|
+
}
|
|
54
|
+
if (Array.isArray(raw.ignoreValues)) {
|
|
55
|
+
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
|
|
56
|
+
}
|
|
57
|
+
return config;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Detector filters shared by `monomind design detect` and the design hook.
|
|
62
|
+
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
|
|
63
|
+
* the hook is disabled, but they honor the same ignore rules and design-system
|
|
64
|
+
* toggle.
|
|
65
|
+
*/
|
|
66
|
+
export function readDetectionConfig(root) {
|
|
67
|
+
const config = cloneDetectionConfig();
|
|
68
|
+
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
|
|
69
|
+
const raw = safeReadJson(filePath);
|
|
70
|
+
// Back-compat: old builds stored detector filters under hook.*.
|
|
71
|
+
applyDetectionConfigSource(config, hookSection(raw));
|
|
72
|
+
applyDetectionConfigSource(config, detectorSection(raw));
|
|
73
|
+
}
|
|
74
|
+
return config;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function readRawDetectionConfig(root, opts = {}) {
|
|
78
|
+
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
|
|
79
|
+
const config = cloneRawDetectionConfig();
|
|
80
|
+
applyDetectionConfigSource(config, hookSection(raw));
|
|
81
|
+
applyDetectionConfigSource(config, detectorSection(raw));
|
|
82
|
+
return config;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
|
|
86
|
+
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
|
|
87
|
+
if (opts.local) ensureConfigGitExclude(root);
|
|
88
|
+
const existing = safeReadJson(filePath) || {};
|
|
89
|
+
const existingHook = hookSection(existing);
|
|
90
|
+
const nextHook = stripDetectorKeys(existingHook);
|
|
91
|
+
const nextDetector = {
|
|
92
|
+
...(detectorSection(existing) || {}),
|
|
93
|
+
...normalizeDetectionConfigForWrite(detectorConfig),
|
|
94
|
+
};
|
|
95
|
+
const next = {
|
|
96
|
+
...existing,
|
|
97
|
+
detector: nextDetector,
|
|
98
|
+
};
|
|
99
|
+
if (nextHook && Object.keys(nextHook).length > 0) {
|
|
100
|
+
next.hook = nextHook;
|
|
101
|
+
} else {
|
|
102
|
+
delete next.hook;
|
|
103
|
+
}
|
|
104
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
105
|
+
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
|
|
106
|
+
return filePath;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function normalizeDetectionConfigForWrite(config) {
|
|
110
|
+
const out = {};
|
|
111
|
+
if (Array.isArray(config?.ignoreRules)) {
|
|
112
|
+
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(config?.ignoreFiles)) {
|
|
115
|
+
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
|
|
116
|
+
}
|
|
117
|
+
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
|
118
|
+
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
|
|
119
|
+
out.designSystem = {
|
|
120
|
+
enabled: config.designSystem.enabled !== false,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function stripDetectorKeys(raw) {
|
|
127
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
128
|
+
const out = {};
|
|
129
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
130
|
+
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|