samey-cli 0.0.0-stage → 0.1.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/CHANGELOG.md +43 -0
- package/LICENSE +21 -0
- package/README.md +224 -2
- package/dist/ai/local-model.d.ts +26 -0
- package/dist/ai/local-model.js +91 -0
- package/dist/audit.d.ts +18 -0
- package/dist/audit.js +250 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +166 -0
- package/dist/config.d.ts +34 -0
- package/dist/config.js +40 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/render/crawl.d.ts +34 -0
- package/dist/render/crawl.js +337 -0
- package/dist/render/extract.d.ts +8 -0
- package/dist/render/extract.js +441 -0
- package/dist/render/interact.d.ts +8 -0
- package/dist/render/interact.js +76 -0
- package/dist/render/network.d.ts +33 -0
- package/dist/render/network.js +92 -0
- package/dist/render/robots.d.ts +8 -0
- package/dist/render/robots.js +61 -0
- package/dist/render/server.d.ts +15 -0
- package/dist/render/server.js +174 -0
- package/dist/report/html.d.ts +2 -0
- package/dist/report/html.js +368 -0
- package/dist/report/prompt.d.ts +12 -0
- package/dist/report/prompt.js +62 -0
- package/dist/report/terminal.d.ts +6 -0
- package/dist/report/terminal.js +73 -0
- package/dist/rules/content.d.ts +8 -0
- package/dist/rules/content.js +227 -0
- package/dist/rules/functional.d.ts +17 -0
- package/dist/rules/functional.js +467 -0
- package/dist/rules/helpers.d.ts +11 -0
- package/dist/rules/helpers.js +29 -0
- package/dist/rules/index.d.ts +13 -0
- package/dist/rules/index.js +100 -0
- package/dist/rules/normalize.d.ts +18 -0
- package/dist/rules/normalize.js +114 -0
- package/dist/rules/static.d.ts +9 -0
- package/dist/rules/static.js +314 -0
- package/dist/rules/types.d.ts +39 -0
- package/dist/rules/types.js +16 -0
- package/dist/rules/visual.d.ts +15 -0
- package/dist/rules/visual.js +463 -0
- package/dist/score.d.ts +31 -0
- package/dist/score.js +66 -0
- package/dist/security/fetch.d.ts +26 -0
- package/dist/security/fetch.js +86 -0
- package/dist/security/net-policy.d.ts +46 -0
- package/dist/security/net-policy.js +178 -0
- package/dist/static/scan.d.ts +15 -0
- package/dist/static/scan.js +191 -0
- package/dist/types.d.ts +316 -0
- package/dist/types.js +1 -0
- package/dist/util/color.d.ts +25 -0
- package/dist/util/color.js +180 -0
- package/dist/util/text.d.ts +22 -0
- package/dist/util/text.js +106 -0
- package/package.json +69 -4
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import { extractColors, isSaturated, isStockGradient } from '../util/color.js';
|
|
2
|
+
import { firstEmoji, hasEmoji, truncate } from '../util/text.js';
|
|
3
|
+
import { el, inChrome, pagesWith, plural, topEntries, uniqueRoutes } from './helpers.js';
|
|
4
|
+
export const stockGradient = {
|
|
5
|
+
id: 'stock-gradient',
|
|
6
|
+
title: 'Stock blue-violet-pink gradients',
|
|
7
|
+
category: 'visual',
|
|
8
|
+
needs: 'rendered',
|
|
9
|
+
verified: false,
|
|
10
|
+
description: 'Saturated gradients drawn from the indigo/violet/fuchsia/pink band, the default look of generated landing pages.',
|
|
11
|
+
run({ crawl }) {
|
|
12
|
+
const ev = [];
|
|
13
|
+
let textCount = 0;
|
|
14
|
+
let large = 0;
|
|
15
|
+
for (const { page, facts } of pagesWith(crawl, 'desktop')) {
|
|
16
|
+
for (const g of facts.gradients) {
|
|
17
|
+
if (g.area < 400 || !isStockGradient(g.value))
|
|
18
|
+
continue;
|
|
19
|
+
if (g.isText)
|
|
20
|
+
textCount++;
|
|
21
|
+
if (g.area >= facts.width * facts.height * 0.25)
|
|
22
|
+
large++;
|
|
23
|
+
ev.push(el(page, 'desktop', g, truncate(g.value, 140), g.isText ? 'gradient text' : g.area >= facts.width * facts.height * 0.25 ? 'large background' : 'background'));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (!ev.length)
|
|
27
|
+
return [];
|
|
28
|
+
// One modest gradient is a legitimate choice; it only reads as stock when it dominates or repeats.
|
|
29
|
+
const severity = textCount > 0 || large > 0 || ev.length >= 4 ? 'medium' : ev.length >= 2 ? 'low' : 'info';
|
|
30
|
+
const parts = [plural(ev.length, 'element')];
|
|
31
|
+
if (textCount)
|
|
32
|
+
parts.push(`${textCount} as gradient text`);
|
|
33
|
+
if (large)
|
|
34
|
+
parts.push(`${large} covering a large area`);
|
|
35
|
+
return [
|
|
36
|
+
{
|
|
37
|
+
severity,
|
|
38
|
+
confidence: 0.9,
|
|
39
|
+
description: `Found the blue-violet-pink gradient on ${parts.join(', ')}. It is the most recognizable default of AI-generated UIs and makes the site read as a template.`,
|
|
40
|
+
recommendation: 'Pick a palette from the brand rather than the framework default: one primary hue used with intent, gradients only where they carry meaning. Replace gradient headline text with solid color and weight contrast.',
|
|
41
|
+
evidence: ev,
|
|
42
|
+
routes: uniqueRoutes(ev),
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
export const glowEffects = {
|
|
48
|
+
id: 'glow-effects',
|
|
49
|
+
title: 'Decorative glows, blur blobs, and glass panels',
|
|
50
|
+
category: 'visual',
|
|
51
|
+
needs: 'rendered',
|
|
52
|
+
verified: false,
|
|
53
|
+
description: 'Colored large-blur shadows, blurred background orbs, and backdrop-blur glass panels used as decoration.',
|
|
54
|
+
run({ crawl }) {
|
|
55
|
+
const ev = [];
|
|
56
|
+
const counts = { shadow: 0, 'blur-blob': 0, backdrop: 0 };
|
|
57
|
+
for (const { page, facts } of pagesWith(crawl, 'desktop')) {
|
|
58
|
+
for (const g of facts.glows) {
|
|
59
|
+
if (g.kind === 'shadow' && !extractColors(g.value).some((c) => isSaturated(c) && c.a >= 0.15))
|
|
60
|
+
continue;
|
|
61
|
+
// A sticky nav with backdrop blur is a common, legitimate pattern; only count non-chrome panels.
|
|
62
|
+
if (g.kind === 'backdrop' && inChrome(facts, g))
|
|
63
|
+
continue;
|
|
64
|
+
counts[g.kind]++;
|
|
65
|
+
ev.push(el(page, 'desktop', g, truncate(g.value, 140), g.kind === 'shadow' ? 'colored glow shadow' : g.kind === 'blur-blob' ? 'blurred decorative blob' : 'glass panel'));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const total = counts.shadow + counts['blur-blob'] + counts.backdrop;
|
|
69
|
+
if (total < 3)
|
|
70
|
+
return [];
|
|
71
|
+
const parts = [];
|
|
72
|
+
if (counts.shadow)
|
|
73
|
+
parts.push(plural(counts.shadow, 'colored glow shadow'));
|
|
74
|
+
if (counts['blur-blob'])
|
|
75
|
+
parts.push(plural(counts['blur-blob'], 'blurred background blob'));
|
|
76
|
+
if (counts.backdrop)
|
|
77
|
+
parts.push(plural(counts.backdrop, 'glass panel'));
|
|
78
|
+
return [
|
|
79
|
+
{
|
|
80
|
+
severity: total >= 8 ? 'medium' : 'low',
|
|
81
|
+
confidence: 0.9,
|
|
82
|
+
description: `Found ${parts.join(', ')}. Layered glows and glass are decoration the content does not ask for, and they flatten hierarchy because everything shines equally.`,
|
|
83
|
+
recommendation: 'Remove decorative blobs and glow shadows. Use elevation sparingly and with neutral shadows; keep at most one deliberate accent effect per page.',
|
|
84
|
+
evidence: ev,
|
|
85
|
+
routes: uniqueRoutes(ev),
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
function contentSections(sections) {
|
|
91
|
+
return sections.filter((s) => !s.isChrome);
|
|
92
|
+
}
|
|
93
|
+
export const cardGridSameness = {
|
|
94
|
+
id: 'card-grid-sameness',
|
|
95
|
+
title: 'The same card grid, over and over',
|
|
96
|
+
category: 'visual',
|
|
97
|
+
needs: 'rendered',
|
|
98
|
+
verified: false,
|
|
99
|
+
description: 'Sections built from the same repeated card composition across the site.',
|
|
100
|
+
run({ crawl }) {
|
|
101
|
+
const bySig = new Map();
|
|
102
|
+
let cardSections = 0;
|
|
103
|
+
let total = 0;
|
|
104
|
+
for (const { page, facts } of pagesWith(crawl, 'desktop')) {
|
|
105
|
+
for (const s of contentSections(facts.sections)) {
|
|
106
|
+
total++;
|
|
107
|
+
if (s.cardCount >= 3 && s.cardSignature) {
|
|
108
|
+
cardSections++;
|
|
109
|
+
const key = `${s.cardSignature}|${s.columns}`;
|
|
110
|
+
if (!bySig.has(key))
|
|
111
|
+
bySig.set(key, []);
|
|
112
|
+
bySig.get(key).push(el(page, 'desktop', s, s.heading ? truncate(s.heading, 80) : undefined, `${s.cardCount} cards in ${s.columns} columns`));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const [, ev] of [...bySig].sort((a, b) => b[1].length - a[1].length)) {
|
|
118
|
+
if (ev.length < 3)
|
|
119
|
+
break;
|
|
120
|
+
out.push({
|
|
121
|
+
severity: ev.length >= 5 ? 'medium' : 'low',
|
|
122
|
+
confidence: 0.6,
|
|
123
|
+
description: `${plural(ev.length, 'section')} use an identical card layout (same structure and column count). Repeating one composition makes every section feel like the previous one.`,
|
|
124
|
+
recommendation: 'Vary the form to fit the content: a comparison table for plans, a single large example for the main feature, a list for secondary points. Keep cards for content that is genuinely a set of peers.',
|
|
125
|
+
evidence: ev,
|
|
126
|
+
routes: uniqueRoutes(ev),
|
|
127
|
+
});
|
|
128
|
+
if (out.length >= 2)
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
if (total >= 5 && cardSections / total >= 0.5 && !out.length) {
|
|
132
|
+
out.push({
|
|
133
|
+
title: 'Most sections are card grids',
|
|
134
|
+
severity: 'low',
|
|
135
|
+
confidence: 0.6,
|
|
136
|
+
description: `${cardSections} of ${total} content sections are grids of three or more cards.`,
|
|
137
|
+
recommendation: 'Break the rhythm with sections that use prose, a single image, a table, or a list where the content fits that better.',
|
|
138
|
+
evidence: [],
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
export const repeatedRhythm = {
|
|
145
|
+
id: 'repeated-section-rhythm',
|
|
146
|
+
title: 'Consecutive sections with the same layout',
|
|
147
|
+
category: 'visual',
|
|
148
|
+
needs: 'rendered',
|
|
149
|
+
verified: false,
|
|
150
|
+
description: 'Three or more adjacent sections with an identical layout fingerprint.',
|
|
151
|
+
run({ crawl }) {
|
|
152
|
+
const out = [];
|
|
153
|
+
for (const { page, facts } of pagesWith(crawl, 'desktop')) {
|
|
154
|
+
const secs = contentSections(facts.sections);
|
|
155
|
+
let runStart = 0;
|
|
156
|
+
for (let i = 1; i <= secs.length; i++) {
|
|
157
|
+
if (i < secs.length && secs[i].signature === secs[runStart].signature)
|
|
158
|
+
continue;
|
|
159
|
+
const len = i - runStart;
|
|
160
|
+
const run = secs.slice(runStart, i);
|
|
161
|
+
// Plain prose sections following each other is normal reading flow, not a repeated composition.
|
|
162
|
+
if (len >= 3 && (run[0].cardCount >= 3 || run[0].columns > 1)) {
|
|
163
|
+
out.push({
|
|
164
|
+
severity: 'low',
|
|
165
|
+
confidence: 0.6,
|
|
166
|
+
description: `${len} consecutive sections on ${page.path} share the same layout (heading, ${run[0].columns} column${run[0].columns === 1 ? '' : 's'}, ${run[0].cardCount ? run[0].cardCount + ' cards' : 'no cards'}). The page reads as a stack of interchangeable blocks.`,
|
|
167
|
+
recommendation: 'Give each section a layout that matches its job, and let the most important one take more space.',
|
|
168
|
+
evidence: run.map((s) => el(page, 'desktop', s, s.heading ? truncate(s.heading, 80) : undefined)),
|
|
169
|
+
routes: [page.path],
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
runStart = i;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out.slice(0, 3);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
function jaccard(a, b) {
|
|
179
|
+
const A = new Map();
|
|
180
|
+
const B = new Map();
|
|
181
|
+
for (const x of a)
|
|
182
|
+
A.set(x, (A.get(x) ?? 0) + 1);
|
|
183
|
+
for (const x of b)
|
|
184
|
+
B.set(x, (B.get(x) ?? 0) + 1);
|
|
185
|
+
let inter = 0;
|
|
186
|
+
let union = 0;
|
|
187
|
+
for (const k of new Set([...A.keys(), ...B.keys()])) {
|
|
188
|
+
inter += Math.min(A.get(k) ?? 0, B.get(k) ?? 0);
|
|
189
|
+
union += Math.max(A.get(k) ?? 0, B.get(k) ?? 0);
|
|
190
|
+
}
|
|
191
|
+
return union ? inter / union : 0;
|
|
192
|
+
}
|
|
193
|
+
export const crossPageTemplate = {
|
|
194
|
+
id: 'cross-page-template',
|
|
195
|
+
title: 'Different pages, same template',
|
|
196
|
+
category: 'visual',
|
|
197
|
+
needs: 'rendered',
|
|
198
|
+
verified: false,
|
|
199
|
+
description: 'Pages with different purposes that share most of their section structure.',
|
|
200
|
+
run({ crawl }) {
|
|
201
|
+
// Pages under the same parent (/blog/a, /blog/b) are expected to share a template; compare one per group.
|
|
202
|
+
const groups = new Map();
|
|
203
|
+
for (const { page, facts } of pagesWith(crawl, 'desktop')) {
|
|
204
|
+
const segs = page.path.split('/').filter(Boolean);
|
|
205
|
+
const group = segs.length > 1 ? segs[0] : page.path;
|
|
206
|
+
if (groups.has(group))
|
|
207
|
+
continue;
|
|
208
|
+
const sigs = contentSections(facts.sections).map((s) => s.signature);
|
|
209
|
+
if (sigs.length < 3)
|
|
210
|
+
continue;
|
|
211
|
+
groups.set(group, { path: page.path, sigs, ev: { type: 'metric', route: page.path, viewport: 'desktop', detail: `${sigs.length} content sections` } });
|
|
212
|
+
}
|
|
213
|
+
const list = [...groups.values()];
|
|
214
|
+
if (list.length < 3)
|
|
215
|
+
return [];
|
|
216
|
+
const pairs = [];
|
|
217
|
+
let sum = 0;
|
|
218
|
+
for (let i = 0; i < list.length; i++)
|
|
219
|
+
for (let j = i + 1; j < list.length; j++) {
|
|
220
|
+
const sim = jaccard(list[i].sigs, list[j].sigs);
|
|
221
|
+
pairs.push({ a: list[i].path, b: list[j].path, sim });
|
|
222
|
+
sum += sim;
|
|
223
|
+
}
|
|
224
|
+
const avg = sum / pairs.length;
|
|
225
|
+
if (avg < 0.5)
|
|
226
|
+
return [];
|
|
227
|
+
const top = pairs.sort((x, y) => y.sim - x.sim).slice(0, 5);
|
|
228
|
+
return [
|
|
229
|
+
{
|
|
230
|
+
severity: avg >= 0.7 ? 'medium' : 'low',
|
|
231
|
+
confidence: 0.6,
|
|
232
|
+
description: `Across ${list.length} distinct page types, ${Math.round(avg * 100)}% of section structure is shared on average. Pages with different purposes are built from the same blocks in a similar order.`,
|
|
233
|
+
recommendation: 'Design each page around its own task: an about page tells a story, a pricing page compares, a docs page navigates. Reuse components, not whole page skeletons.',
|
|
234
|
+
evidence: top.map((p) => ({ type: 'metric', detail: `${p.a} vs ${p.b}: ${Math.round(p.sim * 100)}% shared structure`, route: p.a })),
|
|
235
|
+
routes: list.map((l) => l.path),
|
|
236
|
+
},
|
|
237
|
+
];
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
export function classifySection(s, index, total) {
|
|
241
|
+
const h = (s.heading ?? '').toLowerCase();
|
|
242
|
+
const t = s.text.toLowerCase();
|
|
243
|
+
if (index === 0 && s.buttonCount >= 1 && s.heading)
|
|
244
|
+
return 'hero';
|
|
245
|
+
if (s.hasPrice || /\bpricing\b|\bplans?\b/.test(h))
|
|
246
|
+
return 'pricing';
|
|
247
|
+
if (s.hasDetails || /\bfaq\b|frequently asked|questions/.test(h))
|
|
248
|
+
return 'faq';
|
|
249
|
+
if (s.hasBlockquote || /testimonial|what (?:our|people|customers)|loved by|wall of love|customers say/.test(h))
|
|
250
|
+
return 'testimonials';
|
|
251
|
+
if (/trusted by|used by|backed by|as seen|logos?\b/.test(h || t.slice(0, 80)) || (s.imageCount >= 4 && s.text.length < 160))
|
|
252
|
+
return 'logos';
|
|
253
|
+
if (s.cardCount >= 3 && /feature|why|how it works|everything you need|benefits|what you get/.test(h))
|
|
254
|
+
return 'features';
|
|
255
|
+
if (s.cardCount >= 3 && index <= 3)
|
|
256
|
+
return 'features';
|
|
257
|
+
if (index >= total - 2 && s.buttonCount >= 1 && s.text.length < 400)
|
|
258
|
+
return 'cta';
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
const CANON = ['hero', 'logos', 'features', 'testimonials', 'pricing', 'faq', 'cta'];
|
|
262
|
+
export const stockLandingSequence = {
|
|
263
|
+
id: 'stock-landing-sequence',
|
|
264
|
+
title: 'Stock SaaS landing page sequence',
|
|
265
|
+
category: 'visual',
|
|
266
|
+
needs: 'rendered',
|
|
267
|
+
verified: false,
|
|
268
|
+
penalty: 25,
|
|
269
|
+
description: 'Home page follows hero, logos, features, testimonials, pricing, FAQ, CTA in that order.',
|
|
270
|
+
run({ crawl }) {
|
|
271
|
+
const first = pagesWith(crawl, 'desktop')[0];
|
|
272
|
+
if (!first)
|
|
273
|
+
return [];
|
|
274
|
+
const secs = contentSections(first.facts.sections);
|
|
275
|
+
const kinds = secs.map((s, i) => ({ s, k: classifySection(s, i, secs.length) })).filter((x) => x.k);
|
|
276
|
+
// Longest subsequence of the canonical order present on the page.
|
|
277
|
+
const seq = [];
|
|
278
|
+
let ci = 0;
|
|
279
|
+
for (const x of kinds) {
|
|
280
|
+
const idx = CANON.indexOf(x.k, ci);
|
|
281
|
+
if (idx >= 0) {
|
|
282
|
+
seq.push(x);
|
|
283
|
+
ci = idx + 1;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (seq.length < 5)
|
|
287
|
+
return [];
|
|
288
|
+
return [
|
|
289
|
+
{
|
|
290
|
+
severity: 'low',
|
|
291
|
+
confidence: 0.6,
|
|
292
|
+
description: `The home page follows the stock sequence: ${seq.map((x) => x.k).join(' → ')}. Using familiar sections is fine on its own; combined with stock visuals it makes the page interchangeable with thousands of others.`,
|
|
293
|
+
recommendation: 'Lead with what is specific: a real product screenshot or demo, a concrete use case, or an opinion. Cut sections that exist only because the template has them.',
|
|
294
|
+
evidence: seq.map((x) => el(first.page, 'desktop', x.s, `${x.k}: ${truncate(x.s.heading ?? '', 70)}`)),
|
|
295
|
+
routes: [first.page.path],
|
|
296
|
+
},
|
|
297
|
+
];
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
export const centeredEverything = {
|
|
301
|
+
id: 'centered-everything',
|
|
302
|
+
title: 'Everything is centered',
|
|
303
|
+
category: 'visual',
|
|
304
|
+
needs: 'rendered',
|
|
305
|
+
verified: false,
|
|
306
|
+
penalty: 25,
|
|
307
|
+
description: 'Nearly every section uses a centered heading stack.',
|
|
308
|
+
run({ crawl }) {
|
|
309
|
+
const ev = [];
|
|
310
|
+
let total = 0;
|
|
311
|
+
for (const { page, facts } of pagesWith(crawl, 'desktop')) {
|
|
312
|
+
for (const s of contentSections(facts.sections)) {
|
|
313
|
+
if (!s.heading)
|
|
314
|
+
continue;
|
|
315
|
+
total++;
|
|
316
|
+
if (s.centered)
|
|
317
|
+
ev.push(el(page, 'desktop', s, truncate(s.heading, 80)));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (total < 5 || ev.length / total < 0.85)
|
|
321
|
+
return [];
|
|
322
|
+
return [
|
|
323
|
+
{
|
|
324
|
+
severity: 'low',
|
|
325
|
+
confidence: 0.6,
|
|
326
|
+
description: `${ev.length} of ${total} section headings are centered. Symmetric, centered stacks everywhere remove the reading line and make hierarchy depend only on size.`,
|
|
327
|
+
recommendation: 'Left-align long-form and feature content; reserve centering for one or two moments such as the hero or a closing call to action.',
|
|
328
|
+
evidence: ev.slice(0, 8),
|
|
329
|
+
routes: uniqueRoutes(ev),
|
|
330
|
+
},
|
|
331
|
+
];
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
export const emojiIcons = {
|
|
335
|
+
id: 'emoji-icons',
|
|
336
|
+
title: 'Emoji used as icons',
|
|
337
|
+
category: 'visual',
|
|
338
|
+
needs: 'rendered',
|
|
339
|
+
verified: false,
|
|
340
|
+
description: 'Emoji standing in for icons in headings, buttons, and feature cards.',
|
|
341
|
+
run({ crawl }) {
|
|
342
|
+
const ev = [];
|
|
343
|
+
const seen = new Set();
|
|
344
|
+
for (const { page, facts } of pagesWith(crawl, 'desktop')) {
|
|
345
|
+
for (const b of facts.textBlocks) {
|
|
346
|
+
if (b.text.length > 120 || !hasEmoji(b.text))
|
|
347
|
+
continue;
|
|
348
|
+
if (!/^h[1-6]$|^button$|^li$|^a$|^span$|^div$|^p$|^dt$/.test(b.tag))
|
|
349
|
+
continue;
|
|
350
|
+
const key = page.path + b.selector;
|
|
351
|
+
if (seen.has(key))
|
|
352
|
+
continue;
|
|
353
|
+
seen.add(key);
|
|
354
|
+
ev.push(el(page, 'desktop', b, truncate(b.text, 80), `emoji ${firstEmoji(b.text)}`));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (ev.length < 2)
|
|
358
|
+
return [];
|
|
359
|
+
return [
|
|
360
|
+
{
|
|
361
|
+
severity: ev.length >= 5 ? 'medium' : 'low',
|
|
362
|
+
confidence: 0.9,
|
|
363
|
+
description: `${plural(ev.length, 'heading, button, or label')} use emoji as decoration or icons. Emoji render differently on every platform, cannot be styled to the brand, and are a strong signal of generated UI.`,
|
|
364
|
+
recommendation: 'Replace emoji with a consistent SVG icon set (one stroke width, one size scale) or drop the icon entirely where the label is clear.',
|
|
365
|
+
evidence: ev,
|
|
366
|
+
routes: uniqueRoutes(ev),
|
|
367
|
+
},
|
|
368
|
+
];
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
const px = (v) => parseFloat(v);
|
|
372
|
+
export const tokenSprawl = {
|
|
373
|
+
id: 'token-sprawl',
|
|
374
|
+
title: 'Inconsistent design tokens',
|
|
375
|
+
category: 'visual',
|
|
376
|
+
needs: 'rendered',
|
|
377
|
+
verified: false,
|
|
378
|
+
description: 'Too many distinct font sizes, radii, shadows, or spacing values off a consistent scale.',
|
|
379
|
+
run({ crawl }) {
|
|
380
|
+
const agg = { fontSizes: {}, radii: {}, shadows: {}, spacing: {}, fontFamilies: {} };
|
|
381
|
+
const pages = pagesWith(crawl, 'desktop');
|
|
382
|
+
if (!pages.length)
|
|
383
|
+
return [];
|
|
384
|
+
for (const { facts } of pages) {
|
|
385
|
+
for (const k of Object.keys(agg)) {
|
|
386
|
+
for (const [v, n] of Object.entries(facts.styles[k]))
|
|
387
|
+
agg[k][v] = (agg[k][v] ?? 0) + n;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const out = [];
|
|
391
|
+
const metric = (detail) => ({ type: 'metric', detail });
|
|
392
|
+
const sizes = Object.keys(agg.fontSizes).sort((a, b) => px(a) - px(b));
|
|
393
|
+
if (sizes.length > 10) {
|
|
394
|
+
out.push({
|
|
395
|
+
title: `${sizes.length} different font sizes`,
|
|
396
|
+
severity: sizes.length > 14 ? 'medium' : 'low',
|
|
397
|
+
confidence: 0.6,
|
|
398
|
+
description: `Text uses ${sizes.length} distinct font sizes (${sizes.join(', ')}). A type scale usually needs 6 to 8 steps; more than that blurs hierarchy.`,
|
|
399
|
+
recommendation: 'Define a type scale as tokens and map every text style to it. Collapse sizes within 1 to 2px of each other.',
|
|
400
|
+
evidence: topEntries(agg.fontSizes, 12).map(([v, n]) => metric(`${v} used by ${n} elements`)),
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
const radii = Object.keys(agg.radii).filter((r) => px(r) < 9999 && px(r) > 0);
|
|
404
|
+
if (radii.length > 6) {
|
|
405
|
+
out.push({
|
|
406
|
+
title: `${radii.length} different corner radii`,
|
|
407
|
+
severity: 'low',
|
|
408
|
+
confidence: 0.6,
|
|
409
|
+
description: `Elements use ${radii.length} distinct border radii (${radii.sort((a, b) => px(a) - px(b)).join(', ')}).`,
|
|
410
|
+
recommendation: 'Use two or three radius tokens (for example small controls, cards, and full pills) and apply them by component type.',
|
|
411
|
+
evidence: topEntries(agg.radii, 10).map(([v, n]) => metric(`${v} on ${n} elements`)),
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
const shadows = Object.keys(agg.shadows);
|
|
415
|
+
if (shadows.length > 6) {
|
|
416
|
+
out.push({
|
|
417
|
+
title: `${shadows.length} different shadows`,
|
|
418
|
+
severity: 'low',
|
|
419
|
+
confidence: 0.6,
|
|
420
|
+
description: `There are ${shadows.length} distinct box-shadow values. Elevation without a system makes depth meaningless.`,
|
|
421
|
+
recommendation: 'Define two or three elevation levels and use them consistently.',
|
|
422
|
+
evidence: topEntries(agg.shadows, 6).map(([v, n]) => metric(`${truncate(v, 90)} on ${n} elements`)),
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
let onGrid = 0;
|
|
426
|
+
let offGrid = 0;
|
|
427
|
+
const offValues = {};
|
|
428
|
+
for (const [v, n] of Object.entries(agg.spacing)) {
|
|
429
|
+
const p = px(v);
|
|
430
|
+
if (p <= 2)
|
|
431
|
+
continue;
|
|
432
|
+
if (Math.abs(p / 4 - Math.round(p / 4)) < 0.01)
|
|
433
|
+
onGrid += n;
|
|
434
|
+
else {
|
|
435
|
+
offGrid += n;
|
|
436
|
+
offValues[v] = n;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (onGrid + offGrid >= 30 && offGrid / (onGrid + offGrid) > 0.3) {
|
|
440
|
+
out.push({
|
|
441
|
+
title: 'Spacing off a consistent scale',
|
|
442
|
+
severity: 'low',
|
|
443
|
+
confidence: 0.35,
|
|
444
|
+
description: `${Math.round((offGrid / (onGrid + offGrid)) * 100)}% of padding, margin, and gap values are not multiples of 4px.`,
|
|
445
|
+
recommendation: 'Adopt a spacing scale (4px or 8px based) and replace one-off values with tokens.',
|
|
446
|
+
evidence: topEntries(offValues, 8).map(([v, n]) => metric(`${v} used ${n} times`)),
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
const families = Object.keys(agg.fontFamilies);
|
|
450
|
+
if (families.length > 3) {
|
|
451
|
+
out.push({
|
|
452
|
+
title: `${families.length} font families`,
|
|
453
|
+
severity: 'low',
|
|
454
|
+
confidence: 0.6,
|
|
455
|
+
description: `Text is set in ${families.length} font families: ${families.join(', ')}.`,
|
|
456
|
+
recommendation: 'Use one family for text and at most one for display or code.',
|
|
457
|
+
evidence: topEntries(agg.fontFamilies, 6).map(([v, n]) => metric(`${v}: ${n} elements`)),
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
return out;
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
export const visualRules = [stockGradient, glowEffects, cardGridSameness, repeatedRhythm, crossPageTemplate, stockLandingSequence, centeredEverything, emojiIcons, tokenSprawl];
|
package/dist/score.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Category, CategoryScore, Coverage, Finding, Severity } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Versioned scoring configuration. The score measures detected quality problems, not the
|
|
4
|
+
* probability of AI authorship: 0 means few slop indicators were found, 100 means many or
|
|
5
|
+
* severe ones. Weights are starting hypotheses to be calibrated against human judgments.
|
|
6
|
+
*/
|
|
7
|
+
export declare const SCORING: {
|
|
8
|
+
readonly version: '2.0.0';
|
|
9
|
+
readonly categoryWeights: Record<Category, number>;
|
|
10
|
+
readonly severityValues: Record<Severity, number>;
|
|
11
|
+
/** Default rule penalty w. Rules may override it with `penalty`. */
|
|
12
|
+
readonly defaultPenalty: 40;
|
|
13
|
+
/** P at which a category reaches ~63% (1 - 1/e). Larger = slower saturation. */
|
|
14
|
+
readonly saturation: 100;
|
|
15
|
+
};
|
|
16
|
+
export declare const CATEGORY_LABELS: Record<Category, string>;
|
|
17
|
+
/** p = s * c * w, rounded to 0.01 so the score is stable across platforms. */
|
|
18
|
+
export declare function findingPenalty(f: Pick<Finding, 'severity' | 'confidence' | 'advisory'>, rulePenalty?: number): number;
|
|
19
|
+
/** S = 100 (1 - e^(-P / saturation)), so many minor findings cannot exceed 100. */
|
|
20
|
+
export declare function saturate(P: number): number;
|
|
21
|
+
/**
|
|
22
|
+
* Category score from the deduplicated findings, then the weighted average of the measured
|
|
23
|
+
* categories. Categories that could not be measured are null and excluded, with the
|
|
24
|
+
* remaining weights renormalized, so a static-only run is not reported as defect-free.
|
|
25
|
+
*/
|
|
26
|
+
export declare function scoreFindings(findings: Finding[], coverage: Pick<Coverage, 'rendered' | 'staticAnalysis'>, penalties?: Record<string, number | undefined>): {
|
|
27
|
+
score: number | null;
|
|
28
|
+
categories: CategoryScore[];
|
|
29
|
+
};
|
|
30
|
+
/** Fix-first order: verified defects, then severity, confidence, category weight, evidence count. */
|
|
31
|
+
export declare function prioritize(findings: Finding[]): Finding[];
|
package/dist/score.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Versioned scoring configuration. The score measures detected quality problems, not the
|
|
3
|
+
* probability of AI authorship: 0 means few slop indicators were found, 100 means many or
|
|
4
|
+
* severe ones. Weights are starting hypotheses to be calibrated against human judgments.
|
|
5
|
+
*/
|
|
6
|
+
export const SCORING = {
|
|
7
|
+
version: '2.0.0',
|
|
8
|
+
categoryWeights: { visual: 0.4, content: 0.2, functional: 0.15, accessibility: 0.15, technical: 0.1 },
|
|
9
|
+
severityValues: { info: 0, low: 0.25, medium: 0.5, high: 0.75, critical: 1 },
|
|
10
|
+
/** Default rule penalty w. Rules may override it with `penalty`. */
|
|
11
|
+
defaultPenalty: 40,
|
|
12
|
+
/** P at which a category reaches ~63% (1 - 1/e). Larger = slower saturation. */
|
|
13
|
+
saturation: 100,
|
|
14
|
+
};
|
|
15
|
+
export const CATEGORY_LABELS = {
|
|
16
|
+
visual: 'Visual genericness',
|
|
17
|
+
content: 'Content genericness',
|
|
18
|
+
functional: 'Functional defects',
|
|
19
|
+
accessibility: 'Accessibility and usability',
|
|
20
|
+
technical: 'Technical quality',
|
|
21
|
+
};
|
|
22
|
+
/** Categories that can only be measured by rendering the site. */
|
|
23
|
+
const RENDER_ONLY = new Set(['functional', 'accessibility', 'technical']);
|
|
24
|
+
/** p = s * c * w, rounded to 0.01 so the score is stable across platforms. */
|
|
25
|
+
export function findingPenalty(f, rulePenalty = SCORING.defaultPenalty) {
|
|
26
|
+
if (f.advisory)
|
|
27
|
+
return 0;
|
|
28
|
+
return Math.round(SCORING.severityValues[f.severity] * f.confidence * rulePenalty * 100) / 100;
|
|
29
|
+
}
|
|
30
|
+
/** S = 100 (1 - e^(-P / saturation)), so many minor findings cannot exceed 100. */
|
|
31
|
+
export function saturate(P) {
|
|
32
|
+
return Math.round(100 * (1 - Math.exp(-Math.max(0, P) / SCORING.saturation)) * 10) / 10;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Category score from the deduplicated findings, then the weighted average of the measured
|
|
36
|
+
* categories. Categories that could not be measured are null and excluded, with the
|
|
37
|
+
* remaining weights renormalized, so a static-only run is not reported as defect-free.
|
|
38
|
+
*/
|
|
39
|
+
export function scoreFindings(findings, coverage, penalties = {}) {
|
|
40
|
+
const categories = Object.keys(SCORING.categoryWeights).map((category) => {
|
|
41
|
+
const measured = coverage.rendered || (!RENDER_ONLY.has(category) && coverage.staticAnalysis);
|
|
42
|
+
const deductions = findings
|
|
43
|
+
.filter((f) => f.category === category && !f.advisory)
|
|
44
|
+
.map((f) => ({ findingId: f.id, ruleId: f.ruleId, penalty: findingPenalty(f, penalties[f.ruleId] ?? SCORING.defaultPenalty) }))
|
|
45
|
+
.filter((d) => d.penalty > 0);
|
|
46
|
+
const penalty = Math.round(deductions.reduce((s, d) => s + d.penalty, 0) * 100) / 100;
|
|
47
|
+
return { category, weight: SCORING.categoryWeights[category], penalty, score: measured ? saturate(penalty) : null, deductions };
|
|
48
|
+
});
|
|
49
|
+
const measured = categories.filter((c) => c.score !== null);
|
|
50
|
+
if (!measured.length)
|
|
51
|
+
return { score: null, categories };
|
|
52
|
+
const wsum = measured.reduce((s, c) => s + c.weight, 0);
|
|
53
|
+
const score = Math.round((measured.reduce((s, c) => s + c.score * c.weight, 0) / wsum) * 10) / 10;
|
|
54
|
+
return { score, categories };
|
|
55
|
+
}
|
|
56
|
+
const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
|
|
57
|
+
/** Fix-first order: verified defects, then severity, confidence, category weight, evidence count. */
|
|
58
|
+
export function prioritize(findings) {
|
|
59
|
+
return [...findings].sort((a, b) => Number(!!a.advisory) - Number(!!b.advisory) ||
|
|
60
|
+
SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] ||
|
|
61
|
+
Number(b.verified) - Number(a.verified) ||
|
|
62
|
+
b.confidence - a.confidence ||
|
|
63
|
+
SCORING.categoryWeights[b.category] - SCORING.categoryWeights[a.category] ||
|
|
64
|
+
b.evidence.length - a.evidence.length ||
|
|
65
|
+
a.fingerprint.localeCompare(b.fingerprint));
|
|
66
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type NetworkPolicy } from './net-policy.js';
|
|
2
|
+
export interface GuardedResponse {
|
|
3
|
+
url: string;
|
|
4
|
+
status: number;
|
|
5
|
+
headers: Record<string, string | string[]>;
|
|
6
|
+
body: Buffer;
|
|
7
|
+
}
|
|
8
|
+
export interface RequestOptions {
|
|
9
|
+
policy: NetworkPolicy;
|
|
10
|
+
method?: string;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
body?: Buffer | null;
|
|
13
|
+
maxBytes: number;
|
|
14
|
+
timeoutMs: number;
|
|
15
|
+
}
|
|
16
|
+
export declare class TooLargeError extends Error {
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* One HTTP request, no redirects followed. The connection goes to the address that
|
|
20
|
+
* resolveTarget() validated, never to a fresh DNS answer.
|
|
21
|
+
*/
|
|
22
|
+
export declare function guardedRequest(rawUrl: string, opts: RequestOptions): Promise<GuardedResponse>;
|
|
23
|
+
/** GET with redirects, re-validating every hop against the policy. */
|
|
24
|
+
export declare function guardedFetch(url: string, opts: RequestOptions & {
|
|
25
|
+
maxRedirects?: number;
|
|
26
|
+
}): Promise<GuardedResponse>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import zlib from 'node:zlib';
|
|
4
|
+
import { BlockedError, resolveTarget } from './net-policy.js';
|
|
5
|
+
export class TooLargeError extends Error {
|
|
6
|
+
}
|
|
7
|
+
const DROP_REQUEST = new Set(['host', 'connection', 'content-length', 'accept-encoding', 'proxy-authorization', 'proxy-connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'te']);
|
|
8
|
+
/**
|
|
9
|
+
* One HTTP request, no redirects followed. The connection goes to the address that
|
|
10
|
+
* resolveTarget() validated, never to a fresh DNS answer.
|
|
11
|
+
*/
|
|
12
|
+
export async function guardedRequest(rawUrl, opts) {
|
|
13
|
+
const target = await resolveTarget(rawUrl, opts.policy);
|
|
14
|
+
const lookup = (_host, options, cb) => {
|
|
15
|
+
if (options.all)
|
|
16
|
+
cb(null, [{ address: target.address, family: target.family }]);
|
|
17
|
+
else
|
|
18
|
+
cb(null, target.address, target.family);
|
|
19
|
+
};
|
|
20
|
+
const headers = {};
|
|
21
|
+
for (const [k, v] of Object.entries(opts.headers ?? {}))
|
|
22
|
+
if (!DROP_REQUEST.has(k.toLowerCase()) && !k.startsWith(':'))
|
|
23
|
+
headers[k] = v;
|
|
24
|
+
headers['accept-encoding'] = 'gzip, deflate, br';
|
|
25
|
+
const mod = target.url.protocol === 'https:' ? https : http;
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const req = mod.request(target.url, { method: opts.method ?? 'GET', headers, lookup, timeout: opts.timeoutMs, agent: false }, (res) => {
|
|
28
|
+
const declared = Number(res.headers['content-length'] ?? 0);
|
|
29
|
+
if (declared > opts.maxBytes) {
|
|
30
|
+
res.destroy();
|
|
31
|
+
reject(new TooLargeError(`response is ${declared} bytes, over the ${opts.maxBytes} byte limit`));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const enc = String(res.headers['content-encoding'] ?? '').toLowerCase();
|
|
35
|
+
const stream = enc === 'gzip' || enc === 'x-gzip' ? res.pipe(zlib.createGunzip()) : enc === 'deflate' ? res.pipe(zlib.createInflate()) : enc === 'br' ? res.pipe(zlib.createBrotliDecompress()) : res;
|
|
36
|
+
const chunks = [];
|
|
37
|
+
let size = 0;
|
|
38
|
+
stream.on('data', (c) => {
|
|
39
|
+
size += c.length;
|
|
40
|
+
if (size > opts.maxBytes) {
|
|
41
|
+
res.destroy();
|
|
42
|
+
stream.destroy();
|
|
43
|
+
reject(new TooLargeError(`response exceeded the ${opts.maxBytes} byte limit`));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
chunks.push(c);
|
|
47
|
+
});
|
|
48
|
+
stream.on('end', () => {
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const [k, v] of Object.entries(res.headers))
|
|
51
|
+
if (v !== undefined)
|
|
52
|
+
out[k] = v;
|
|
53
|
+
if (enc) {
|
|
54
|
+
delete out['content-encoding'];
|
|
55
|
+
delete out['content-length'];
|
|
56
|
+
}
|
|
57
|
+
resolve({ url: target.url.href, status: res.statusCode ?? 0, headers: out, body: Buffer.concat(chunks) });
|
|
58
|
+
});
|
|
59
|
+
stream.on('error', reject);
|
|
60
|
+
});
|
|
61
|
+
req.on('timeout', () => req.destroy(new Error(`timed out after ${opts.timeoutMs}ms`)));
|
|
62
|
+
req.on('error', reject);
|
|
63
|
+
if (opts.body && opts.body.length)
|
|
64
|
+
req.write(opts.body);
|
|
65
|
+
req.end();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/** GET with redirects, re-validating every hop against the policy. */
|
|
69
|
+
export async function guardedFetch(url, opts) {
|
|
70
|
+
const max = opts.maxRedirects ?? 5;
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
let current = url;
|
|
73
|
+
for (let hop = 0; hop <= max; hop++) {
|
|
74
|
+
if (seen.has(current))
|
|
75
|
+
throw new BlockedError(`redirect loop at ${current}`, current);
|
|
76
|
+
seen.add(current);
|
|
77
|
+
const res = await guardedRequest(current, opts);
|
|
78
|
+
const location = res.headers['location'];
|
|
79
|
+
if (res.status >= 300 && res.status < 400 && location) {
|
|
80
|
+
current = new URL(String(location), current).href;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
return res;
|
|
84
|
+
}
|
|
85
|
+
throw new BlockedError(`more than ${max} redirects`, url);
|
|
86
|
+
}
|