@veraxhq/verax 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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +237 -0
  3. package/bin/verax.js +452 -0
  4. package/package.json +57 -0
  5. package/src/verax/detect/comparison.js +69 -0
  6. package/src/verax/detect/confidence-engine.js +498 -0
  7. package/src/verax/detect/evidence-validator.js +33 -0
  8. package/src/verax/detect/expectation-model.js +204 -0
  9. package/src/verax/detect/findings-writer.js +31 -0
  10. package/src/verax/detect/index.js +397 -0
  11. package/src/verax/detect/skip-classifier.js +202 -0
  12. package/src/verax/flow/flow-engine.js +265 -0
  13. package/src/verax/flow/flow-spec.js +145 -0
  14. package/src/verax/flow/redaction.js +74 -0
  15. package/src/verax/index.js +97 -0
  16. package/src/verax/learn/action-contract-extractor.js +281 -0
  17. package/src/verax/learn/ast-contract-extractor.js +255 -0
  18. package/src/verax/learn/index.js +18 -0
  19. package/src/verax/learn/manifest-writer.js +97 -0
  20. package/src/verax/learn/project-detector.js +87 -0
  21. package/src/verax/learn/react-router-extractor.js +73 -0
  22. package/src/verax/learn/route-extractor.js +122 -0
  23. package/src/verax/learn/route-validator.js +215 -0
  24. package/src/verax/learn/source-instrumenter.js +214 -0
  25. package/src/verax/learn/static-extractor.js +222 -0
  26. package/src/verax/learn/truth-assessor.js +96 -0
  27. package/src/verax/learn/ts-contract-resolver.js +395 -0
  28. package/src/verax/observe/browser.js +22 -0
  29. package/src/verax/observe/console-sensor.js +166 -0
  30. package/src/verax/observe/dom-signature.js +23 -0
  31. package/src/verax/observe/domain-boundary.js +38 -0
  32. package/src/verax/observe/evidence-capture.js +5 -0
  33. package/src/verax/observe/human-driver.js +376 -0
  34. package/src/verax/observe/index.js +67 -0
  35. package/src/verax/observe/interaction-discovery.js +269 -0
  36. package/src/verax/observe/interaction-runner.js +410 -0
  37. package/src/verax/observe/network-sensor.js +173 -0
  38. package/src/verax/observe/selector-generator.js +74 -0
  39. package/src/verax/observe/settle.js +155 -0
  40. package/src/verax/observe/state-ui-sensor.js +200 -0
  41. package/src/verax/observe/traces-writer.js +82 -0
  42. package/src/verax/observe/ui-signal-sensor.js +197 -0
  43. package/src/verax/resolve-workspace-root.js +173 -0
  44. package/src/verax/scan-summary-writer.js +41 -0
  45. package/src/verax/shared/artifact-manager.js +139 -0
  46. package/src/verax/shared/caching.js +104 -0
  47. package/src/verax/shared/expectation-proof.js +4 -0
  48. package/src/verax/shared/redaction.js +227 -0
  49. package/src/verax/shared/retry-policy.js +89 -0
  50. package/src/verax/shared/timing-metrics.js +44 -0
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@veraxhq/verax",
3
+ "version": "0.1.0",
4
+ "description": "VERAX - Silent failure detection for websites",
5
+ "type": "module",
6
+ "main": "./src/verax/index.js",
7
+ "bin": {
8
+ "verax": "bin/verax.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "LICENSE"
14
+ ],
15
+ "exports": {
16
+ ".": {
17
+ "import": "./src/verax/index.js"
18
+ },
19
+ "./learn": {
20
+ "import": "./src/verax/learn/index.js"
21
+ },
22
+ "./observe": {
23
+ "import": "./src/verax/observe/index.js"
24
+ },
25
+ "./detect": {
26
+ "import": "./src/verax/detect/index.js"
27
+ }
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "scripts": {
33
+ "test": "node --test test/learn.test.js test/observe.test.js test/detect.test.js test/static-learn.test.js test/static-detect.test.js test/observe-boundary.test.js test/static-buttons-detect.test.js test/spa-support.test.js test/scan-summary.test.js test/route-validation.test.js test/skip-reasons.test.js test/observe-stabilization.test.js test/observe-coverage.test.js test/ast-extraction.test.js test/spa-ast-integration.test.js test/wave2-human-driver.test.js test/wave4-confidence.test.js test/wave5-action-contracts.test.js test/wave6-action-contracts.test.js test/wave7-auth-flows.test.js test/wave8-state-contracts.test.js test/wave9-hardening.test.js test/wave10-ci-summary.test.js test/cli-scan.test.js test/final-gate.test.js"
34
+ },
35
+ "dependencies": {
36
+ "@babel/generator": "^7.28.5",
37
+ "@babel/parser": "^7.28.5",
38
+ "@babel/traverse": "^7.28.5",
39
+ "@babel/types": "^7.28.5",
40
+ "glob": "^10.3.10",
41
+ "inquirer": "^9.2.15",
42
+ "node-html-parser": "^7.0.1",
43
+ "playwright": "^1.40.0",
44
+ "typescript": "^5.4.0"
45
+ },
46
+ "engines": {
47
+ "node": ">=18.0.0"
48
+ },
49
+ "keywords": [
50
+ "testing",
51
+ "qa",
52
+ "website",
53
+ "silent-failures",
54
+ "verification"
55
+ ],
56
+ "license": "MIT"
57
+ }
@@ -0,0 +1,69 @@
1
+ import { resolve } from 'path';
2
+ import { existsSync, readdirSync, statSync } from 'fs';
3
+ import { getUrlPath, getScreenshotHash } from './evidence-validator.js';
4
+
5
+ export function hasMeaningfulUrlChange(beforeUrl, afterUrl) {
6
+ const beforePath = getUrlPath(beforeUrl);
7
+ const afterPath = getUrlPath(afterUrl);
8
+
9
+ if (!beforePath || !afterPath) return false;
10
+
11
+ if (beforePath === afterPath) return false;
12
+
13
+ const beforeNormalized = beforePath.replace(/\/$/, '') || '/';
14
+ const afterNormalized = afterPath.replace(/\/$/, '') || '/';
15
+
16
+ return beforeNormalized !== afterNormalized;
17
+ }
18
+
19
+ export function hasVisibleChange(beforeScreenshot, afterScreenshot, projectDir) {
20
+ // Screenshots are stored as relative paths like "screenshots/before-..."
21
+ // Try new structure first (.verax/runs/<runId>/evidence/), then legacy
22
+ let beforePath, afterPath;
23
+ const newEvidencePath = resolve(projectDir, '.verax', 'runs');
24
+ const legacyObservePath = resolve(projectDir, '.veraxverax', 'observe');
25
+
26
+ // Check if new structure exists and find latest run
27
+ if (existsSync(newEvidencePath)) {
28
+ try {
29
+ const runs = readdirSync(newEvidencePath)
30
+ .map(name => ({ name, time: statSync(resolve(newEvidencePath, name)).mtimeMs }))
31
+ .sort((a, b) => b.time - a.time);
32
+ if (runs.length > 0) {
33
+ const runEvidencePath = resolve(newEvidencePath, runs[0].name, 'evidence', beforeScreenshot);
34
+ if (existsSync(runEvidencePath)) {
35
+ beforePath = resolve(newEvidencePath, runs[0].name, 'evidence', beforeScreenshot);
36
+ afterPath = resolve(newEvidencePath, runs[0].name, 'evidence', afterScreenshot);
37
+ } else {
38
+ beforePath = resolve(legacyObservePath, beforeScreenshot);
39
+ afterPath = resolve(legacyObservePath, afterScreenshot);
40
+ }
41
+ } else {
42
+ beforePath = resolve(legacyObservePath, beforeScreenshot);
43
+ afterPath = resolve(legacyObservePath, afterScreenshot);
44
+ }
45
+ } catch {
46
+ beforePath = resolve(legacyObservePath, beforeScreenshot);
47
+ afterPath = resolve(legacyObservePath, afterScreenshot);
48
+ }
49
+ } else {
50
+ beforePath = resolve(legacyObservePath, beforeScreenshot);
51
+ afterPath = resolve(legacyObservePath, afterScreenshot);
52
+ }
53
+
54
+ const beforeHash = getScreenshotHash(beforePath);
55
+ const afterHash = getScreenshotHash(afterPath);
56
+
57
+ if (!beforeHash || !afterHash) return false;
58
+
59
+ return beforeHash !== afterHash;
60
+ }
61
+
62
+ export function hasDomChange(trace) {
63
+ if (!trace.dom || !trace.dom.beforeHash || !trace.dom.afterHash) {
64
+ return false;
65
+ }
66
+
67
+ return trace.dom.beforeHash !== trace.dom.afterHash;
68
+ }
69
+
@@ -0,0 +1,498 @@
1
+ /**
2
+ * WAVE 4: CONFIDENCE ENGINE
3
+ *
4
+ * Evidence-based scoring for findings (0-100). No heuristics, no AI.
5
+ * Confidence computed strictly from:
6
+ * - Expectation proof strength (PROVEN_EXPECTATION required)
7
+ * - Runtime sensor evidence (network/console/ui-signals)
8
+ * - Deterministic observation signals (url/dom/screenshot changes)
9
+ */
10
+
11
+ const BASE_SCORES = {
12
+ network_silent_failure: 70,
13
+ validation_silent_failure: 60,
14
+ missing_feedback_failure: 55,
15
+ no_effect_silent_failure: 50,
16
+ missing_network_action: 65,
17
+ missing_state_action: 60
18
+ };
19
+
20
+ const CONFIDENCE_LEVELS = {
21
+ HIGH: 80,
22
+ MEDIUM: 60
23
+ };
24
+
25
+ const LONG_ACTION_MS = 2000; // Threshold for slow requests
26
+
27
+ /**
28
+ * Compute confidence score for a finding.
29
+ *
30
+ * @param {Object} params
31
+ * @param {string} params.findingType - Type of finding
32
+ * @param {Object} params.expectation - Expectation with proof status
33
+ * @param {Object} params.sensors - Sensor data (network, console, uiSignals)
34
+ * @param {Object} params.comparisons - Comparison results (hasUrlChange, hasDomChange, hasVisibleChange)
35
+ * @param {Object} params.attemptMeta - Metadata about the interaction attempt
36
+ * @returns {Object} { score, level, reasons, breakdown }
37
+ */
38
+ export function computeConfidence({ findingType, expectation, sensors = {}, comparisons = {}, attemptMeta = {} }) {
39
+ const baseScore = BASE_SCORES[findingType] || 50;
40
+ const points = { plus: {}, minus: {} };
41
+ const reasons = [];
42
+
43
+ const networkSummary = sensors.network || {};
44
+ const consoleSummary = sensors.console || {};
45
+ const uiSignals = sensors.uiSignals || {};
46
+
47
+ // Extract signals
48
+ const hasErrorFeedback = detectErrorFeedback(uiSignals);
49
+ const hasLoadingFeedback = detectLoadingFeedback(uiSignals);
50
+ const hasAnyFeedback = hasErrorFeedback || hasLoadingFeedback || detectStatusFeedback(uiSignals);
51
+
52
+ let totalPlus = 0;
53
+ let totalMinus = 0;
54
+
55
+ // Type-specific scoring
56
+ switch (findingType) {
57
+ case 'network_silent_failure':
58
+ totalPlus += scoreNetworkSilentFailure({
59
+ networkSummary,
60
+ consoleSummary,
61
+ hasErrorFeedback,
62
+ hasAnyFeedback,
63
+ points,
64
+ reasons
65
+ });
66
+ totalMinus += penalizeNetworkSilentFailure({
67
+ hasAnyFeedback,
68
+ points,
69
+ reasons
70
+ });
71
+ break;
72
+
73
+ case 'validation_silent_failure':
74
+ totalPlus += scoreValidationSilentFailure({
75
+ networkSummary,
76
+ consoleSummary,
77
+ hasErrorFeedback,
78
+ attemptMeta,
79
+ points,
80
+ reasons
81
+ });
82
+ totalMinus += penalizeValidationSilentFailure({
83
+ hasErrorFeedback,
84
+ points,
85
+ reasons
86
+ });
87
+ break;
88
+
89
+ case 'missing_feedback_failure':
90
+ totalPlus += scoreMissingFeedbackFailure({
91
+ networkSummary,
92
+ hasLoadingFeedback,
93
+ points,
94
+ reasons
95
+ });
96
+ totalMinus += penalizeMissingFeedbackFailure({
97
+ hasLoadingFeedback,
98
+ points,
99
+ reasons
100
+ });
101
+ break;
102
+
103
+ case 'no_effect_silent_failure':
104
+ totalPlus += scoreNoEffectSilentFailure({
105
+ expectation,
106
+ comparisons,
107
+ networkSummary,
108
+ hasAnyFeedback,
109
+ points,
110
+ reasons
111
+ });
112
+ totalMinus += penalizeNoEffectSilentFailure({
113
+ networkSummary,
114
+ hasAnyFeedback,
115
+ points,
116
+ reasons
117
+ });
118
+ break;
119
+
120
+ case 'missing_network_action':
121
+ totalPlus += scoreMissingNetworkAction({
122
+ expectation,
123
+ attemptMeta,
124
+ consoleSummary,
125
+ networkSummary,
126
+ points,
127
+ reasons
128
+ });
129
+ totalMinus += penalizeMissingNetworkAction({
130
+ networkSummary,
131
+ points,
132
+ reasons
133
+ });
134
+ break;
135
+
136
+ case 'missing_state_action':
137
+ totalPlus += scoreMissingStateAction({
138
+ expectation,
139
+ attemptMeta,
140
+ sensors,
141
+ comparisons,
142
+ points,
143
+ reasons
144
+ });
145
+ totalMinus += penalizeMissingStateAction({
146
+ sensors,
147
+ networkSummary,
148
+ points,
149
+ reasons
150
+ });
151
+ break;
152
+ }
153
+
154
+ // Compute final score
155
+ let score = baseScore + totalPlus - totalMinus;
156
+ score = Math.max(0, Math.min(100, score)); // Cap to [0, 100]
157
+
158
+ // Determine level
159
+ let level = 'LOW';
160
+ if (score >= CONFIDENCE_LEVELS.HIGH) {
161
+ level = 'HIGH';
162
+ } else if (score >= CONFIDENCE_LEVELS.MEDIUM) {
163
+ level = 'MEDIUM';
164
+ }
165
+
166
+ // Limit reasons to 6 most important
167
+ const limitedReasons = reasons.slice(0, 6);
168
+
169
+ return {
170
+ score: Math.round(score),
171
+ level,
172
+ reasons: limitedReasons,
173
+ breakdown: {
174
+ base: baseScore,
175
+ plus: points.plus,
176
+ minus: points.minus
177
+ }
178
+ };
179
+ }
180
+
181
+ // === NETWORK SILENT FAILURE SCORING ===
182
+
183
+ function scoreNetworkSilentFailure({ networkSummary, consoleSummary, hasErrorFeedback, hasAnyFeedback, points, reasons }) {
184
+ let total = 0;
185
+
186
+ // +15 if server error (5xx)
187
+ if (networkSummary.failedRequests >= 1) {
188
+ const has5xxError = Object.keys(networkSummary.failedByStatus || {}).some(status => parseInt(status) >= 500);
189
+ if (has5xxError) {
190
+ points.plus.serverError = 15;
191
+ total += 15;
192
+ reasons.push('Server error (5xx) detected');
193
+ } else {
194
+ // Client error (4xx)
195
+ points.plus.networkFailure = 10;
196
+ total += 10;
197
+ reasons.push('Network request failed');
198
+ }
199
+ }
200
+
201
+ // +10 if request explicitly failed (requestfailed event)
202
+ if (networkSummary.failedRequests > 0) {
203
+ const hasExplicitFailure = networkSummary.topFailedUrls?.length > 0;
204
+ if (hasExplicitFailure) {
205
+ points.plus.explicitFailure = 10;
206
+ total += 10;
207
+ reasons.push('Request failure event captured');
208
+ }
209
+ }
210
+
211
+ // +10 if console shows page error or unhandled rejection
212
+ if ((consoleSummary.pageErrorCount || 0) > 0 || (consoleSummary.unhandledRejectionCount || 0) > 0) {
213
+ points.plus.jsError = 10;
214
+ total += 10;
215
+ reasons.push('JavaScript error or unhandled rejection logged');
216
+ }
217
+
218
+ // +10 if NO user feedback at all
219
+ if (!hasAnyFeedback) {
220
+ points.plus.noFeedback = 10;
221
+ total += 10;
222
+ reasons.push('No user-visible error feedback');
223
+ }
224
+
225
+ return total;
226
+ }
227
+
228
+ function penalizeNetworkSilentFailure({ hasAnyFeedback, points, reasons }) {
229
+ let total = 0;
230
+
231
+ // -20 if any feedback exists (shouldn't be classified as silent failure)
232
+ if (hasAnyFeedback) {
233
+ points.minus.hasFeedback = 20;
234
+ total += 20;
235
+ reasons.push('User feedback detected (reduces confidence)');
236
+ }
237
+
238
+ return total;
239
+ }
240
+
241
+ // === VALIDATION SILENT FAILURE SCORING ===
242
+
243
+ function scoreValidationSilentFailure({ networkSummary, consoleSummary, hasErrorFeedback, attemptMeta, points, reasons }) {
244
+ let total = 0;
245
+
246
+ // +15 if invalid fields detected
247
+ const invalidFieldsCount = attemptMeta.invalidFieldsCount || 0;
248
+ if (invalidFieldsCount >= 1) {
249
+ points.plus.invalidFields = 15;
250
+ total += 15;
251
+ reasons.push(`${invalidFieldsCount} invalid form field(s) detected`);
252
+ }
253
+
254
+ // +10 if console errors logged
255
+ if ((consoleSummary.consoleErrorCount || 0) >= 1) {
256
+ points.plus.consoleError = 10;
257
+ total += 10;
258
+ reasons.push('Validation errors logged to console');
259
+ }
260
+
261
+ // +10 if no validation feedback visible
262
+ if (!hasErrorFeedback) {
263
+ points.plus.noValidationFeedback = 10;
264
+ total += 10;
265
+ reasons.push('No visible validation error message');
266
+ }
267
+
268
+ return total;
269
+ }
270
+
271
+ function penalizeValidationSilentFailure({ hasErrorFeedback, points, reasons }) {
272
+ let total = 0;
273
+
274
+ // -15 if error feedback exists
275
+ if (hasErrorFeedback) {
276
+ points.minus.hasErrorFeedback = 15;
277
+ total += 15;
278
+ reasons.push('Error feedback visible (reduces confidence)');
279
+ }
280
+
281
+ return total;
282
+ }
283
+
284
+ // === MISSING FEEDBACK FAILURE SCORING ===
285
+
286
+ function scoreMissingFeedbackFailure({ networkSummary, hasLoadingFeedback, points, reasons }) {
287
+ let total = 0;
288
+
289
+ // +15 if slow requests detected
290
+ if ((networkSummary.slowRequestsCount || 0) >= 1) {
291
+ const slowestDuration = networkSummary.slowRequests?.[0]?.duration || 0;
292
+ points.plus.slowRequest = 15;
293
+ total += 15;
294
+ reasons.push(`Slow request detected (${slowestDuration}ms)`);
295
+ }
296
+
297
+ // +10 if significant network activity
298
+ if ((networkSummary.totalRequests || 0) >= 1 && (networkSummary.durationMs || 0) >= LONG_ACTION_MS) {
299
+ points.plus.longAction = 10;
300
+ total += 10;
301
+ reasons.push('Long-running network activity');
302
+ }
303
+
304
+ // +10 if no loading feedback
305
+ if (!hasLoadingFeedback) {
306
+ points.plus.noLoadingFeedback = 10;
307
+ total += 10;
308
+ reasons.push('No loading indicator shown');
309
+ }
310
+
311
+ return total;
312
+ }
313
+
314
+ function penalizeMissingFeedbackFailure({ hasLoadingFeedback, points, reasons }) {
315
+ let total = 0;
316
+
317
+ // -10 if loading indicator detected
318
+ if (hasLoadingFeedback) {
319
+ points.minus.hasLoadingFeedback = 10;
320
+ total += 10;
321
+ reasons.push('Loading indicator detected (reduces confidence)');
322
+ }
323
+
324
+ return total;
325
+ }
326
+
327
+ // === NO EFFECT SILENT FAILURE SCORING ===
328
+
329
+ function scoreNoEffectSilentFailure({ expectation, comparisons, networkSummary, hasAnyFeedback, points, reasons }) {
330
+ let total = 0;
331
+
332
+ // +15 if expected navigation but no URL change
333
+ const expectsNavigation = expectation?.expectationType === 'navigation' ||
334
+ expectation?.expectationType === 'spa_navigation' ||
335
+ expectation?.expectationType === 'form_submission';
336
+ if (expectsNavigation && !comparisons.hasUrlChange) {
337
+ points.plus.expectedNavNoUrl = 15;
338
+ total += 15;
339
+ reasons.push('Expected navigation did not occur');
340
+ }
341
+
342
+ // +10 if no DOM change at all
343
+ if (!comparisons.hasDomChange) {
344
+ points.plus.noDomChange = 10;
345
+ total += 10;
346
+ reasons.push('No DOM changes detected');
347
+ }
348
+
349
+ // +10 if screenshot unchanged (if available)
350
+ if (comparisons.hasVisibleChange === false) {
351
+ points.plus.noVisibleChange = 10;
352
+ total += 10;
353
+ reasons.push('No visible changes in screenshot');
354
+ }
355
+
356
+ return total;
357
+ }
358
+
359
+ function penalizeNoEffectSilentFailure({ networkSummary, hasAnyFeedback, points, reasons }) {
360
+ let total = 0;
361
+
362
+ // -10 if network activity occurred (might be effect without visible change)
363
+ if ((networkSummary.totalRequests || 0) > 0) {
364
+ points.minus.hasNetworkActivity = 10;
365
+ total += 10;
366
+ reasons.push('Network activity detected (potential hidden effect)');
367
+ }
368
+
369
+ // -10 if UI signal changed
370
+ if (hasAnyFeedback) {
371
+ points.minus.uiSignalChanged = 10;
372
+ total += 10;
373
+ reasons.push('UI signal changed (potential effect)');
374
+ }
375
+
376
+ return total;
377
+ }
378
+
379
+ // === HELPER FUNCTIONS ===
380
+
381
+ function detectErrorFeedback(uiSignals) {
382
+ const before = uiSignals.before || {};
383
+ const after = uiSignals.after || {};
384
+ const changes = uiSignals.changes || {};
385
+
386
+ // Check if error signal appeared after interaction
387
+ return (after.hasErrorSignal && !before.hasErrorSignal) ||
388
+ (changes.changed && after.hasErrorSignal);
389
+ }
390
+
391
+ function detectLoadingFeedback(uiSignals) {
392
+ const before = uiSignals.before || {};
393
+ const after = uiSignals.after || {};
394
+
395
+ // Check if loading indicator appeared or changed
396
+ return after.hasLoadingIndicator ||
397
+ (before.hasLoadingIndicator !== after.hasLoadingIndicator);
398
+ }
399
+
400
+ function detectStatusFeedback(uiSignals) {
401
+ const after = uiSignals.after || {};
402
+
403
+ // Check for any status/live region updates
404
+ return after.hasStatusSignal || after.hasLiveRegion || after.hasDialog;
405
+ }
406
+
407
+ // === MISSING NETWORK ACTION SCORING ===
408
+
409
+ function scoreMissingNetworkAction({ expectation, attemptMeta, consoleSummary, networkSummary, points, reasons }) {
410
+ let total = 0;
411
+
412
+ // +15 if PROVEN expectation with source attribution (high certainty of broken promise)
413
+ if (expectation?.proof === 'PROVEN_EXPECTATION' && attemptMeta.sourceRef) {
414
+ points.plus.provenContract = 15;
415
+ total += 15;
416
+ reasons.push('Code contract proven via AST analysis');
417
+ }
418
+
419
+ // +10 if console has errors (might explain why request didn't fire)
420
+ if ((consoleSummary.consoleErrorCount || 0) > 0 || (consoleSummary.pageErrorCount || 0) > 0) {
421
+ points.plus.consoleError = 10;
422
+ total += 10;
423
+ reasons.push('JavaScript errors may have prevented request');
424
+ }
425
+
426
+ // +10 if absolutely zero network activity (not even unrelated requests)
427
+ if ((networkSummary.totalRequests || 0) === 0) {
428
+ points.plus.zeroNetworkActivity = 10;
429
+ total += 10;
430
+ reasons.push('Zero network activity despite code promise');
431
+ }
432
+
433
+ return total;
434
+ }
435
+
436
+ function penalizeMissingNetworkAction({ networkSummary, points, reasons }) {
437
+ let total = 0;
438
+
439
+ // -15 if there WAS network activity (maybe promise fulfilled differently)
440
+ if ((networkSummary.totalRequests || 0) > 0) {
441
+ points.minus.hadNetworkActivity = 15;
442
+ total += 15;
443
+ reasons.push('Other network requests occurred (may be fulfilling contract)');
444
+ }
445
+
446
+ return total;
447
+ }
448
+
449
+ // === MISSING STATE ACTION SCORING ===
450
+
451
+ function scoreMissingStateAction({ expectation, attemptMeta, sensors, comparisons, points, reasons }) {
452
+ let total = 0;
453
+
454
+ // +15 if PROVEN expectation with handlerRef (TS cross-file proof)
455
+ if (expectation?.proof === 'PROVEN_EXPECTATION' && attemptMeta.handlerRef) {
456
+ points.plus.provenHandlerRef = 15;
457
+ total += 15;
458
+ reasons.push('State mutation proven via TS cross-file analysis');
459
+ }
460
+
461
+ // +10 if state UI did not change (explicit signal)
462
+ const stateUI = sensors.stateUI || {};
463
+ if (stateUI.changed === false) {
464
+ points.plus.noStateChange = 10;
465
+ total += 10;
466
+ reasons.push('State UI signals show no change');
467
+ }
468
+
469
+ // +5 if DOM did not change (supports missing state mutation theory)
470
+ if (comparisons.hasDomChange === false) {
471
+ points.plus.noDomChange = 5;
472
+ total += 5;
473
+ reasons.push('DOM unchanged despite promised state mutation');
474
+ }
475
+
476
+ return total;
477
+ }
478
+
479
+ function penalizeMissingStateAction({ sensors, networkSummary, points, reasons }) {
480
+ let total = 0;
481
+
482
+ // -10 if there IS network activity (may be causing state change asynchronously)
483
+ if ((networkSummary.totalRequests || 0) > 0) {
484
+ points.minus.hadNetworkActivity = 10;
485
+ total += 10;
486
+ reasons.push('Network activity may be causing deferred state update');
487
+ }
488
+
489
+ // -10 if UI feedback changed (may indicate state managed differently)
490
+ const uiSignals = sensors.uiSignals || {};
491
+ if (uiSignals.changes?.changed === true) {
492
+ points.minus.hadUIFeedback = 10;
493
+ total += 10;
494
+ reasons.push('UI feedback changed (state may be managed via feedback rather than direct mutation)');
495
+ }
496
+
497
+ return total;
498
+ }
@@ -0,0 +1,33 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { createHash } from 'crypto';
3
+
4
+ export function normalizeUrl(url) {
5
+ try {
6
+ const urlObj = new URL(url);
7
+ return {
8
+ origin: urlObj.origin,
9
+ pathname: urlObj.pathname,
10
+ search: urlObj.search,
11
+ hash: urlObj.hash
12
+ };
13
+ } catch (error) {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ export function getUrlPath(url) {
19
+ const normalized = normalizeUrl(url);
20
+ if (!normalized) return null;
21
+ return normalized.pathname + normalized.hash;
22
+ }
23
+
24
+ export function getScreenshotHash(screenshotPath) {
25
+ try {
26
+ if (!existsSync(screenshotPath)) return null;
27
+ const imageData = readFileSync(screenshotPath);
28
+ return createHash('md5').update(imageData).digest('hex');
29
+ } catch (error) {
30
+ return null;
31
+ }
32
+ }
33
+