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.
Files changed (62) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +224 -2
  4. package/dist/ai/local-model.d.ts +26 -0
  5. package/dist/ai/local-model.js +91 -0
  6. package/dist/audit.d.ts +18 -0
  7. package/dist/audit.js +250 -0
  8. package/dist/cli.d.ts +2 -0
  9. package/dist/cli.js +166 -0
  10. package/dist/config.d.ts +34 -0
  11. package/dist/config.js +40 -0
  12. package/dist/index.d.ts +6 -0
  13. package/dist/index.js +5 -0
  14. package/dist/render/crawl.d.ts +34 -0
  15. package/dist/render/crawl.js +337 -0
  16. package/dist/render/extract.d.ts +8 -0
  17. package/dist/render/extract.js +441 -0
  18. package/dist/render/interact.d.ts +8 -0
  19. package/dist/render/interact.js +76 -0
  20. package/dist/render/network.d.ts +33 -0
  21. package/dist/render/network.js +92 -0
  22. package/dist/render/robots.d.ts +8 -0
  23. package/dist/render/robots.js +61 -0
  24. package/dist/render/server.d.ts +15 -0
  25. package/dist/render/server.js +174 -0
  26. package/dist/report/html.d.ts +2 -0
  27. package/dist/report/html.js +368 -0
  28. package/dist/report/prompt.d.ts +12 -0
  29. package/dist/report/prompt.js +62 -0
  30. package/dist/report/terminal.d.ts +6 -0
  31. package/dist/report/terminal.js +73 -0
  32. package/dist/rules/content.d.ts +8 -0
  33. package/dist/rules/content.js +227 -0
  34. package/dist/rules/functional.d.ts +17 -0
  35. package/dist/rules/functional.js +467 -0
  36. package/dist/rules/helpers.d.ts +11 -0
  37. package/dist/rules/helpers.js +29 -0
  38. package/dist/rules/index.d.ts +13 -0
  39. package/dist/rules/index.js +100 -0
  40. package/dist/rules/normalize.d.ts +18 -0
  41. package/dist/rules/normalize.js +114 -0
  42. package/dist/rules/static.d.ts +9 -0
  43. package/dist/rules/static.js +314 -0
  44. package/dist/rules/types.d.ts +39 -0
  45. package/dist/rules/types.js +16 -0
  46. package/dist/rules/visual.d.ts +15 -0
  47. package/dist/rules/visual.js +463 -0
  48. package/dist/score.d.ts +31 -0
  49. package/dist/score.js +66 -0
  50. package/dist/security/fetch.d.ts +26 -0
  51. package/dist/security/fetch.js +86 -0
  52. package/dist/security/net-policy.d.ts +46 -0
  53. package/dist/security/net-policy.js +178 -0
  54. package/dist/static/scan.d.ts +15 -0
  55. package/dist/static/scan.js +191 -0
  56. package/dist/types.d.ts +316 -0
  57. package/dist/types.js +1 -0
  58. package/dist/util/color.d.ts +25 -0
  59. package/dist/util/color.js +180 -0
  60. package/dist/util/text.d.ts +22 -0
  61. package/dist/util/text.js +106 -0
  62. package/package.json +69 -4
@@ -0,0 +1,114 @@
1
+ import { createHash } from 'node:crypto';
2
+ import path from 'node:path';
3
+ const SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'info']);
4
+ const MAX_EVIDENCE = 24;
5
+ /** Common credential shapes. Matches are replaced before anything reaches a report. */
6
+ const SECRET_PATTERNS = [
7
+ /\b(?:sk|pk|rk)[-_](?:live|test|proj|ant)?[-_]?[A-Za-z0-9_-]{16,}\b/g, // Stripe, OpenAI, Anthropic style
8
+ /\bgh[pousr]_[A-Za-z0-9]{30,}\b/g, // GitHub
9
+ /\bAKIA[0-9A-Z]{16}\b/g, // AWS access key id
10
+ /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g, // Slack
11
+ /\bAIza[0-9A-Za-z_-]{35}\b/g, // Google API key
12
+ /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, // JWT
13
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g,
14
+ /((?:api[_-]?key|secret|token|password|passwd|auth|bearer)["']?\s*[:=]\s*["']?)[^\s"'`,;]{8,}/gi,
15
+ ];
16
+ export function redact(text) {
17
+ let out = text;
18
+ for (const re of SECRET_PATTERNS)
19
+ out = out.replace(re, (m, prefix) => (typeof prefix === 'string' && m.startsWith(prefix) ? `${prefix}[redacted]` : '[redacted]'));
20
+ return out;
21
+ }
22
+ /** A source path is kept only if it is relative, inside the project, and in the scanned file set. */
23
+ export function safeSourcePath(file, known) {
24
+ if (!file || path.isAbsolute(file) || /^[a-zA-Z]:/.test(file))
25
+ return undefined;
26
+ const norm = path.posix.normalize(file.replace(/\\/g, '/'));
27
+ if (norm.startsWith('../') || norm === '..' || norm.includes('\0'))
28
+ return undefined;
29
+ if (known && !known.has(norm))
30
+ return undefined;
31
+ return norm;
32
+ }
33
+ function evidenceIdentity(e) {
34
+ return [e.type, e.viewport ?? '', e.selector ?? '', e.file ?? '', e.line ?? '', e.excerpt ?? '', e.detail ?? ''].join('|');
35
+ }
36
+ /**
37
+ * Identical evidence on several routes (the same footer link on every page) is one problem
38
+ * in one shared component. Keep the first occurrence and list the other routes.
39
+ */
40
+ export function collapseEvidence(evidence) {
41
+ const byKey = new Map();
42
+ for (const e of evidence) {
43
+ const key = evidenceIdentity(e);
44
+ const prev = byKey.get(key);
45
+ if (!prev)
46
+ byKey.set(key, { ...e });
47
+ else if (e.route && e.route !== prev.route)
48
+ prev.alsoOn = [...new Set([...(prev.alsoOn ?? []), e.route])];
49
+ }
50
+ return [...byKey.values()];
51
+ }
52
+ /** Stable hash of rule identity and evidence identity, independent of routes and order. */
53
+ export function fingerprint(ruleId, evidence) {
54
+ const keys = [...new Set(evidence.map(evidenceIdentity))].sort();
55
+ return createHash('sha256').update(`${ruleId}\n${keys.join('\n')}`).digest('hex').slice(0, 16);
56
+ }
57
+ export function validateDraft(d) {
58
+ if (typeof d.confidence !== 'number' || !Number.isFinite(d.confidence) || d.confidence < 0 || d.confidence > 1) {
59
+ throw new RangeError(`Rule ${d.ruleId} produced confidence ${d.confidence}; it must be a number from 0 to 1.`);
60
+ }
61
+ if (!SEVERITIES.has(d.severity))
62
+ throw new RangeError(`Rule ${d.ruleId} produced unknown severity ${d.severity}.`);
63
+ if (!d.ruleId || !d.description)
64
+ throw new RangeError('Findings need a rule id and a description.');
65
+ }
66
+ function sanitizeEvidence(e, knownFiles) {
67
+ const out = { ...e };
68
+ if (out.file !== undefined) {
69
+ const safe = safeSourcePath(out.file, knownFiles);
70
+ if (safe)
71
+ out.file = safe;
72
+ else {
73
+ delete out.file;
74
+ delete out.line;
75
+ }
76
+ }
77
+ if (out.line !== undefined && (!Number.isInteger(out.line) || out.line < 1))
78
+ delete out.line;
79
+ if (out.excerpt)
80
+ out.excerpt = redact(out.excerpt).slice(0, 400);
81
+ if (out.detail)
82
+ out.detail = redact(out.detail).slice(0, 400);
83
+ return out;
84
+ }
85
+ const SEV_RANK = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
86
+ /**
87
+ * Validates drafts, cleans evidence, assigns fingerprints and ids, and merges findings that
88
+ * share a fingerprint (same rule, same evidence), keeping the higher severity and confidence.
89
+ */
90
+ export function normalizeFindings(drafts, knownFiles) {
91
+ const byFp = new Map();
92
+ for (const d of drafts) {
93
+ validateDraft(d);
94
+ const evidence = collapseEvidence(d.evidence.map((e) => sanitizeEvidence(e, knownFiles)));
95
+ const fp = fingerprint(d.ruleId, evidence);
96
+ const existing = byFp.get(fp);
97
+ if (existing) {
98
+ if (SEV_RANK[d.severity] > SEV_RANK[existing.severity])
99
+ existing.severity = d.severity;
100
+ existing.confidence = Math.max(existing.confidence, d.confidence);
101
+ existing.routes = [...new Set([...(existing.routes ?? []), ...(d.routes ?? [])])];
102
+ existing.evidence = collapseEvidence([...existing.evidence, ...evidence]).slice(0, MAX_EVIDENCE);
103
+ continue;
104
+ }
105
+ byFp.set(fp, {
106
+ ...d,
107
+ description: redact(d.description),
108
+ evidence: evidence.slice(0, MAX_EVIDENCE),
109
+ fingerprint: fp,
110
+ id: `${d.ruleId}-${fp.slice(0, 8)}`,
111
+ });
112
+ }
113
+ return [...byFp.values()];
114
+ }
@@ -0,0 +1,9 @@
1
+ import type { Rule } from './types.js';
2
+ export declare const srcStockGradient: Rule;
3
+ export declare const srcGlow: Rule;
4
+ export declare const srcEmoji: Rule;
5
+ export declare const srcPlaceholder: Rule;
6
+ export declare const srcStockCopy: Rule;
7
+ export declare const arbitraryValues: Rule;
8
+ export declare const duplicatedMarkup: Rule;
9
+ export declare const staticRules: Rule[];
@@ -0,0 +1,314 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isStockGradient } from '../util/color.js';
3
+ import { findPlaceholders, findStockPhrases, firstEmoji, hasEmoji, truncate } from '../util/text.js';
4
+ import { plural } from './helpers.js';
5
+ const MARKUP = new Set(['.html', '.htm', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.mdx']);
6
+ const STYLE = new Set(['.css', '.scss', '.sass', '.less']);
7
+ function* lines(f) {
8
+ const arr = f.content.split('\n');
9
+ for (let i = 0; i < arr.length; i++)
10
+ yield { text: arr[i], line: i + 1 };
11
+ }
12
+ function src(f, line, text, detail) {
13
+ return { type: 'source', file: f.path, line, excerpt: truncate(text.trim(), 160), detail };
14
+ }
15
+ const isComment = (t) => /^\s*(?:\/\/|\/\*|\*|<!--|\{\s*\/\*)/.test(t);
16
+ /** Text that appears between tags or in string literals; skips attribute names like placeholder= and className=. */
17
+ function visibleTextish(line) {
18
+ return line
19
+ .replace(/\b(?:className|class|placeholder|alt|href|src|id|key|style|aria-[\w-]+|data-[\w-]+|type|name|for|htmlFor)\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\})/g, ' ')
20
+ .replace(/\bimport\b.*$/, ' ');
21
+ }
22
+ const TW_GRADIENT = /\bbg-(?:gradient|linear)-to-[trbl]{1,2}\b|\bbg-(?:radial|conic)\b/;
23
+ const TW_SYNTH_STOP = /\b(?:from|via|to)-(?:indigo|violet|purple|fuchsia|pink)-\d{2,3}\b/;
24
+ const TW_TEXT_GRADIENT = /\bbg-clip-text\b.*\btext-transparent\b|\btext-transparent\b.*\bbg-clip-text\b/;
25
+ export const srcStockGradient = {
26
+ id: 'src-stock-gradient',
27
+ title: 'Stock gradients in source',
28
+ category: 'visual',
29
+ needs: 'static',
30
+ verified: false,
31
+ mergeInto: 'stock-gradient',
32
+ description: 'Tailwind gradient utilities and CSS gradients using the indigo/violet/pink band.',
33
+ run({ repo }) {
34
+ const ev = [];
35
+ for (const f of repo?.files ?? []) {
36
+ for (const { text, line } of lines(f)) {
37
+ if (isComment(text))
38
+ continue;
39
+ if (MARKUP.has(f.ext) || f.ext === '.js' || f.ext === '.ts') {
40
+ if (TW_GRADIENT.test(text) && TW_SYNTH_STOP.test(text))
41
+ ev.push(src(f, line, text, TW_TEXT_GRADIENT.test(text) ? 'gradient text' : 'gradient'));
42
+ else if (TW_TEXT_GRADIENT.test(text) && TW_SYNTH_STOP.test(text))
43
+ ev.push(src(f, line, text, 'gradient text'));
44
+ }
45
+ if ((STYLE.has(f.ext) || MARKUP.has(f.ext)) && /gradient\(/.test(text) && isStockGradient(text))
46
+ ev.push(src(f, line, text, 'css gradient'));
47
+ }
48
+ }
49
+ if (!ev.length)
50
+ return [];
51
+ return [
52
+ {
53
+ severity: ev.length >= 4 ? 'medium' : 'low',
54
+ confidence: 0.6,
55
+ description: `${plural(ev.length, 'line')} in source define blue-violet-pink gradients.`,
56
+ recommendation: 'Replace stock gradient utilities with brand color tokens. Prefer solid fills; use gradients only where they carry meaning.',
57
+ evidence: ev,
58
+ },
59
+ ];
60
+ },
61
+ };
62
+ export const srcGlow = {
63
+ id: 'src-glow-effects',
64
+ title: 'Glow and glass utilities in source',
65
+ category: 'visual',
66
+ needs: 'static',
67
+ verified: false,
68
+ mergeInto: 'glow-effects',
69
+ description: 'backdrop-blur, large blur blobs, and colored shadows in source.',
70
+ run({ repo }) {
71
+ const ev = [];
72
+ for (const f of repo?.files ?? []) {
73
+ if (!MARKUP.has(f.ext) && !STYLE.has(f.ext))
74
+ continue;
75
+ for (const { text, line } of lines(f)) {
76
+ if (isComment(text))
77
+ continue;
78
+ if (/\bblur-(?:2xl|3xl|\[\d{2,}px\])\b/.test(text))
79
+ ev.push(src(f, line, text, 'blur blob'));
80
+ else if (/\bshadow-(?:indigo|violet|purple|fuchsia|pink|blue|cyan)-\d{2,3}(?:\/\d+)?\b|shadow-\[0_0_\d{2,}px/.test(text))
81
+ ev.push(src(f, line, text, 'colored glow'));
82
+ else if (/\bbackdrop-blur(?:-\w+)?\b|backdrop-filter:\s*blur/.test(text))
83
+ ev.push(src(f, line, text, 'glass panel'));
84
+ }
85
+ }
86
+ if (ev.length < 3)
87
+ return [];
88
+ return [
89
+ {
90
+ severity: ev.length >= 8 ? 'medium' : 'low',
91
+ confidence: 0.6,
92
+ description: `${plural(ev.length, 'line')} in source add glows, blur blobs, or glass panels.`,
93
+ recommendation: 'Remove decorative blur and glow utilities; keep neutral, systematic elevation.',
94
+ evidence: ev,
95
+ },
96
+ ];
97
+ },
98
+ };
99
+ export const srcEmoji = {
100
+ id: 'src-emoji-icons',
101
+ title: 'Emoji in component markup',
102
+ category: 'visual',
103
+ needs: 'static',
104
+ verified: false,
105
+ mergeInto: 'emoji-icons',
106
+ description: 'Emoji characters in markup and component files.',
107
+ run({ repo }) {
108
+ const ev = [];
109
+ for (const f of repo?.files ?? []) {
110
+ if (!MARKUP.has(f.ext) && f.ext !== '.ts' && f.ext !== '.js')
111
+ continue;
112
+ for (const { text, line } of lines(f)) {
113
+ if (isComment(text) || !hasEmoji(text))
114
+ continue;
115
+ if (/console\.(?:log|warn|error)/.test(text))
116
+ continue;
117
+ ev.push(src(f, line, text, `emoji ${firstEmoji(text)}`));
118
+ }
119
+ }
120
+ if (ev.length < 2)
121
+ return [];
122
+ return [
123
+ {
124
+ severity: ev.length >= 5 ? 'medium' : 'low',
125
+ confidence: 0.6,
126
+ description: `${plural(ev.length, 'line')} of markup or content data contain emoji.`,
127
+ recommendation: 'Use a consistent SVG icon set instead of emoji.',
128
+ evidence: ev,
129
+ },
130
+ ];
131
+ },
132
+ };
133
+ export const srcPlaceholder = {
134
+ id: 'src-placeholder-content',
135
+ title: 'Placeholder content in source',
136
+ category: 'content',
137
+ needs: 'static',
138
+ verified: true,
139
+ mergeInto: 'placeholder-content',
140
+ description: 'Lorem ipsum, sample names, and example domains in markup.',
141
+ run({ repo }) {
142
+ const ev = [];
143
+ for (const f of repo?.files ?? []) {
144
+ if (!MARKUP.has(f.ext) && f.ext !== '.ts' && f.ext !== '.js')
145
+ continue;
146
+ for (const { text, line } of lines(f)) {
147
+ if (isComment(text))
148
+ continue;
149
+ const m = findPlaceholders(visibleTextish(text)).filter((x) => x.phrase !== 'TODO marker');
150
+ if (m.length)
151
+ ev.push(src(f, line, text, m.map((x) => x.phrase).join(', ')));
152
+ }
153
+ }
154
+ if (!ev.length)
155
+ return [];
156
+ return [
157
+ {
158
+ severity: ev.length >= 3 ? 'high' : 'medium',
159
+ confidence: 0.6,
160
+ description: `${plural(ev.length, 'line')} in source contain placeholder content.`,
161
+ recommendation: 'Replace placeholders with real content before shipping.',
162
+ evidence: ev,
163
+ },
164
+ ];
165
+ },
166
+ };
167
+ export const srcStockCopy = {
168
+ id: 'src-stock-copy',
169
+ title: 'Stock marketing phrases in source',
170
+ category: 'content',
171
+ needs: 'static',
172
+ verified: false,
173
+ mergeInto: 'stock-copy',
174
+ description: 'Stock phrases in markup and content files.',
175
+ run({ repo }) {
176
+ const ev = [];
177
+ for (const f of repo?.files ?? []) {
178
+ if (!MARKUP.has(f.ext))
179
+ continue;
180
+ for (const { text, line } of lines(f)) {
181
+ if (isComment(text))
182
+ continue;
183
+ const m = findStockPhrases(visibleTextish(text));
184
+ if (m.length)
185
+ ev.push(src(f, line, text, m.map((x) => `"${x.phrase}"`).join(', ')));
186
+ }
187
+ }
188
+ if (ev.length < 2)
189
+ return [];
190
+ return [
191
+ {
192
+ severity: ev.length >= 6 ? 'medium' : 'low',
193
+ confidence: 0.6,
194
+ description: `${plural(ev.length, 'line')} of copy in source use stock marketing phrases.`,
195
+ recommendation: 'Rewrite with specifics about the product: what it does, for whom, with what result.',
196
+ evidence: ev,
197
+ },
198
+ ];
199
+ },
200
+ };
201
+ export const arbitraryValues = {
202
+ id: 'arbitrary-values',
203
+ title: 'Hard-coded style values bypassing tokens',
204
+ category: 'visual',
205
+ needs: 'static',
206
+ verified: false,
207
+ penalty: 25,
208
+ description: 'Tailwind arbitrary values and raw hex colors in components instead of design tokens.',
209
+ run({ repo }) {
210
+ const ev = [];
211
+ let arbitrary = 0;
212
+ let hex = 0;
213
+ const perFile = new Map();
214
+ for (const f of repo?.files ?? []) {
215
+ if (!MARKUP.has(f.ext))
216
+ continue;
217
+ for (const { text, line } of lines(f)) {
218
+ if (isComment(text))
219
+ continue;
220
+ const a = text.match(/\b[a-z-]+-\[(?:#[0-9a-f]{3,8}|\d+(?:\.\d+)?(?:px|rem|em)|rgba?\([^\]]+\))\]/gi) ?? [];
221
+ const h = (text.match(/(?<![\w&])#[0-9a-f]{6}\b|(?<![\w&])#[0-9a-f]{3}\b/gi) ?? []).filter(() => /style|color|fill|stroke|background/i.test(text));
222
+ if (a.length || h.length) {
223
+ arbitrary += a.length;
224
+ hex += h.length;
225
+ perFile.set(f.path, (perFile.get(f.path) ?? 0) + a.length + h.length);
226
+ if (ev.length < 12)
227
+ ev.push(src(f, line, text, [...a, ...h].slice(0, 4).join(' ')));
228
+ }
229
+ }
230
+ }
231
+ const total = arbitrary + hex;
232
+ if (total < 15)
233
+ return [];
234
+ const worst = [...perFile].sort((x, y) => y[1] - x[1]).slice(0, 3).map(([p, n]) => `${p} (${n})`);
235
+ return [
236
+ {
237
+ severity: total >= 40 ? 'medium' : 'low',
238
+ confidence: 0.6,
239
+ description: `${total} hard-coded style values in components (${arbitrary} arbitrary utilities, ${hex} raw hex colors). Most in ${worst.join(', ')}.`,
240
+ recommendation: 'Move repeated values into theme tokens (Tailwind theme or CSS custom properties) so the design stays consistent and changes in one place.',
241
+ evidence: ev,
242
+ },
243
+ ];
244
+ },
245
+ };
246
+ const SHINGLE = 5;
247
+ export const duplicatedMarkup = {
248
+ id: 'duplicated-markup',
249
+ title: 'Copy-pasted component markup',
250
+ category: 'visual',
251
+ needs: 'static',
252
+ verified: false,
253
+ penalty: 25,
254
+ description: 'Identical blocks of markup repeated within or across files instead of a shared component.',
255
+ run({ repo }) {
256
+ const blocks = new Map();
257
+ for (const f of repo?.files ?? []) {
258
+ if (!MARKUP.has(f.ext))
259
+ continue;
260
+ const raw = f.content.split('\n');
261
+ const sig = [];
262
+ raw.forEach((l, i) => {
263
+ // Compare structure, not wording: three hand-written cards that differ only in text are still a copy-paste.
264
+ const t = l.trim().replace(/>[^<>{}]*</g, '><').replace(/>[^<>{}]+$/, '>');
265
+ // Every HTML page starts with the same document boilerplate; that is not copied component markup.
266
+ if (/^<(?:!doctype|\/?html|\/?head|meta|link|title|\/?body|script\b[^>]*\bsrc=)/i.test(t))
267
+ return;
268
+ if (t.length >= 12 && /[<>]/.test(t))
269
+ sig.push({ t, line: i + 1 });
270
+ });
271
+ for (let i = 0; i + SHINGLE <= sig.length; i++) {
272
+ const win = sig.slice(i, i + SHINGLE);
273
+ // Ignore windows that span a big gap; they are not one contiguous block.
274
+ if (win[SHINGLE - 1].line - win[0].line > SHINGLE * 3)
275
+ continue;
276
+ const key = createHash('sha1').update(win.map((w) => w.t).join('\n')).digest('hex');
277
+ if (!blocks.has(key))
278
+ blocks.set(key, []);
279
+ const list = blocks.get(key);
280
+ const last = list[list.length - 1];
281
+ if (last && last.file === f.path && win[0].line - last.line < SHINGLE)
282
+ continue; // overlapping window
283
+ list.push({ file: f.path, line: win[0].line, text: win[0].t });
284
+ }
285
+ }
286
+ const clusters = [...blocks.values()].filter((l) => l.length >= 3 || new Set(l.map((x) => x.file)).size >= 2);
287
+ if (!clusters.length)
288
+ return [];
289
+ // Collapse overlapping clusters: keep one per first-location.
290
+ const seenLoc = new Set();
291
+ const kept = [];
292
+ for (const c of clusters.sort((a, b) => b.length - a.length)) {
293
+ const k = `${c[0].file}:${Math.floor(c[0].line / 20)}`;
294
+ if (seenLoc.has(k))
295
+ continue;
296
+ seenLoc.add(k);
297
+ kept.push(c);
298
+ if (kept.length >= 5)
299
+ break;
300
+ }
301
+ const ev = kept.flatMap((c) => c.slice(0, 3).map((x) => ({ type: 'source', file: x.file, line: x.line, excerpt: truncate(x.text, 120), detail: `block repeated ${c.length} times` })));
302
+ const out = [
303
+ {
304
+ severity: 'low',
305
+ confidence: 0.6,
306
+ description: `${plural(kept.length, 'block')} of markup (${SHINGLE}+ lines) are repeated with the same structure${kept.some((c) => new Set(c.map((x) => x.file)).size > 1) ? ' across files' : ''}. Copy-pasted sections drift apart over time and make the design inconsistent.`,
307
+ recommendation: 'Extract the repeated block into a component and render it from data.',
308
+ evidence: ev,
309
+ },
310
+ ];
311
+ return out;
312
+ },
313
+ };
314
+ export const staticRules = [srcStockGradient, srcGlow, srcEmoji, srcPlaceholder, srcStockCopy, arbitraryValues, duplicatedMarkup];
@@ -0,0 +1,39 @@
1
+ import type { Category, CrawlResult, Evidence, Finding, RepoFacts, Severity } from '../types.js';
2
+ export interface RuleMeta {
3
+ id: string;
4
+ /** Bump when a rule's logic changes in a way that changes its findings. */
5
+ version?: number;
6
+ title: string;
7
+ category: Category;
8
+ description: string;
9
+ /** Measured defect (true) or design observation that needs judgment (false). */
10
+ verified: boolean;
11
+ /** Relative weight w in p = s * c * w. Defaults to SCORING.defaultPenalty. */
12
+ penalty?: number;
13
+ }
14
+ export interface RuleContext {
15
+ crawl?: CrawlResult;
16
+ repo?: RepoFacts;
17
+ }
18
+ export interface FindingInput {
19
+ severity: Severity;
20
+ confidence: number;
21
+ description: string;
22
+ recommendation: string;
23
+ evidence: Evidence[];
24
+ routes?: string[];
25
+ title?: string;
26
+ }
27
+ export interface Rule extends RuleMeta {
28
+ /** 'rendered' rules need a crawl, 'static' rules need a repository. */
29
+ needs: 'rendered' | 'static';
30
+ /**
31
+ * For static rules that mirror a rendered rule: when the rendered rule fires, this rule's
32
+ * source locations are attached to it as evidence instead of producing a second finding.
33
+ */
34
+ mergeInto?: string;
35
+ run(ctx: RuleContext): FindingInput[];
36
+ }
37
+ /** Raw finding before normalization assigns id and fingerprint. */
38
+ export type DraftFinding = Omit<Finding, 'id' | 'fingerprint'>;
39
+ export declare function toDraft(rule: Rule, input: FindingInput, source: Finding['source']): DraftFinding;
@@ -0,0 +1,16 @@
1
+ export function toDraft(rule, input, source) {
2
+ return {
3
+ ruleId: rule.id,
4
+ ruleVersion: rule.version ?? 1,
5
+ title: input.title ?? rule.title,
6
+ category: rule.category,
7
+ severity: input.severity,
8
+ confidence: input.confidence,
9
+ source,
10
+ description: input.description,
11
+ recommendation: input.recommendation,
12
+ evidence: input.evidence,
13
+ routes: input.routes,
14
+ verified: rule.verified,
15
+ };
16
+ }
@@ -0,0 +1,15 @@
1
+ import type { SectionFact } from '../types.js';
2
+ import type { Rule } from './types.js';
3
+ export declare const stockGradient: Rule;
4
+ export declare const glowEffects: Rule;
5
+ export declare const cardGridSameness: Rule;
6
+ export declare const repeatedRhythm: Rule;
7
+ export declare const crossPageTemplate: Rule;
8
+ type Kind = 'hero' | 'logos' | 'features' | 'testimonials' | 'pricing' | 'faq' | 'cta';
9
+ export declare function classifySection(s: SectionFact, index: number, total: number): Kind | undefined;
10
+ export declare const stockLandingSequence: Rule;
11
+ export declare const centeredEverything: Rule;
12
+ export declare const emojiIcons: Rule;
13
+ export declare const tokenSprawl: Rule;
14
+ export declare const visualRules: Rule[];
15
+ export {};