gemstack-ai 1.3.0 → 2.0.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 (71) hide show
  1. package/.agents/rules/03-gemstack-security.md +2 -2
  2. package/.gemstack/state.json +7 -8
  3. package/CHANGELOG.md +87 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +113 -22
  6. package/RELEASE_NOTES.md +80 -1
  7. package/handoff.md +33 -19
  8. package/package.json +4 -3
  9. package/scripts/ci/check-package-contents.js +1 -1
  10. package/scripts/ci/check-secrets.js +84 -0
  11. package/specs/009-context-capsule/context-capsule.json +4 -4
  12. package/specs/010-agent-swarm-visual-qa/.gemstack.json +5 -0
  13. package/specs/010-agent-swarm-visual-qa/closure.json +59 -0
  14. package/specs/010-agent-swarm-visual-qa/plan.md +759 -0
  15. package/specs/010-agent-swarm-visual-qa/spec.md +842 -0
  16. package/specs/010-agent-swarm-visual-qa/swarm.json +49 -0
  17. package/specs/010-agent-swarm-visual-qa/tasks.md +873 -0
  18. package/specs/010-agent-swarm-visual-qa/visual-qa.json +41 -0
  19. package/specs/011-gemstack-2.0-hardening/.gemstack.json +5 -0
  20. package/specs/011-gemstack-2.0-hardening/closure.json +58 -0
  21. package/specs/011-gemstack-2.0-hardening/plan.md +210 -0
  22. package/specs/011-gemstack-2.0-hardening/spec.md +277 -0
  23. package/specs/011-gemstack-2.0-hardening/tasks.md +59 -0
  24. package/specs/012-gemstack-2.0-honest-evidence/.gemstack.json +5 -0
  25. package/specs/012-gemstack-2.0-honest-evidence/closure.json +58 -0
  26. package/specs/012-gemstack-2.0-honest-evidence/plan.md +202 -0
  27. package/specs/012-gemstack-2.0-honest-evidence/spec.md +222 -0
  28. package/specs/012-gemstack-2.0-honest-evidence/tasks.md +99 -0
  29. package/specs/013-gemstack-2.0-adaptable-sdd/.gemstack.json +9 -0
  30. package/specs/013-gemstack-2.0-adaptable-sdd/closure.json +58 -0
  31. package/specs/013-gemstack-2.0-adaptable-sdd/context-capsule.json +227 -0
  32. package/specs/013-gemstack-2.0-adaptable-sdd/plan.md +179 -0
  33. package/specs/013-gemstack-2.0-adaptable-sdd/spec.md +212 -0
  34. package/specs/013-gemstack-2.0-adaptable-sdd/tasks.md +90 -0
  35. package/specs/014-gemstack-2.0-context-memory/.gemstack.json +9 -0
  36. package/specs/014-gemstack-2.0-context-memory/closure.json +58 -0
  37. package/specs/014-gemstack-2.0-context-memory/plan.md +161 -0
  38. package/specs/014-gemstack-2.0-context-memory/spec.md +163 -0
  39. package/specs/014-gemstack-2.0-context-memory/tasks.md +79 -0
  40. package/src/cli.js +11 -0
  41. package/src/commands/context.js +1 -1
  42. package/src/commands/doctor.js +18 -0
  43. package/src/commands/hooks.js +98 -14
  44. package/src/commands/init.js +1 -1
  45. package/src/commands/install.js +174 -49
  46. package/src/commands/spec.js +105 -0
  47. package/src/commands/swarm.js +111 -0
  48. package/src/commands/update.js +1 -1
  49. package/src/commands/verify.js +48 -0
  50. package/src/commands/visual.js +82 -0
  51. package/src/lib/backup.js +3 -3
  52. package/src/lib/closure-context.js +9 -1
  53. package/src/lib/context-fatigue.js +165 -0
  54. package/src/lib/contract-amendments.js +109 -0
  55. package/src/lib/dependency-audit.js +202 -0
  56. package/src/lib/filesystem-safe.js +85 -15
  57. package/src/lib/memory-audit.js +121 -0
  58. package/src/lib/provider-boundary.js +5 -1
  59. package/src/lib/provider-registry.js +6 -4
  60. package/src/lib/safety-gates.js +176 -8
  61. package/src/lib/sdd-rigor.js +181 -0
  62. package/src/lib/spec-delta.js +194 -0
  63. package/src/lib/spec-merge.js +168 -0
  64. package/src/lib/swarm.js +639 -0
  65. package/src/lib/visual-qa.js +652 -0
  66. package/template/.agents/rules/03-gemstack-security.md +2 -2
  67. package/.github/workflows/main-ci.yml +0 -32
  68. package/.github/workflows/pr-ci.yml +0 -31
  69. package/.github/workflows/publish.yml +0 -52
  70. package/.github/workflows/release-readiness.yml +0 -43
  71. package/gemstack-ai-1.3.0.tgz +0 -0
@@ -0,0 +1,652 @@
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, hashFile } = require('./hasher');
14
+ const { createFinding } = require('./findings');
15
+ const fssafe = require('./filesystem-safe');
16
+
17
+ const VQA_SCHEMA_VERSION = '1.0.0';
18
+
19
+ const CANONICAL_VIEWPORT_PROFILES = {
20
+ 'desktop-standard': { width: 1920, height: 1080, device_scale_factor: 1 },
21
+ 'desktop-compact': { width: 1280, height: 800, device_scale_factor: 1 },
22
+ 'tablet-portrait': { width: 768, height: 1024, device_scale_factor: 2 },
23
+ 'mobile-portrait': { width: 375, height: 667, device_scale_factor: 2 }
24
+ };
25
+
26
+ /**
27
+ * Parses and validates visual-qa.json manifest.
28
+ *
29
+ * @param {string|object} input - Raw JSON string or parsed object
30
+ * @returns {object} Canonical parsed visual QA manifest
31
+ */
32
+ function parseVisualManifest(input) {
33
+ let parsed;
34
+ if (typeof input === 'string') {
35
+ try {
36
+ parsed = JSON.parse(input);
37
+ } catch (err) {
38
+ const error = new Error(`Invalid JSON in visual-qa manifest: ${err.message}`);
39
+ error.code = 'VQA_PARSE_ERROR';
40
+ throw error;
41
+ }
42
+ } else if (input && typeof input === 'object') {
43
+ parsed = input;
44
+ } else {
45
+ const error = new Error('Visual manifest input must be a JSON string or object.');
46
+ error.code = 'VQA_INVALID_INPUT';
47
+ throw error;
48
+ }
49
+
50
+ return validateVisualSchema(parsed);
51
+ }
52
+
53
+ /**
54
+ * Validates the schema structure of visual-qa.json manifest.
55
+ *
56
+ * @param {object} manifest
57
+ * @returns {object} Validated manifest
58
+ */
59
+ function validateVisualSchema(manifest) {
60
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
61
+ const err = new Error('Visual manifest root must be a JSON object.');
62
+ err.code = 'VQA_INVALID_MANIFEST';
63
+ throw err;
64
+ }
65
+
66
+ if (manifest.version && manifest.version !== VQA_SCHEMA_VERSION) {
67
+ const err = new Error(`Unsupported visual QA schema version: "${manifest.version}". Expected "${VQA_SCHEMA_VERSION}".`);
68
+ err.code = 'VQA_INVALID_MANIFEST';
69
+ throw err;
70
+ }
71
+
72
+ if (!manifest.feature_id || typeof manifest.feature_id !== 'string') {
73
+ const err = new Error('Visual manifest must declare a valid "feature_id".');
74
+ err.code = 'VQA_INVALID_MANIFEST';
75
+ throw err;
76
+ }
77
+
78
+ if (!Array.isArray(manifest.scenarios)) {
79
+ const err = new Error('Visual manifest must declare a "scenarios" array.');
80
+ err.code = 'VQA_INVALID_MANIFEST';
81
+ throw err;
82
+ }
83
+
84
+ const seenIds = new Set();
85
+ for (const s of manifest.scenarios) {
86
+ if (!s.scenario_id || typeof s.scenario_id !== 'string') {
87
+ const err = new Error('Every visual QA scenario must declare an explicit "scenario_id".');
88
+ err.code = 'VQA_INVALID_MANIFEST';
89
+ throw err;
90
+ }
91
+
92
+ if (seenIds.has(s.scenario_id)) {
93
+ const err = new Error(`Duplicate scenario_id detected: "${s.scenario_id}".`);
94
+ err.code = 'VQA_INVALID_MANIFEST';
95
+ throw err;
96
+ }
97
+ seenIds.add(s.scenario_id);
98
+
99
+ if (!s.route || typeof s.route !== 'string') {
100
+ const err = new Error(`Scenario "${s.scenario_id}" is missing required "route".`);
101
+ err.code = 'VQA_INVALID_MANIFEST';
102
+ throw err;
103
+ }
104
+
105
+ if (!s.viewport || typeof s.viewport !== 'object') {
106
+ const err = new Error(`Scenario "${s.scenario_id}" is missing required "viewport" configuration.`);
107
+ err.code = 'VQA_INVALID_MANIFEST';
108
+ throw err;
109
+ }
110
+
111
+ validateViewport(s.viewport, s.scenario_id);
112
+ }
113
+
114
+ return manifest;
115
+ }
116
+
117
+ /**
118
+ * Validates deterministic viewport dimensions and profiles.
119
+ *
120
+ * @param {object} viewport - Viewport declaration
121
+ * @param {string} [scenarioId='unknown']
122
+ * @returns {boolean} True if valid; throws if invalid
123
+ */
124
+ function validateViewport(viewport, scenarioId = 'unknown') {
125
+ if (!viewport || typeof viewport !== 'object') {
126
+ const err = new Error(`Scenario "${scenarioId}": Viewport must be an object.`);
127
+ err.code = 'VQA_INVALID_VIEWPORT';
128
+ throw err;
129
+ }
130
+
131
+ const { width, height, device_scale_factor } = viewport;
132
+
133
+ if (typeof width !== 'number' || width <= 0 || !Number.isInteger(width)) {
134
+ const err = new Error(`Scenario "${scenarioId}": Viewport width must be a positive integer.`);
135
+ err.code = 'VQA_INVALID_VIEWPORT';
136
+ throw err;
137
+ }
138
+
139
+ if (typeof height !== 'number' || height <= 0 || !Number.isInteger(height)) {
140
+ const err = new Error(`Scenario "${scenarioId}": Viewport height must be a positive integer.`);
141
+ err.code = 'VQA_INVALID_VIEWPORT';
142
+ throw err;
143
+ }
144
+
145
+ if (device_scale_factor !== undefined) {
146
+ if (typeof device_scale_factor !== 'number' || device_scale_factor <= 0) {
147
+ const err = new Error(`Scenario "${scenarioId}": Viewport device_scale_factor must be a positive number.`);
148
+ err.code = 'VQA_INVALID_VIEWPORT';
149
+ throw err;
150
+ }
151
+ }
152
+
153
+ return true;
154
+ }
155
+
156
+ /**
157
+ * Validates capture environment metadata match against scenario expectations.
158
+ *
159
+ * @param {object} scenario - Scenario object
160
+ * @param {object} evidence - Submitted evidence object
161
+ * @returns {{ valid: boolean, findings: Array<object> }}
162
+ */
163
+ function validateEnvironmentMetadata(scenario, evidence) {
164
+ const findings = [];
165
+ if (!evidence || !evidence.environment) {
166
+ return { valid: true, findings: [] };
167
+ }
168
+
169
+ const expViewport = scenario.viewport || {};
170
+ const actEnv = evidence.environment || {};
171
+
172
+ if (actEnv.color_scheme && expViewport.color_scheme) {
173
+ if (actEnv.color_scheme !== expViewport.color_scheme) {
174
+ findings.push(createFinding({
175
+ code: 'VQA_ENVIRONMENT_MISMATCH',
176
+ contractId: 'deterministic-viewports',
177
+ phase: 'visual-qa',
178
+ location: scenario.scenario_id,
179
+ details: `Color scheme mismatch for scenario "${scenario.scenario_id}": expected "${expViewport.color_scheme}", got "${actEnv.color_scheme}".`
180
+ }));
181
+ }
182
+ }
183
+
184
+ return {
185
+ valid: findings.length === 0,
186
+ findings
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Applies neutral masking rules to dynamic selectors and sensitive inputs.
192
+ *
193
+ * @param {string} domHtml - Serialized DOM HTML or structure
194
+ * @param {Array<string>} maskSelectors - Custom mask selectors
195
+ * @returns {string} Masked DOM structure
196
+ */
197
+ function applySelectorMasks(domHtml, maskSelectors = []) {
198
+ if (!domHtml || typeof domHtml !== 'string') return '';
199
+
200
+ let masked = domHtml;
201
+
202
+ // 1. Mandatory secret auto-masking: replace password values with solid mask
203
+ masked = masked.replace(/type=["']password["'][^>]*value=["'][^"']*["']/gi, 'type="password" value="[MASKED_SECRET]"');
204
+ masked = masked.replace(/data-sensitive=["']true["'][^>]*>([^<]*)<\//gi, 'data-sensitive="true">[MASKED_SECRET]</');
205
+
206
+ // 2. Custom selector masking (timestamps, live avatars)
207
+ for (const sel of maskSelectors) {
208
+ const cleanSel = sel.replace(/[.#]/, '');
209
+ const regex = new RegExp(`class=["'][^"']*\\b${cleanSel}\\b[^"']*["'][^>]*>([^<]*)<\\/`, 'gi');
210
+ masked = masked.replace(regex, `class="${cleanSel}">[MASKED_NEUTRAL]</`);
211
+ }
212
+
213
+ return masked;
214
+ }
215
+
216
+ /**
217
+ * Validates baseline image integrity and cryptographic hash.
218
+ *
219
+ * @param {object} scenario - Scenario object
220
+ * @param {string} targetDir - Repository target directory
221
+ * @returns {{ valid: boolean, findings: Array<object> }}
222
+ */
223
+ function validateBaselineIntegrity(scenario, targetDir) {
224
+ const findings = [];
225
+ const baseline = scenario.baseline;
226
+
227
+ if (!baseline) {
228
+ findings.push(createFinding({
229
+ code: 'VQA_BASELINE_MISSING',
230
+ contractId: 'visual-evidence-subordinate',
231
+ phase: 'visual-qa',
232
+ location: scenario.scenario_id,
233
+ details: `Scenario "${scenario.scenario_id}" does not declare a baseline.`
234
+ }));
235
+ return { valid: false, findings };
236
+ }
237
+
238
+ const imgPath = path.join(targetDir, baseline.image_path);
239
+ if (!fs.existsSync(imgPath)) {
240
+ findings.push(createFinding({
241
+ code: 'VQA_BASELINE_MISSING',
242
+ contractId: 'visual-evidence-subordinate',
243
+ phase: 'visual-qa',
244
+ location: baseline.image_path,
245
+ details: `Baseline image file "${baseline.image_path}" does not exist on disk.`
246
+ }));
247
+ return { valid: false, findings };
248
+ }
249
+
250
+ const { hashFile } = require('./hasher');
251
+ const liveHash = hashFile(imgPath);
252
+
253
+ if (liveHash !== baseline.image_sha256) {
254
+ findings.push(createFinding({
255
+ code: 'VQA_BASELINE_TAMPERED',
256
+ contractId: 'baseline-explicit-update-only',
257
+ phase: 'visual-qa',
258
+ location: baseline.image_path,
259
+ 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)}...`
260
+ }));
261
+ }
262
+
263
+ return {
264
+ valid: findings.length === 0,
265
+ findings
266
+ };
267
+ }
268
+
269
+ /**
270
+ * Sanitizes and masks sensitive input fields and tokens before visual capture persistence.
271
+ *
272
+ * @param {string} content - HTML or DOM text
273
+ * @returns {string} Sanitized content with sensitive data masked
274
+ */
275
+ function maskSensitiveFieldsBeforeCapture(content) {
276
+ if (!content || typeof content !== 'string') return content;
277
+ let masked = content;
278
+
279
+ // 1. Password input values
280
+ masked = masked.replace(/(<input\b[^>]*\btype\s*=\s*["']?password["']?[^>]*\bvalue\s*=\s*["'])([^"']*)(["'])/gi, '$1[MASKED_PASSWORD]$3');
281
+ masked = masked.replace(/(<input\b[^>]*\bvalue\s*=\s*["'])([^"']*)(["'][^>]*\btype\s*=\s*["']?password["']?[^>]*>)/gi, '$1[MASKED_PASSWORD]$3');
282
+
283
+ // 2. Sensitive ids or names (password, token, secret, key, credit_card, card)
284
+ masked = masked.replace(/(<input\b[^>]*(?:\bid|\bname)\s*=\s*["']?[^"']*(?:password|token|secret|key|credit_card|card)[^"']*["']?[^>]*\bvalue\s*=\s*["'])([^"']*)(["'])/gi, '$1[MASKED_SENSITIVE]$3');
285
+ masked = masked.replace(/(<input\b[^>]*\bvalue\s*=\s*["'])([^"']*)(["'][^>]*(?:\bid|\bname)\s*=\s*["']?[^"']*(?:password|token|secret|key|credit_card|card)[^"']*["']?[^>]*>)/gi, '$1[MASKED_SENSITIVE]$3');
286
+
287
+ // 3. API Keys and Tokens in attributes or text
288
+ masked = masked.replace(/sk-(?:live_|proj-)?[a-zA-Z0-9_-]{10,}/g, '[MASKED_KEY]');
289
+
290
+ // 4. Credit card numbers (13-19 consecutive digits or grouped with spaces/dashes)
291
+ masked = masked.replace(/\b(?:\d{4}[ -]?){3,4}\d{1,4}\b/g, '[MASKED_CARD]');
292
+
293
+ return masked;
294
+ }
295
+
296
+ /**
297
+ * Compares live visual evidence against canonical baseline with honest disk recomputation.
298
+ *
299
+ * @param {object} scenario - Scenario definition
300
+ * @param {object} evidence - Submitted evidence object
301
+ * @param {string} targetDir - Repository target directory
302
+ * @param {object} [options={}] - Additional options (e.g. diffAdapter)
303
+ * @returns {{ status: string, passed: boolean, diff_percentage: number, findings: Array<object> }}
304
+ */
305
+ function compareVisualEvidence(scenario, evidence, targetDir, options = {}) {
306
+ const findings = [];
307
+ const baseline = scenario.baseline;
308
+
309
+ if (!evidence) {
310
+ findings.push(createFinding({
311
+ code: 'VQA_EVIDENCE_MISSING',
312
+ contractId: 'visual-evidence-subordinate',
313
+ phase: 'visual-qa',
314
+ location: scenario.scenario_id,
315
+ details: `Scenario "${scenario.scenario_id}" has no submitted live evidence.`
316
+ }));
317
+ return { status: 'EVIDENCE_MISSING', passed: false, diff_percentage: 1.0, findings };
318
+ }
319
+
320
+ if (!baseline) {
321
+ findings.push(createFinding({
322
+ code: 'VQA_BASELINE_MISSING',
323
+ contractId: 'visual-evidence-subordinate',
324
+ phase: 'visual-qa',
325
+ location: scenario.scenario_id,
326
+ details: `Scenario "${scenario.scenario_id}" has no baseline.`
327
+ }));
328
+ return { status: 'BASELINE_MISSING', passed: false, diff_percentage: 1.0, findings };
329
+ }
330
+
331
+ let effectiveLiveHash = evidence.image_sha256;
332
+ let effectiveBaselineHash = baseline.image_sha256;
333
+
334
+ // Recompute hashes directly from disk files if targetDir and paths are available
335
+ if (targetDir) {
336
+ if (baseline.image_path) {
337
+ try {
338
+ const absBaselinePath = fssafe.resolveSafeStrict(targetDir, baseline.image_path);
339
+ if (fs.existsSync(absBaselinePath)) {
340
+ const diskBaselineHash = hashFile(absBaselinePath);
341
+ if (baseline.image_sha256 && diskBaselineHash !== baseline.image_sha256) {
342
+ findings.push(createFinding({
343
+ code: 'VQA_BASELINE_TAMPERED',
344
+ contractId: 'baseline-explicit-update-only',
345
+ phase: 'visual-qa',
346
+ location: baseline.image_path,
347
+ details: `Baseline image "${baseline.image_path}" was modified on disk. Recorded: ${baseline.image_sha256}, Actual: ${diskBaselineHash}.`
348
+ }));
349
+ return { status: 'BASELINE_TAMPERED', passed: false, diff_percentage: 1.0, findings };
350
+ }
351
+ effectiveBaselineHash = diskBaselineHash;
352
+ }
353
+ } catch (err) {
354
+ findings.push(createFinding({
355
+ code: 'VQA_BASELINE_TAMPERED',
356
+ contractId: 'baseline-explicit-update-only',
357
+ phase: 'visual-qa',
358
+ location: baseline.image_path,
359
+ details: `Error validating baseline path: ${err.message}`
360
+ }));
361
+ return { status: 'BASELINE_TAMPERED', passed: false, diff_percentage: 1.0, findings };
362
+ }
363
+ }
364
+
365
+ const livePathCandidate = evidence.live_screenshot_path || (evidence.image_path && fs.existsSync(fssafe.resolveSafe(targetDir, evidence.image_path)) ? evidence.image_path : null);
366
+ if (livePathCandidate) {
367
+ try {
368
+ const absLivePath = fssafe.resolveSafeStrict(targetDir, livePathCandidate);
369
+ if (!fs.existsSync(absLivePath)) {
370
+ findings.push(createFinding({
371
+ code: 'VQA_IMAGE_NOT_FOUND',
372
+ contractId: 'visual-evidence-subordinate',
373
+ phase: 'visual-qa',
374
+ location: scenario.scenario_id,
375
+ details: `Live screenshot file not found: ${livePathCandidate}`
376
+ }));
377
+ return { status: 'EVIDENCE_MISSING', passed: false, diff_percentage: 1.0, findings };
378
+ }
379
+ const diskLiveHash = hashFile(absLivePath);
380
+ if (evidence.image_sha256 && evidence.image_sha256 !== diskLiveHash) {
381
+ findings.push(createFinding({
382
+ code: 'VQA_EVIDENCE_HASH_MISMATCH',
383
+ contractId: 'visual-evidence-subordinate',
384
+ phase: 'visual-qa',
385
+ location: scenario.scenario_id,
386
+ details: `Submitted evidence image_sha256 (${evidence.image_sha256}) does not match disk file hash (${diskLiveHash}).`
387
+ }));
388
+ return { status: 'EVIDENCE_HASH_MISMATCH', passed: false, diff_percentage: 1.0, findings };
389
+ }
390
+ effectiveLiveHash = diskLiveHash;
391
+ } catch (err) {
392
+ findings.push(createFinding({
393
+ code: 'VQA_IMAGE_NOT_FOUND',
394
+ contractId: 'visual-evidence-subordinate',
395
+ phase: 'visual-qa',
396
+ location: scenario.scenario_id,
397
+ details: `Error validating live screenshot path: ${err.message}`
398
+ }));
399
+ return { status: 'EVIDENCE_MISSING', passed: false, diff_percentage: 1.0, findings };
400
+ }
401
+ }
402
+ }
403
+
404
+ // Fast-path: SHA-256 match from verified disk hashes
405
+ if (effectiveLiveHash && effectiveBaselineHash && effectiveLiveHash === effectiveBaselineHash) {
406
+ return {
407
+ status: 'PASS',
408
+ passed: true,
409
+ diff_percentage: 0.0,
410
+ findings: []
411
+ };
412
+ }
413
+
414
+ // Evaluate tolerances and real diff adapter
415
+ const maxDiff = (scenario.tolerances && scenario.tolerances.max_diff_percentage !== undefined)
416
+ ? scenario.tolerances.max_diff_percentage
417
+ : 0.00;
418
+
419
+ const diffAdapter = (options && options.diffAdapter) || (evidence && evidence.diffAdapter);
420
+
421
+ if (diffAdapter && typeof diffAdapter.computeDiff === 'function') {
422
+ let baselineBuf = null;
423
+ let liveBuf = null;
424
+ if (targetDir && baseline.image_path) {
425
+ try { baselineBuf = fs.readFileSync(fssafe.resolveSafeStrict(targetDir, baseline.image_path)); } catch {}
426
+ }
427
+ const liveRel = evidence.live_screenshot_path || evidence.image_path;
428
+ if (targetDir && liveRel) {
429
+ try { liveBuf = fs.readFileSync(fssafe.resolveSafeStrict(targetDir, liveRel)); } catch {}
430
+ }
431
+
432
+ const diffResult = diffAdapter.computeDiff(baselineBuf, liveBuf, scenario.tolerances);
433
+ const observedDiff = typeof diffResult.diff_percentage === 'number' ? diffResult.diff_percentage : 0.0;
434
+
435
+ if (observedDiff > maxDiff) {
436
+ findings.push(createFinding({
437
+ code: 'VQA_VISUAL_REGRESSION',
438
+ contractId: 'visual-evidence-subordinate',
439
+ phase: 'visual-qa',
440
+ location: scenario.scenario_id,
441
+ details: `Visual regression on "${scenario.scenario_id}": observed diff ${observedDiff} exceeds maximum allowed ${maxDiff}.`
442
+ }));
443
+ return {
444
+ status: 'VISUAL_REGRESSION',
445
+ passed: false,
446
+ diff_percentage: observedDiff,
447
+ findings
448
+ };
449
+ }
450
+
451
+ return {
452
+ status: 'PASS',
453
+ passed: true,
454
+ diff_percentage: observedDiff,
455
+ findings: []
456
+ };
457
+ }
458
+
459
+ // If no diff adapter is provided:
460
+ const observedDiff = typeof evidence.diff_percentage === 'number' ? evidence.diff_percentage : 0.05;
461
+
462
+ if (observedDiff > maxDiff) {
463
+ findings.push(createFinding({
464
+ code: 'VQA_VISUAL_REGRESSION',
465
+ contractId: 'visual-evidence-subordinate',
466
+ phase: 'visual-qa',
467
+ location: scenario.scenario_id,
468
+ details: `Visual regression on "${scenario.scenario_id}": observed diff ${observedDiff} exceeds maximum allowed ${maxDiff}.`
469
+ }));
470
+ return {
471
+ status: 'VISUAL_REGRESSION',
472
+ passed: false,
473
+ diff_percentage: observedDiff,
474
+ findings
475
+ };
476
+ }
477
+
478
+ // When live screenshot differs from baseline and caller claims low diff without adapter: FAIL-CLOSED UNVERIFIED
479
+ findings.push(createFinding({
480
+ code: 'VQA_DIFF_ENGINE_UNAVAILABLE',
481
+ contractId: 'visual-evidence-subordinate',
482
+ phase: 'visual-qa',
483
+ location: scenario.scenario_id,
484
+ details: `Visual deviation detected on "${scenario.scenario_id}" but no diff engine adapter is available. Status UNVERIFIED.`
485
+ }));
486
+
487
+ return {
488
+ status: 'UNVERIFIED',
489
+ passed: false,
490
+ diff_percentage: observedDiff,
491
+ findings
492
+ };
493
+ }
494
+
495
+ /**
496
+ * Promotes a live evidence capture to canonical baseline status.
497
+ *
498
+ * @param {string} targetDir - Repository target directory
499
+ * @param {string} featureId - Active feature directory path
500
+ * @param {string} scenarioId - Target scenario ID
501
+ * @param {string} liveImagePath - Relative path to captured screenshot
502
+ * @param {string} approvedBy - Approver role/identity
503
+ * @returns {{ success: boolean, updatedManifest: object }}
504
+ */
505
+ function promoteVisualBaseline(targetDir, featureId, scenarioId, liveImagePath, approvedBy = 'human-lead') {
506
+ const manifestPath = path.join(targetDir, featureId, 'visual-qa.json');
507
+ if (!fs.existsSync(manifestPath)) {
508
+ const err = new Error(`visual-qa.json not found in "${featureId}".`);
509
+ err.code = 'VQA_MANIFEST_NOT_FOUND';
510
+ throw err;
511
+ }
512
+
513
+ const raw = fs.readFileSync(manifestPath, 'utf8');
514
+ const manifest = parseVisualManifest(raw);
515
+
516
+ const scenario = (manifest.scenarios || []).find(s => s.scenario_id === scenarioId);
517
+ if (!scenario) {
518
+ const err = new Error(`Scenario "${scenarioId}" not found in manifest.`);
519
+ err.code = 'VQA_SCENARIO_NOT_FOUND';
520
+ throw err;
521
+ }
522
+
523
+ const absLivePath = path.join(targetDir, liveImagePath);
524
+ if (!fs.existsSync(absLivePath)) {
525
+ const err = new Error(`Live image file "${liveImagePath}" not found.`);
526
+ err.code = 'VQA_IMAGE_NOT_FOUND';
527
+ throw err;
528
+ }
529
+
530
+ const { hashFile } = require('./hasher');
531
+ const imgHash = hashFile(absLivePath);
532
+
533
+ // Copy to canonical baselines directory
534
+ const baselinesDir = path.join(targetDir, featureId, 'baselines');
535
+ if (!fs.existsSync(baselinesDir)) {
536
+ fs.mkdirSync(baselinesDir, { recursive: true });
537
+ }
538
+
539
+ const canonicalRelPath = path.posix.join(featureId.replace(/\\/g, '/'), 'baselines', `${scenarioId.toLowerCase()}.png`);
540
+ const canonicalAbsPath = path.join(targetDir, canonicalRelPath);
541
+ fs.copyFileSync(absLivePath, canonicalAbsPath);
542
+
543
+ scenario.baseline = {
544
+ image_path: canonicalRelPath,
545
+ image_sha256: imgHash,
546
+ dom_hash: scenario.baseline ? scenario.baseline.dom_hash : 'd0m_h4sh_pr0m0t3d',
547
+ approved_by: approvedBy,
548
+ approved_at: new Date().toISOString()
549
+ };
550
+
551
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
552
+
553
+ return {
554
+ success: true,
555
+ updatedManifest: manifest
556
+ };
557
+ }
558
+
559
+ /**
560
+ * Validates a complete visual-qa.json file in read-only mode.
561
+ *
562
+ * @param {string} targetDir - Repository target directory
563
+ * @param {string} featureId - Active feature directory path
564
+ * @returns {{ valid: boolean, state: string, findings: Array<object> }}
565
+ */
566
+ function validateVisualManifest(targetDir, featureId) {
567
+ const manifestPath = path.join(targetDir, featureId, 'visual-qa.json');
568
+ if (!fs.existsSync(manifestPath)) {
569
+ return {
570
+ valid: false,
571
+ state: 'MISSING',
572
+ findings: []
573
+ };
574
+ }
575
+
576
+ let raw;
577
+ try {
578
+ raw = fs.readFileSync(manifestPath, 'utf8');
579
+ } catch (err) {
580
+ return {
581
+ valid: false,
582
+ state: 'UNREADABLE',
583
+ findings: [createFinding({
584
+ code: 'VQA_UNREADABLE',
585
+ contractId: 'visual-evidence-subordinate',
586
+ phase: 'visual-qa',
587
+ location: manifestPath,
588
+ details: err.message
589
+ })]
590
+ };
591
+ }
592
+
593
+ let manifest;
594
+ try {
595
+ manifest = parseVisualManifest(raw);
596
+ } catch (err) {
597
+ return {
598
+ valid: false,
599
+ state: 'INVALID',
600
+ findings: [createFinding({
601
+ code: 'VQA_INVALID_MANIFEST',
602
+ contractId: 'visual-evidence-subordinate',
603
+ phase: 'visual-qa',
604
+ location: manifestPath,
605
+ details: err.message
606
+ })]
607
+ };
608
+ }
609
+
610
+ const findings = [];
611
+ const scenarios = manifest.scenarios || [];
612
+
613
+ for (const s of scenarios) {
614
+ // 1. Viewport check
615
+ try {
616
+ validateViewport(s.viewport, s.scenario_id);
617
+ } catch (vErr) {
618
+ findings.push(createFinding({
619
+ code: 'VQA_INVALID_VIEWPORT',
620
+ contractId: 'deterministic-viewports',
621
+ phase: 'visual-qa',
622
+ location: s.scenario_id,
623
+ details: vErr.message
624
+ }));
625
+ }
626
+
627
+ // 2. Baseline integrity check
628
+ const baseCheck = validateBaselineIntegrity(s, targetDir);
629
+ findings.push(...baseCheck.findings);
630
+ }
631
+
632
+ return {
633
+ valid: findings.length === 0,
634
+ state: findings.length === 0 ? 'VALID' : 'INVALID',
635
+ findings
636
+ };
637
+ }
638
+
639
+ module.exports = {
640
+ VQA_SCHEMA_VERSION,
641
+ CANONICAL_VIEWPORT_PROFILES,
642
+ parseVisualManifest,
643
+ validateVisualSchema,
644
+ validateViewport,
645
+ validateEnvironmentMetadata,
646
+ applySelectorMasks,
647
+ maskSensitiveFieldsBeforeCapture,
648
+ validateBaselineIntegrity,
649
+ compareVisualEvidence,
650
+ promoteVisualBaseline,
651
+ validateVisualManifest
652
+ };
@@ -1,7 +1,7 @@
1
- # Gemstack Security Core (Military-Grade Shielding)
1
+ # Gemstack Security Core (Architecture & Security Gates)
2
2
 
3
3
  ## Propósito
4
- Esta es la Ley de "Seguridad por Diseño". Todo código escrito, planificado o revisado por la IA bajo el marco Gemstack DEBE adherirse a estos principios de blindaje, independientemente del stack tecnológico utilizado. El objetivo es mitigar el 99% de las vulnerabilidades comunes (OWASP) desde el momento de la concepción del código.
4
+ Esta es la Ley de "Seguridad por Diseño". Todo código escrito, planificado o revisado por la IA bajo el marco Gemstack DEBE adherirse a estos principios de blindaje, independientemente del stack tecnológico utilizado. El objetivo es establecer controles sistemáticos y verificables frente a vulnerabilidades comunes (OWASP Top 10) desde el momento de la concepción del código.
5
5
 
6
6
  ## 1. Cero Exposición de Credenciales (Zero Trust Secrets)
7
7
  - **Regla Estricta:** JAMÁS hardcodees contraseñas, llaves de API, secrets de Webhooks o URIs de bases de datos en el código fuente.
@@ -1,32 +0,0 @@
1
- name: Main CI
2
-
3
- on:
4
- push:
5
- branches:
6
- - main
7
-
8
- jobs:
9
- test:
10
- name: Full CI
11
- runs-on: ${{ matrix.os }}
12
- strategy:
13
- matrix:
14
- os: [ubuntu-latest, windows-latest, macos-latest]
15
- node-version: [18.x, 20.x]
16
- steps:
17
- - name: Checkout repository
18
- uses: actions/checkout@v4
19
- - name: Setup Node.js
20
- uses: actions/setup-node@v4
21
- with:
22
- node-version: ${{ matrix.node-version }}
23
- - name: Install Dependencies
24
- run: npm install
25
- - name: Run Native Tests
26
- run: npm test
27
- - name: Run CI Suite
28
- run: npm run ci:all
29
- - name: Run Demo Smoke
30
- run: npm run ci:demo
31
- - name: Validate Package Build
32
- run: npm run pack:dry