hauntview 0.0.0-stage → 0.0.6
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/LICENSE +21 -0
- package/README.md +358 -2
- package/VERSION +1 -0
- package/dist/cli.js +8184 -0
- package/package.json +120 -4
- package/scripts/postinstall.mjs +47 -0
- package/src/core/browser-launch.ts +60 -0
- package/src/core/browser-pool.ts +267 -0
- package/src/core/concurrency-resolver.ts +154 -0
- package/src/core/finding-classifier.ts +346 -0
- package/src/core/index.ts +8 -0
- package/src/core/profile-manager.ts +499 -0
- package/src/core/report-store.ts +117 -0
- package/src/core/screenshot-manager.ts +312 -0
- package/src/core/system-capability.ts +161 -0
- package/src/core/visual-diff.ts +230 -0
- package/src/engine.ts +473 -0
- package/src/profiles/accessibility-cognitive.ts +261 -0
- package/src/profiles/accessibility-keyboard.ts +225 -0
- package/src/profiles/accessibility-screenreader.ts +243 -0
- package/src/profiles/accessibility-visual.ts +257 -0
- package/src/profiles/broken-links.ts +261 -0
- package/src/profiles/browser-edge.ts +192 -0
- package/src/profiles/browser-firefox.ts +254 -0
- package/src/profiles/browser-legacy.ts +213 -0
- package/src/profiles/console-errors.ts +192 -0
- package/src/profiles/desktop-4k.ts +273 -0
- package/src/profiles/desktop.ts +192 -0
- package/src/profiles/font-failure.ts +148 -0
- package/src/profiles/form-resilience.ts +285 -0
- package/src/profiles/i18n-cjk.ts +218 -0
- package/src/profiles/i18n-mixed.ts +255 -0
- package/src/profiles/i18n-rtl.ts +236 -0
- package/src/profiles/image-failure.ts +178 -0
- package/src/profiles/index.ts +42 -0
- package/src/profiles/js-disabled.ts +179 -0
- package/src/profiles/mobile.ts +175 -0
- package/src/profiles/network-resilience.ts +208 -0
- package/src/profiles/offline.ts +165 -0
- package/src/profiles/performance.ts +244 -0
- package/src/profiles/reduced-motion.ts +144 -0
- package/src/profiles/security-hygiene.ts +206 -0
- package/src/profiles/tablet.ts +176 -0
- package/src/reports/factory.ts +56 -0
- package/src/reports/html-generator.ts +1105 -0
- package/src/reports/index.ts +9 -0
- package/src/reports/markdown-generator.ts +349 -0
- package/src/reports/types.ts +34 -0
- package/src/reports/zip-generator.ts +85 -0
- package/src/types/contracts.ts +238 -0
- package/src/types/finding-utils.ts +121 -0
- package/src/types/index.ts +8 -0
- package/src/types/profiles.ts +175 -0
- package/src/types/results.ts +135 -0
- package/src/version.ts +5 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FindingClassifier - Classifies and canonicalizes findings across profiles
|
|
3
|
+
* Handles finding normalization, deduplication, and categorization
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as crypto from 'crypto';
|
|
7
|
+
import { Finding, ClassificationResult, CacheEntry } from '../types';
|
|
8
|
+
|
|
9
|
+
interface ClassificationRule {
|
|
10
|
+
pattern: RegExp;
|
|
11
|
+
category: string;
|
|
12
|
+
severity: 'error' | 'warning' | 'info';
|
|
13
|
+
canonical: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class FindingClassifier {
|
|
17
|
+
private rules: ClassificationRule[] = [];
|
|
18
|
+
private cache: Map<string, CacheEntry> = new Map();
|
|
19
|
+
private seenFindings: Map<string, Finding> = new Map(); // hash -> canonical finding
|
|
20
|
+
private cacheTTL: number = 60 * 60 * 1000; // 1 hour
|
|
21
|
+
|
|
22
|
+
constructor() {
|
|
23
|
+
this.initializeClassificationRules();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Classify a finding
|
|
28
|
+
*/
|
|
29
|
+
async classify(finding: Finding): Promise<ClassificationResult> {
|
|
30
|
+
// Check cache first
|
|
31
|
+
const cacheKey = this.generateFindingHash(finding);
|
|
32
|
+
const cached = this.cache.get(cacheKey);
|
|
33
|
+
|
|
34
|
+
if (cached && !this.isCacheExpired(cached)) {
|
|
35
|
+
return {
|
|
36
|
+
originalFinding: finding,
|
|
37
|
+
canonical: cached.finding,
|
|
38
|
+
classifier: 'cache',
|
|
39
|
+
confidence: 1.0,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Match against classification rules
|
|
44
|
+
for (const rule of this.rules) {
|
|
45
|
+
if (rule.pattern.test(finding.message)) {
|
|
46
|
+
const canonical: Finding = {
|
|
47
|
+
...finding,
|
|
48
|
+
category: rule.category,
|
|
49
|
+
severity: rule.severity,
|
|
50
|
+
message: rule.canonical,
|
|
51
|
+
canonicalHash: this.hashFinding(rule.canonical),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Store in deduplication index
|
|
55
|
+
this.seenFindings.set(canonical.canonicalHash!, canonical);
|
|
56
|
+
|
|
57
|
+
// Cache result
|
|
58
|
+
this.cacheClassification(cacheKey, canonical);
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
originalFinding: finding,
|
|
62
|
+
canonical,
|
|
63
|
+
classifier: 'rule-based',
|
|
64
|
+
confidence: 0.95,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// If no rule matches, use heuristic classification
|
|
70
|
+
return this.classifyByHeuristic(finding, cacheKey);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Batch classify findings
|
|
75
|
+
*/
|
|
76
|
+
async classifyBatch(findings: Finding[]): Promise<ClassificationResult[]> {
|
|
77
|
+
return Promise.all(findings.map((f) => this.classify(f)));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Deduplicate findings
|
|
82
|
+
*/
|
|
83
|
+
deduplicateFindings(findings: Finding[]): Finding[] {
|
|
84
|
+
const deduplicated: Finding[] = [];
|
|
85
|
+
const seen = new Set<string>();
|
|
86
|
+
|
|
87
|
+
for (const finding of findings) {
|
|
88
|
+
const hash = finding.canonicalHash || this.hashFinding(finding.message);
|
|
89
|
+
|
|
90
|
+
if (!seen.has(hash)) {
|
|
91
|
+
seen.add(hash);
|
|
92
|
+
deduplicated.push(finding);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return deduplicated;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Group findings by category
|
|
101
|
+
*/
|
|
102
|
+
groupByCategory(findings: Finding[]): Map<string, Finding[]> {
|
|
103
|
+
const grouped = new Map<string, Finding[]>();
|
|
104
|
+
|
|
105
|
+
for (const finding of findings) {
|
|
106
|
+
const category = finding.category || 'uncategorized';
|
|
107
|
+
|
|
108
|
+
if (!grouped.has(category)) {
|
|
109
|
+
grouped.set(category, []);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
grouped.get(category)!.push(finding);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return grouped;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Group findings by severity
|
|
120
|
+
*/
|
|
121
|
+
groupBySeverity(findings: Finding[]): Map<string, Finding[]> {
|
|
122
|
+
const grouped = new Map<string, Finding[]>();
|
|
123
|
+
|
|
124
|
+
for (const finding of findings) {
|
|
125
|
+
if (!grouped.has(finding.severity)) {
|
|
126
|
+
grouped.set(finding.severity, []);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
grouped.get(finding.severity)!.push(finding);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return grouped;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Analyze finding trends
|
|
137
|
+
*/
|
|
138
|
+
analyzeTrends(
|
|
139
|
+
currentFindings: Finding[],
|
|
140
|
+
previousFindings?: Finding[]
|
|
141
|
+
): {
|
|
142
|
+
newFindings: Finding[];
|
|
143
|
+
resolvedFindings: Finding[];
|
|
144
|
+
changedFindings: Finding[];
|
|
145
|
+
} {
|
|
146
|
+
if (!previousFindings) {
|
|
147
|
+
return {
|
|
148
|
+
newFindings: currentFindings,
|
|
149
|
+
resolvedFindings: [],
|
|
150
|
+
changedFindings: [],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const currentHashes = new Set(
|
|
155
|
+
currentFindings.map((f) => f.canonicalHash || this.hashFinding(f.message))
|
|
156
|
+
);
|
|
157
|
+
const previousHashes = new Set(
|
|
158
|
+
previousFindings.map((f) => f.canonicalHash || this.hashFinding(f.message))
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const newFindings = currentFindings.filter(
|
|
162
|
+
(f) => !previousHashes.has(f.canonicalHash || this.hashFinding(f.message))
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const resolvedFindings = previousFindings.filter(
|
|
166
|
+
(f) => !currentHashes.has(f.canonicalHash || this.hashFinding(f.message))
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const changedFindings = currentFindings.filter((f) => {
|
|
170
|
+
const hash = f.canonicalHash || this.hashFinding(f.message);
|
|
171
|
+
if (!previousHashes.has(hash)) return false;
|
|
172
|
+
|
|
173
|
+
const prevFinding = previousFindings.find(
|
|
174
|
+
(pf) => (pf.canonicalHash || this.hashFinding(pf.message)) === hash
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
return prevFinding && prevFinding.severity !== f.severity;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
newFindings,
|
|
182
|
+
resolvedFindings,
|
|
183
|
+
changedFindings,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Clear classification cache
|
|
189
|
+
*/
|
|
190
|
+
clearCache(): void {
|
|
191
|
+
this.cache.clear();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Initialize classification rules
|
|
196
|
+
*/
|
|
197
|
+
private initializeClassificationRules(): void {
|
|
198
|
+
this.rules = [
|
|
199
|
+
// Accessibility rules
|
|
200
|
+
{
|
|
201
|
+
pattern: /alt.{0,50}text|missing.{0,30}alt/i,
|
|
202
|
+
category: 'accessibility',
|
|
203
|
+
severity: 'error',
|
|
204
|
+
canonical: 'Missing or invalid alt text on image element',
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
pattern: /aria.{0,50}label|missing.{0,30}aria.{0,20}label/i,
|
|
208
|
+
category: 'accessibility',
|
|
209
|
+
severity: 'warning',
|
|
210
|
+
canonical: 'Element missing ARIA label',
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
pattern: /contrast|wcag|color.{0,30}contrast/i,
|
|
214
|
+
category: 'accessibility',
|
|
215
|
+
severity: 'warning',
|
|
216
|
+
canonical: 'Insufficient color contrast ratio',
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
// Performance rules
|
|
220
|
+
{
|
|
221
|
+
pattern: /layout.{0,30}shift|cumulative.{0,30}layout|cls/i,
|
|
222
|
+
category: 'performance',
|
|
223
|
+
severity: 'warning',
|
|
224
|
+
canonical: 'Cumulative Layout Shift detected',
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
pattern: /large.{0,30}image|unoptimized.{0,30}image|image.{0,30}size/i,
|
|
228
|
+
category: 'performance',
|
|
229
|
+
severity: 'info',
|
|
230
|
+
canonical: 'Image optimization opportunity detected',
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
// Best practices rules
|
|
234
|
+
{
|
|
235
|
+
pattern: /console.{0,50}error|javascript.{0,30}error|uncaught/i,
|
|
236
|
+
category: 'errors',
|
|
237
|
+
severity: 'error',
|
|
238
|
+
canonical: 'JavaScript error in console',
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
pattern: /console.{0,50}warn|javascript.{0,30}warning/i,
|
|
242
|
+
category: 'errors',
|
|
243
|
+
severity: 'warning',
|
|
244
|
+
canonical: 'JavaScript warning in console',
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
// Security rules
|
|
248
|
+
{
|
|
249
|
+
pattern: /mixed.{0,30}content|http.{0,30}https|insecure/i,
|
|
250
|
+
category: 'security',
|
|
251
|
+
severity: 'error',
|
|
252
|
+
canonical: 'Mixed content (HTTP/HTTPS) detected',
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
// Responsive design rules
|
|
256
|
+
{
|
|
257
|
+
pattern: /text.{0,30}small|font.{0,30}size|readability/i,
|
|
258
|
+
category: 'responsive',
|
|
259
|
+
severity: 'warning',
|
|
260
|
+
canonical: 'Text size may be too small for readability',
|
|
261
|
+
},
|
|
262
|
+
];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Classify by heuristic when no rule matches
|
|
267
|
+
*/
|
|
268
|
+
private classifyByHeuristic(finding: Finding, cacheKey: string): ClassificationResult {
|
|
269
|
+
// Infer category from message content
|
|
270
|
+
const category = finding.category || 'general';
|
|
271
|
+
let severity = finding.severity || 'info';
|
|
272
|
+
|
|
273
|
+
if (finding.message.toLowerCase().includes('error')) {
|
|
274
|
+
severity = 'error';
|
|
275
|
+
} else if (finding.message.toLowerCase().includes('warning')) {
|
|
276
|
+
severity = 'warning';
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const canonical: Finding = {
|
|
280
|
+
...finding,
|
|
281
|
+
category,
|
|
282
|
+
severity,
|
|
283
|
+
canonicalHash: this.hashFinding(finding.message),
|
|
284
|
+
isClassified: true,
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
this.cacheClassification(cacheKey, canonical);
|
|
288
|
+
return {
|
|
289
|
+
originalFinding: finding,
|
|
290
|
+
canonical,
|
|
291
|
+
classifier: 'heuristic',
|
|
292
|
+
confidence: 0.6,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Generate cache key for a finding
|
|
298
|
+
*/
|
|
299
|
+
private generateFindingHash(finding: Finding): string {
|
|
300
|
+
const key = `${finding.profileId}:${finding.message}:${finding.category}`;
|
|
301
|
+
return crypto.createHash('sha256').update(key).digest('hex');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Hash finding message
|
|
306
|
+
*/
|
|
307
|
+
private hashFinding(message: string): string {
|
|
308
|
+
return crypto.createHash('sha256').update(message).digest('hex');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Cache a classification result
|
|
313
|
+
*/
|
|
314
|
+
private cacheClassification(key: string, finding: Finding): void {
|
|
315
|
+
const now = Date.now();
|
|
316
|
+
this.cache.set(key, {
|
|
317
|
+
key,
|
|
318
|
+
finding,
|
|
319
|
+
ttl: this.cacheTTL,
|
|
320
|
+
createdAt: now,
|
|
321
|
+
expiresAt: now + this.cacheTTL,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Check if cache entry is expired
|
|
327
|
+
*/
|
|
328
|
+
private isCacheExpired(entry: CacheEntry): boolean {
|
|
329
|
+
return Date.now() > entry.expiresAt;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Get cache statistics
|
|
334
|
+
*/
|
|
335
|
+
getStats(): {
|
|
336
|
+
cacheSize: number;
|
|
337
|
+
seenFindingsCount: number;
|
|
338
|
+
rulesCount: number;
|
|
339
|
+
} {
|
|
340
|
+
return {
|
|
341
|
+
cacheSize: this.cache.size,
|
|
342
|
+
seenFindingsCount: this.seenFindings.size,
|
|
343
|
+
rulesCount: this.rules.length,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core engine exports for HauntView
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { BrowserPool } from './browser-pool';
|
|
6
|
+
export { ScreenshotManager } from './screenshot-manager';
|
|
7
|
+
export { FindingClassifier } from './finding-classifier';
|
|
8
|
+
export { ProfileManager } from './profile-manager';
|