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.
- package/.gemstack/state.json +6 -6
- package/CHANGELOG.md +54 -0
- package/README.md +66 -9
- package/RELEASE_NOTES.md +40 -0
- package/{gemstack-ai-1.3.0.tgz → gemstack-ai-1.4.0.tgz} +0 -0
- package/package.json +2 -2
- package/specs/009-context-capsule/context-capsule.json +4 -4
- package/specs/010-agent-swarm-visual-qa/.gemstack.json +5 -0
- package/specs/010-agent-swarm-visual-qa/closure.json +59 -0
- package/specs/010-agent-swarm-visual-qa/plan.md +759 -0
- package/specs/010-agent-swarm-visual-qa/spec.md +842 -0
- package/specs/010-agent-swarm-visual-qa/swarm.json +49 -0
- package/specs/010-agent-swarm-visual-qa/tasks.md +873 -0
- package/specs/010-agent-swarm-visual-qa/visual-qa.json +41 -0
- package/src/cli.js +8 -0
- package/src/commands/context.js +1 -1
- package/src/commands/swarm.js +111 -0
- package/src/commands/verify.js +38 -0
- package/src/commands/visual.js +82 -0
- package/src/lib/closure-context.js +9 -1
- package/src/lib/swarm.js +639 -0
- package/src/lib/visual-qa.js +499 -0
|
@@ -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
|
+
};
|