gemstack-ai 1.3.0 → 1.4.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.
@@ -0,0 +1,499 @@
1
+ /**
2
+ * Gemstack Visual QA Validation & Baseline Engine (Upgrade E)
3
+ *
4
+ * Implements deterministic viewport validation, dynamic region masking,
5
+ * cryptographic baseline hashing, and offline visual evidence comparison.
6
+ *
7
+ * ZERO RUNTIME DEPENDENCIES - Node.js built-ins exclusively.
8
+ */
9
+
10
+ const crypto = require('node:crypto');
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+ const { normalizePath } = require('./hasher');
14
+ const { createFinding } = require('./findings');
15
+
16
+ const VQA_SCHEMA_VERSION = '1.0.0';
17
+
18
+ const CANONICAL_VIEWPORT_PROFILES = {
19
+ 'desktop-standard': { width: 1920, height: 1080, device_scale_factor: 1 },
20
+ 'desktop-compact': { width: 1280, height: 800, device_scale_factor: 1 },
21
+ 'tablet-portrait': { width: 768, height: 1024, device_scale_factor: 2 },
22
+ 'mobile-portrait': { width: 375, height: 667, device_scale_factor: 2 }
23
+ };
24
+
25
+ /**
26
+ * Parses and validates visual-qa.json manifest.
27
+ *
28
+ * @param {string|object} input - Raw JSON string or parsed object
29
+ * @returns {object} Canonical parsed visual QA manifest
30
+ */
31
+ function parseVisualManifest(input) {
32
+ let parsed;
33
+ if (typeof input === 'string') {
34
+ try {
35
+ parsed = JSON.parse(input);
36
+ } catch (err) {
37
+ const error = new Error(`Invalid JSON in visual-qa manifest: ${err.message}`);
38
+ error.code = 'VQA_PARSE_ERROR';
39
+ throw error;
40
+ }
41
+ } else if (input && typeof input === 'object') {
42
+ parsed = input;
43
+ } else {
44
+ const error = new Error('Visual manifest input must be a JSON string or object.');
45
+ error.code = 'VQA_INVALID_INPUT';
46
+ throw error;
47
+ }
48
+
49
+ return validateVisualSchema(parsed);
50
+ }
51
+
52
+ /**
53
+ * Validates the schema structure of visual-qa.json manifest.
54
+ *
55
+ * @param {object} manifest
56
+ * @returns {object} Validated manifest
57
+ */
58
+ function validateVisualSchema(manifest) {
59
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
60
+ const err = new Error('Visual manifest root must be a JSON object.');
61
+ err.code = 'VQA_INVALID_MANIFEST';
62
+ throw err;
63
+ }
64
+
65
+ if (manifest.version && manifest.version !== VQA_SCHEMA_VERSION) {
66
+ const err = new Error(`Unsupported visual QA schema version: "${manifest.version}". Expected "${VQA_SCHEMA_VERSION}".`);
67
+ err.code = 'VQA_INVALID_MANIFEST';
68
+ throw err;
69
+ }
70
+
71
+ if (!manifest.feature_id || typeof manifest.feature_id !== 'string') {
72
+ const err = new Error('Visual manifest must declare a valid "feature_id".');
73
+ err.code = 'VQA_INVALID_MANIFEST';
74
+ throw err;
75
+ }
76
+
77
+ if (!Array.isArray(manifest.scenarios)) {
78
+ const err = new Error('Visual manifest must declare a "scenarios" array.');
79
+ err.code = 'VQA_INVALID_MANIFEST';
80
+ throw err;
81
+ }
82
+
83
+ const seenIds = new Set();
84
+ for (const s of manifest.scenarios) {
85
+ if (!s.scenario_id || typeof s.scenario_id !== 'string') {
86
+ const err = new Error('Every visual QA scenario must declare an explicit "scenario_id".');
87
+ err.code = 'VQA_INVALID_MANIFEST';
88
+ throw err;
89
+ }
90
+
91
+ if (seenIds.has(s.scenario_id)) {
92
+ const err = new Error(`Duplicate scenario_id detected: "${s.scenario_id}".`);
93
+ err.code = 'VQA_INVALID_MANIFEST';
94
+ throw err;
95
+ }
96
+ seenIds.add(s.scenario_id);
97
+
98
+ if (!s.route || typeof s.route !== 'string') {
99
+ const err = new Error(`Scenario "${s.scenario_id}" is missing required "route".`);
100
+ err.code = 'VQA_INVALID_MANIFEST';
101
+ throw err;
102
+ }
103
+
104
+ if (!s.viewport || typeof s.viewport !== 'object') {
105
+ const err = new Error(`Scenario "${s.scenario_id}" is missing required "viewport" configuration.`);
106
+ err.code = 'VQA_INVALID_MANIFEST';
107
+ throw err;
108
+ }
109
+
110
+ validateViewport(s.viewport, s.scenario_id);
111
+ }
112
+
113
+ return manifest;
114
+ }
115
+
116
+ /**
117
+ * Validates deterministic viewport dimensions and profiles.
118
+ *
119
+ * @param {object} viewport - Viewport declaration
120
+ * @param {string} [scenarioId='unknown']
121
+ * @returns {boolean} True if valid; throws if invalid
122
+ */
123
+ function validateViewport(viewport, scenarioId = 'unknown') {
124
+ if (!viewport || typeof viewport !== 'object') {
125
+ const err = new Error(`Scenario "${scenarioId}": Viewport must be an object.`);
126
+ err.code = 'VQA_INVALID_VIEWPORT';
127
+ throw err;
128
+ }
129
+
130
+ const { width, height, device_scale_factor } = viewport;
131
+
132
+ if (typeof width !== 'number' || width <= 0 || !Number.isInteger(width)) {
133
+ const err = new Error(`Scenario "${scenarioId}": Viewport width must be a positive integer.`);
134
+ err.code = 'VQA_INVALID_VIEWPORT';
135
+ throw err;
136
+ }
137
+
138
+ if (typeof height !== 'number' || height <= 0 || !Number.isInteger(height)) {
139
+ const err = new Error(`Scenario "${scenarioId}": Viewport height must be a positive integer.`);
140
+ err.code = 'VQA_INVALID_VIEWPORT';
141
+ throw err;
142
+ }
143
+
144
+ if (device_scale_factor !== undefined) {
145
+ if (typeof device_scale_factor !== 'number' || device_scale_factor <= 0) {
146
+ const err = new Error(`Scenario "${scenarioId}": Viewport device_scale_factor must be a positive number.`);
147
+ err.code = 'VQA_INVALID_VIEWPORT';
148
+ throw err;
149
+ }
150
+ }
151
+
152
+ return true;
153
+ }
154
+
155
+ /**
156
+ * Validates capture environment metadata match against scenario expectations.
157
+ *
158
+ * @param {object} scenario - Scenario object
159
+ * @param {object} evidence - Submitted evidence object
160
+ * @returns {{ valid: boolean, findings: Array<object> }}
161
+ */
162
+ function validateEnvironmentMetadata(scenario, evidence) {
163
+ const findings = [];
164
+ if (!evidence || !evidence.environment) {
165
+ return { valid: true, findings: [] };
166
+ }
167
+
168
+ const expViewport = scenario.viewport || {};
169
+ const actEnv = evidence.environment || {};
170
+
171
+ if (actEnv.color_scheme && expViewport.color_scheme) {
172
+ if (actEnv.color_scheme !== expViewport.color_scheme) {
173
+ findings.push(createFinding({
174
+ code: 'VQA_ENVIRONMENT_MISMATCH',
175
+ contractId: 'deterministic-viewports',
176
+ phase: 'visual-qa',
177
+ location: scenario.scenario_id,
178
+ details: `Color scheme mismatch for scenario "${scenario.scenario_id}": expected "${expViewport.color_scheme}", got "${actEnv.color_scheme}".`
179
+ }));
180
+ }
181
+ }
182
+
183
+ return {
184
+ valid: findings.length === 0,
185
+ findings
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Applies neutral masking rules to dynamic selectors and sensitive inputs.
191
+ *
192
+ * @param {string} domHtml - Serialized DOM HTML or structure
193
+ * @param {Array<string>} maskSelectors - Custom mask selectors
194
+ * @returns {string} Masked DOM structure
195
+ */
196
+ function applySelectorMasks(domHtml, maskSelectors = []) {
197
+ if (!domHtml || typeof domHtml !== 'string') return '';
198
+
199
+ let masked = domHtml;
200
+
201
+ // 1. Mandatory secret auto-masking: replace password values with solid mask
202
+ masked = masked.replace(/type=["']password["'][^>]*value=["'][^"']*["']/gi, 'type="password" value="[MASKED_SECRET]"');
203
+ masked = masked.replace(/data-sensitive=["']true["'][^>]*>([^<]*)<\//gi, 'data-sensitive="true">[MASKED_SECRET]</');
204
+
205
+ // 2. Custom selector masking (timestamps, live avatars)
206
+ for (const sel of maskSelectors) {
207
+ const cleanSel = sel.replace(/[.#]/, '');
208
+ const regex = new RegExp(`class=["'][^"']*\\b${cleanSel}\\b[^"']*["'][^>]*>([^<]*)<\\/`, 'gi');
209
+ masked = masked.replace(regex, `class="${cleanSel}">[MASKED_NEUTRAL]</`);
210
+ }
211
+
212
+ return masked;
213
+ }
214
+
215
+ /**
216
+ * Validates baseline image integrity and cryptographic hash.
217
+ *
218
+ * @param {object} scenario - Scenario object
219
+ * @param {string} targetDir - Repository target directory
220
+ * @returns {{ valid: boolean, findings: Array<object> }}
221
+ */
222
+ function validateBaselineIntegrity(scenario, targetDir) {
223
+ const findings = [];
224
+ const baseline = scenario.baseline;
225
+
226
+ if (!baseline) {
227
+ findings.push(createFinding({
228
+ code: 'VQA_BASELINE_MISSING',
229
+ contractId: 'visual-evidence-subordinate',
230
+ phase: 'visual-qa',
231
+ location: scenario.scenario_id,
232
+ details: `Scenario "${scenario.scenario_id}" does not declare a baseline.`
233
+ }));
234
+ return { valid: false, findings };
235
+ }
236
+
237
+ const imgPath = path.join(targetDir, baseline.image_path);
238
+ if (!fs.existsSync(imgPath)) {
239
+ findings.push(createFinding({
240
+ code: 'VQA_BASELINE_MISSING',
241
+ contractId: 'visual-evidence-subordinate',
242
+ phase: 'visual-qa',
243
+ location: baseline.image_path,
244
+ details: `Baseline image file "${baseline.image_path}" does not exist on disk.`
245
+ }));
246
+ return { valid: false, findings };
247
+ }
248
+
249
+ const { hashFile } = require('./hasher');
250
+ const liveHash = hashFile(imgPath);
251
+
252
+ if (liveHash !== baseline.image_sha256) {
253
+ findings.push(createFinding({
254
+ code: 'VQA_BASELINE_TAMPERED',
255
+ contractId: 'baseline-explicit-update-only',
256
+ phase: 'visual-qa',
257
+ location: baseline.image_path,
258
+ details: `Baseline image "${baseline.image_path}" was modified on disk without explicit promotion. Recorded: ${baseline.image_sha256.slice(0, 12)}..., Actual: ${liveHash.slice(0, 12)}...`
259
+ }));
260
+ }
261
+
262
+ return {
263
+ valid: findings.length === 0,
264
+ findings
265
+ };
266
+ }
267
+
268
+ /**
269
+ * Compares live visual evidence against canonical baseline.
270
+ *
271
+ * @param {object} scenario - Scenario definition
272
+ * @param {object} evidence - Submitted evidence object
273
+ * @param {string} targetDir - Repository target directory
274
+ * @returns {{ status: string, passed: boolean, diff_percentage: number, findings: Array<object> }}
275
+ */
276
+ function compareVisualEvidence(scenario, evidence, targetDir) {
277
+ const findings = [];
278
+ const baseline = scenario.baseline;
279
+
280
+ if (!evidence) {
281
+ findings.push(createFinding({
282
+ code: 'VQA_EVIDENCE_MISSING',
283
+ contractId: 'visual-evidence-subordinate',
284
+ phase: 'visual-qa',
285
+ location: scenario.scenario_id,
286
+ details: `Scenario "${scenario.scenario_id}" has no submitted live evidence.`
287
+ }));
288
+ return { status: 'EVIDENCE_MISSING', passed: false, diff_percentage: 1.0, findings };
289
+ }
290
+
291
+ if (!baseline) {
292
+ findings.push(createFinding({
293
+ code: 'VQA_BASELINE_MISSING',
294
+ contractId: 'visual-evidence-subordinate',
295
+ phase: 'visual-qa',
296
+ location: scenario.scenario_id,
297
+ details: `Scenario "${scenario.scenario_id}" has no baseline.`
298
+ }));
299
+ return { status: 'BASELINE_MISSING', passed: false, diff_percentage: 1.0, findings };
300
+ }
301
+
302
+ // Fast-path: SHA-256 match
303
+ if (evidence.image_sha256 && baseline.image_sha256 && evidence.image_sha256 === baseline.image_sha256) {
304
+ return {
305
+ status: 'PASS',
306
+ passed: true,
307
+ diff_percentage: 0.0,
308
+ findings: []
309
+ };
310
+ }
311
+
312
+ // Evaluate tolerances
313
+ const maxDiff = (scenario.tolerances && scenario.tolerances.max_diff_percentage !== undefined)
314
+ ? scenario.tolerances.max_diff_percentage
315
+ : 0.00;
316
+
317
+ const observedDiff = typeof evidence.diff_percentage === 'number' ? evidence.diff_percentage : 0.05;
318
+
319
+ if (observedDiff > maxDiff) {
320
+ findings.push(createFinding({
321
+ code: 'VQA_VISUAL_REGRESSION',
322
+ contractId: 'visual-evidence-subordinate',
323
+ phase: 'visual-qa',
324
+ location: scenario.scenario_id,
325
+ details: `Visual regression on "${scenario.scenario_id}": observed diff ${observedDiff} exceeds maximum allowed ${maxDiff}.`
326
+ }));
327
+ return {
328
+ status: 'VISUAL_REGRESSION',
329
+ passed: false,
330
+ diff_percentage: observedDiff,
331
+ findings
332
+ };
333
+ }
334
+
335
+ return {
336
+ status: 'PASS',
337
+ passed: true,
338
+ diff_percentage: observedDiff,
339
+ findings: []
340
+ };
341
+ }
342
+
343
+ /**
344
+ * Promotes a live evidence capture to canonical baseline status.
345
+ *
346
+ * @param {string} targetDir - Repository target directory
347
+ * @param {string} featureId - Active feature directory path
348
+ * @param {string} scenarioId - Target scenario ID
349
+ * @param {string} liveImagePath - Relative path to captured screenshot
350
+ * @param {string} approvedBy - Approver role/identity
351
+ * @returns {{ success: boolean, updatedManifest: object }}
352
+ */
353
+ function promoteVisualBaseline(targetDir, featureId, scenarioId, liveImagePath, approvedBy = 'human-lead') {
354
+ const manifestPath = path.join(targetDir, featureId, 'visual-qa.json');
355
+ if (!fs.existsSync(manifestPath)) {
356
+ const err = new Error(`visual-qa.json not found in "${featureId}".`);
357
+ err.code = 'VQA_MANIFEST_NOT_FOUND';
358
+ throw err;
359
+ }
360
+
361
+ const raw = fs.readFileSync(manifestPath, 'utf8');
362
+ const manifest = parseVisualManifest(raw);
363
+
364
+ const scenario = (manifest.scenarios || []).find(s => s.scenario_id === scenarioId);
365
+ if (!scenario) {
366
+ const err = new Error(`Scenario "${scenarioId}" not found in manifest.`);
367
+ err.code = 'VQA_SCENARIO_NOT_FOUND';
368
+ throw err;
369
+ }
370
+
371
+ const absLivePath = path.join(targetDir, liveImagePath);
372
+ if (!fs.existsSync(absLivePath)) {
373
+ const err = new Error(`Live image file "${liveImagePath}" not found.`);
374
+ err.code = 'VQA_IMAGE_NOT_FOUND';
375
+ throw err;
376
+ }
377
+
378
+ const { hashFile } = require('./hasher');
379
+ const imgHash = hashFile(absLivePath);
380
+
381
+ // Copy to canonical baselines directory
382
+ const baselinesDir = path.join(targetDir, featureId, 'baselines');
383
+ if (!fs.existsSync(baselinesDir)) {
384
+ fs.mkdirSync(baselinesDir, { recursive: true });
385
+ }
386
+
387
+ const canonicalRelPath = path.posix.join(featureId.replace(/\\/g, '/'), 'baselines', `${scenarioId.toLowerCase()}.png`);
388
+ const canonicalAbsPath = path.join(targetDir, canonicalRelPath);
389
+ fs.copyFileSync(absLivePath, canonicalAbsPath);
390
+
391
+ scenario.baseline = {
392
+ image_path: canonicalRelPath,
393
+ image_sha256: imgHash,
394
+ dom_hash: scenario.baseline ? scenario.baseline.dom_hash : 'd0m_h4sh_pr0m0t3d',
395
+ approved_by: approvedBy,
396
+ approved_at: new Date().toISOString()
397
+ };
398
+
399
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
400
+
401
+ return {
402
+ success: true,
403
+ updatedManifest: manifest
404
+ };
405
+ }
406
+
407
+ /**
408
+ * Validates a complete visual-qa.json file in read-only mode.
409
+ *
410
+ * @param {string} targetDir - Repository target directory
411
+ * @param {string} featureId - Active feature directory path
412
+ * @returns {{ valid: boolean, state: string, findings: Array<object> }}
413
+ */
414
+ function validateVisualManifest(targetDir, featureId) {
415
+ const manifestPath = path.join(targetDir, featureId, 'visual-qa.json');
416
+ if (!fs.existsSync(manifestPath)) {
417
+ return {
418
+ valid: false,
419
+ state: 'MISSING',
420
+ findings: []
421
+ };
422
+ }
423
+
424
+ let raw;
425
+ try {
426
+ raw = fs.readFileSync(manifestPath, 'utf8');
427
+ } catch (err) {
428
+ return {
429
+ valid: false,
430
+ state: 'UNREADABLE',
431
+ findings: [createFinding({
432
+ code: 'VQA_UNREADABLE',
433
+ contractId: 'visual-evidence-subordinate',
434
+ phase: 'visual-qa',
435
+ location: manifestPath,
436
+ details: err.message
437
+ })]
438
+ };
439
+ }
440
+
441
+ let manifest;
442
+ try {
443
+ manifest = parseVisualManifest(raw);
444
+ } catch (err) {
445
+ return {
446
+ valid: false,
447
+ state: 'INVALID',
448
+ findings: [createFinding({
449
+ code: 'VQA_INVALID_MANIFEST',
450
+ contractId: 'visual-evidence-subordinate',
451
+ phase: 'visual-qa',
452
+ location: manifestPath,
453
+ details: err.message
454
+ })]
455
+ };
456
+ }
457
+
458
+ const findings = [];
459
+ const scenarios = manifest.scenarios || [];
460
+
461
+ for (const s of scenarios) {
462
+ // 1. Viewport check
463
+ try {
464
+ validateViewport(s.viewport, s.scenario_id);
465
+ } catch (vErr) {
466
+ findings.push(createFinding({
467
+ code: 'VQA_INVALID_VIEWPORT',
468
+ contractId: 'deterministic-viewports',
469
+ phase: 'visual-qa',
470
+ location: s.scenario_id,
471
+ details: vErr.message
472
+ }));
473
+ }
474
+
475
+ // 2. Baseline integrity check
476
+ const baseCheck = validateBaselineIntegrity(s, targetDir);
477
+ findings.push(...baseCheck.findings);
478
+ }
479
+
480
+ return {
481
+ valid: findings.length === 0,
482
+ state: findings.length === 0 ? 'VALID' : 'INVALID',
483
+ findings
484
+ };
485
+ }
486
+
487
+ module.exports = {
488
+ VQA_SCHEMA_VERSION,
489
+ CANONICAL_VIEWPORT_PROFILES,
490
+ parseVisualManifest,
491
+ validateVisualSchema,
492
+ validateViewport,
493
+ validateEnvironmentMetadata,
494
+ applySelectorMasks,
495
+ validateBaselineIntegrity,
496
+ compareVisualEvidence,
497
+ promoteVisualBaseline,
498
+ validateVisualManifest
499
+ };