@veraxhq/verax 0.1.0 → 0.2.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 (126) hide show
  1. package/README.md +123 -88
  2. package/bin/verax.js +11 -452
  3. package/package.json +14 -36
  4. package/src/cli/commands/default.js +523 -0
  5. package/src/cli/commands/doctor.js +165 -0
  6. package/src/cli/commands/inspect.js +109 -0
  7. package/src/cli/commands/run.js +402 -0
  8. package/src/cli/entry.js +196 -0
  9. package/src/cli/util/atomic-write.js +37 -0
  10. package/src/cli/util/detection-engine.js +296 -0
  11. package/src/cli/util/env-url.js +33 -0
  12. package/src/cli/util/errors.js +44 -0
  13. package/src/cli/util/events.js +34 -0
  14. package/src/cli/util/expectation-extractor.js +378 -0
  15. package/src/cli/util/findings-writer.js +31 -0
  16. package/src/cli/util/idgen.js +87 -0
  17. package/src/cli/util/learn-writer.js +39 -0
  18. package/src/cli/util/observation-engine.js +366 -0
  19. package/src/cli/util/observe-writer.js +25 -0
  20. package/src/cli/util/paths.js +29 -0
  21. package/src/cli/util/project-discovery.js +277 -0
  22. package/src/cli/util/project-writer.js +26 -0
  23. package/src/cli/util/redact.js +128 -0
  24. package/src/cli/util/run-id.js +30 -0
  25. package/src/cli/util/summary-writer.js +32 -0
  26. package/src/verax/cli/ci-summary.js +35 -0
  27. package/src/verax/cli/context-explanation.js +89 -0
  28. package/src/verax/cli/doctor.js +277 -0
  29. package/src/verax/cli/error-normalizer.js +154 -0
  30. package/src/verax/cli/explain-output.js +105 -0
  31. package/src/verax/cli/finding-explainer.js +130 -0
  32. package/src/verax/cli/init.js +237 -0
  33. package/src/verax/cli/run-overview.js +163 -0
  34. package/src/verax/cli/url-safety.js +101 -0
  35. package/src/verax/cli/wizard.js +98 -0
  36. package/src/verax/cli/zero-findings-explainer.js +57 -0
  37. package/src/verax/cli/zero-interaction-explainer.js +127 -0
  38. package/src/verax/core/action-classifier.js +86 -0
  39. package/src/verax/core/budget-engine.js +218 -0
  40. package/src/verax/core/canonical-outcomes.js +157 -0
  41. package/src/verax/core/decision-snapshot.js +335 -0
  42. package/src/verax/core/determinism-model.js +403 -0
  43. package/src/verax/core/incremental-store.js +237 -0
  44. package/src/verax/core/invariants.js +356 -0
  45. package/src/verax/core/promise-model.js +230 -0
  46. package/src/verax/core/replay-validator.js +350 -0
  47. package/src/verax/core/replay.js +222 -0
  48. package/src/verax/core/run-id.js +175 -0
  49. package/src/verax/core/run-manifest.js +99 -0
  50. package/src/verax/core/silence-impact.js +369 -0
  51. package/src/verax/core/silence-model.js +521 -0
  52. package/src/verax/detect/comparison.js +2 -34
  53. package/src/verax/detect/confidence-engine.js +764 -329
  54. package/src/verax/detect/detection-engine.js +293 -0
  55. package/src/verax/detect/evidence-index.js +177 -0
  56. package/src/verax/detect/expectation-model.js +194 -172
  57. package/src/verax/detect/explanation-helpers.js +187 -0
  58. package/src/verax/detect/finding-detector.js +450 -0
  59. package/src/verax/detect/findings-writer.js +44 -8
  60. package/src/verax/detect/flow-detector.js +366 -0
  61. package/src/verax/detect/index.js +172 -286
  62. package/src/verax/detect/interactive-findings.js +613 -0
  63. package/src/verax/detect/signal-mapper.js +308 -0
  64. package/src/verax/detect/verdict-engine.js +563 -0
  65. package/src/verax/evidence-index-writer.js +61 -0
  66. package/src/verax/index.js +90 -14
  67. package/src/verax/intel/effect-detector.js +368 -0
  68. package/src/verax/intel/handler-mapper.js +249 -0
  69. package/src/verax/intel/index.js +281 -0
  70. package/src/verax/intel/route-extractor.js +280 -0
  71. package/src/verax/intel/ts-program.js +256 -0
  72. package/src/verax/intel/vue-navigation-extractor.js +579 -0
  73. package/src/verax/intel/vue-router-extractor.js +323 -0
  74. package/src/verax/learn/action-contract-extractor.js +335 -101
  75. package/src/verax/learn/ast-contract-extractor.js +95 -5
  76. package/src/verax/learn/flow-extractor.js +172 -0
  77. package/src/verax/learn/manifest-writer.js +97 -47
  78. package/src/verax/learn/project-detector.js +40 -0
  79. package/src/verax/learn/route-extractor.js +27 -96
  80. package/src/verax/learn/state-extractor.js +212 -0
  81. package/src/verax/learn/static-extractor-navigation.js +114 -0
  82. package/src/verax/learn/static-extractor-validation.js +88 -0
  83. package/src/verax/learn/static-extractor.js +112 -4
  84. package/src/verax/learn/truth-assessor.js +24 -21
  85. package/src/verax/observe/aria-sensor.js +211 -0
  86. package/src/verax/observe/browser.js +10 -5
  87. package/src/verax/observe/console-sensor.js +1 -17
  88. package/src/verax/observe/domain-boundary.js +10 -1
  89. package/src/verax/observe/expectation-executor.js +512 -0
  90. package/src/verax/observe/flow-matcher.js +143 -0
  91. package/src/verax/observe/focus-sensor.js +196 -0
  92. package/src/verax/observe/human-driver.js +643 -275
  93. package/src/verax/observe/index.js +908 -27
  94. package/src/verax/observe/index.js.backup +1 -0
  95. package/src/verax/observe/interaction-discovery.js +365 -14
  96. package/src/verax/observe/interaction-runner.js +563 -198
  97. package/src/verax/observe/loading-sensor.js +139 -0
  98. package/src/verax/observe/navigation-sensor.js +255 -0
  99. package/src/verax/observe/network-sensor.js +55 -7
  100. package/src/verax/observe/observed-expectation-deriver.js +186 -0
  101. package/src/verax/observe/observed-expectation.js +305 -0
  102. package/src/verax/observe/page-frontier.js +234 -0
  103. package/src/verax/observe/settle.js +37 -17
  104. package/src/verax/observe/state-sensor.js +389 -0
  105. package/src/verax/observe/timing-sensor.js +228 -0
  106. package/src/verax/observe/traces-writer.js +61 -20
  107. package/src/verax/observe/ui-signal-sensor.js +136 -17
  108. package/src/verax/scan-summary-writer.js +77 -15
  109. package/src/verax/shared/artifact-manager.js +110 -8
  110. package/src/verax/shared/budget-profiles.js +136 -0
  111. package/src/verax/shared/ci-detection.js +39 -0
  112. package/src/verax/shared/config-loader.js +170 -0
  113. package/src/verax/shared/dynamic-route-utils.js +218 -0
  114. package/src/verax/shared/expectation-coverage.js +44 -0
  115. package/src/verax/shared/expectation-prover.js +81 -0
  116. package/src/verax/shared/expectation-tracker.js +201 -0
  117. package/src/verax/shared/expectations-writer.js +60 -0
  118. package/src/verax/shared/first-run.js +44 -0
  119. package/src/verax/shared/progress-reporter.js +171 -0
  120. package/src/verax/shared/retry-policy.js +14 -1
  121. package/src/verax/shared/root-artifacts.js +49 -0
  122. package/src/verax/shared/scan-budget.js +86 -0
  123. package/src/verax/shared/url-normalizer.js +162 -0
  124. package/src/verax/shared/zip-artifacts.js +65 -0
  125. package/src/verax/validate/context-validator.js +244 -0
  126. package/src/verax/validate/context-validator.js.bak +0 -0
@@ -0,0 +1,563 @@
1
+ /**
2
+ * OBSERVATION ENGINE
3
+ *
4
+ * Produces an observation summary from scan results.
5
+ *
6
+ * VERAX is an Outcome Observer - it does NOT judge, validate, or decide.
7
+ * It observes what code promises, what users do, and what actually happens.
8
+ * It reports observations, discrepancies, gaps, and unknowns - nothing more.
9
+ *
10
+ * NO VERDICT. NO JUDGMENT. NO SAFETY CLAIM. NO GO/NO-GO DECISIONS.
11
+ *
12
+ * PHASE 2: All observations include canonical outcome classifications.
13
+ * PHASE 3: All observations include Promise awareness - what promise was being evaluated.
14
+ * PHASE 4: All observations include Silence lifecycle - type, trigger, evaluation status, confidence impact.
15
+ */
16
+
17
+ import fs from 'fs';
18
+ import path from 'path';
19
+ import { buildEvidenceIndex, writeEvidenceIndex } from './evidence-index.js';
20
+ import { CANONICAL_OUTCOMES } from '../core/canonical-outcomes.js';
21
+ import { SILENCE_TYPES, EVALUATION_STATUS } from '../core/silence-model.js';
22
+ import { inferPromiseFromInteraction } from '../core/promise-model.js';
23
+ import { createImpactSummary } from '../core/silence-impact.js';
24
+
25
+ /**
26
+ * Compute observation summary from scan findings and analysis.
27
+ *
28
+ * SILENCE TRACKING: All gaps, timeouts, skips, sensor failures are explicit.
29
+ * Nothing unobserved is allowed to disappear.
30
+ *
31
+ * @param {Array} findings - Array of finding objects (observed discrepancies)
32
+ * @param {Object} observeTruth - Coverage data (what was observed)
33
+ * @param {Object} learnTruth - Learned route data
34
+ * @param {Array} coverageGaps - Expectations/interactions not evaluated
35
+ * @param {Boolean} budgetExceeded - Whether budget was exceeded during scan
36
+ * @param {Object} detectTruth - Detection truth (includes silence data)
37
+ * @returns {Object} ObservationSummary with findings, gaps, unknowns, coverage facts, silences
38
+ */
39
+ export function computeObservationSummary(findings, observeTruth, learnTruth, coverageGaps, budgetExceeded, detectTruth = null, projectDir = null, silenceTracker = null) {
40
+ const isBudgetExceeded = budgetExceeded !== undefined ? budgetExceeded : (observeTruth?.budgetExceeded === true);
41
+ const traces = Array.isArray(observeTruth?.traces) ? observeTruth.traces : [];
42
+ const evidenceBuild = buildEvidenceIndex(traces, projectDir, silenceTracker);
43
+
44
+ // Extract coverage facts
45
+ const coverage = observeTruth?.coverage || {};
46
+ const pagesEvaluated = coverage.pagesVisited || 0;
47
+ const pagesDiscovered = coverage.pagesDiscovered || 0;
48
+ const interactionsEvaluated = coverage.interactionsExecuted || coverage.candidatesSelected || observeTruth?.interactionsObserved || 0;
49
+ const interactionsDiscovered = coverage.interactionsDiscovered || coverage.candidatesDiscovered || 0;
50
+ const expectationTotal = learnTruth?.expectationsDiscovered || 0;
51
+ const coverageGapsCount = coverageGaps?.length || 0;
52
+ const expectationsEvaluated = Math.max(0, expectationTotal - coverageGapsCount);
53
+
54
+ // Count unproven results (interactions without PROVEN expectations)
55
+ const unprovenTraces = traces.filter(t =>
56
+ t.unprovenResult === true || t.resultType === 'UNPROVEN_RESULT'
57
+ );
58
+ const skippedCount = coverage.skippedInteractions || 0;
59
+
60
+ // Count findings by confidence (for transparency, not judgment)
61
+ const findingsByConfidence = {
62
+ HIGH: 0,
63
+ MEDIUM: 0,
64
+ LOW: 0,
65
+ UNKNOWN: 0
66
+ };
67
+ const findingsByType = {};
68
+ const findingsByOutcome = {}; // PHASE 2: Added outcome tracking
69
+ const findingsByPromise = {}; // PHASE 3: Added promise tracking
70
+
71
+ for (const finding of (findings || [])) {
72
+ const confidence = finding.confidence?.level || 'UNKNOWN';
73
+ const type = finding.type || 'unknown';
74
+ const outcome = finding.outcome || CANONICAL_OUTCOMES.SILENT_FAILURE; // Default for legacy findings
75
+ const promiseType = finding.promise?.type || 'UNKNOWN_PROMISE'; // PHASE 3
76
+
77
+ if (findingsByConfidence.hasOwnProperty(confidence)) {
78
+ findingsByConfidence[confidence]++;
79
+ }
80
+ findingsByType[type] = (findingsByType[type] || 0) + 1;
81
+ findingsByOutcome[outcome] = (findingsByOutcome[outcome] || 0) + 1; // PHASE 2
82
+ findingsByPromise[promiseType] = (findingsByPromise[promiseType] || 0) + 1; // PHASE 3
83
+ }
84
+
85
+ // Calculate ratios (factual, not judgmental)
86
+ const pageRatio = pagesDiscovered > 0 ? clampRatio(pagesEvaluated / pagesDiscovered) : (pagesDiscovered === 0 ? null : 0);
87
+ const interactionRatio = interactionsDiscovered > 0 ? clampRatio(interactionsEvaluated / interactionsDiscovered) : (interactionsDiscovered === 0 ? null : 0);
88
+ const expectationRatio = expectationTotal > 0 ? clampRatio(expectationsEvaluated / expectationTotal) : (expectationTotal === 0 ? null : 0);
89
+
90
+ // Identify gaps explicitly
91
+ const gaps = {
92
+ pages: pagesDiscovered > pagesEvaluated ? pagesDiscovered - pagesEvaluated : 0,
93
+ interactions: interactionsDiscovered > interactionsEvaluated ? interactionsDiscovered - interactionsEvaluated : 0,
94
+ expectations: coverageGapsCount,
95
+ skippedInteractions: skippedCount,
96
+ unprovenResults: unprovenTraces.length
97
+ };
98
+
99
+ // Build gap details
100
+ const gapDetails = [];
101
+ if (isBudgetExceeded) {
102
+ gapDetails.push({
103
+ outcome: CANONICAL_OUTCOMES.COVERAGE_GAP, // PHASE 2
104
+ type: 'budget_exceeded',
105
+ message: `Budget limit reached: ${pagesEvaluated} ${pagesEvaluated}/${pagesDiscovered} pages and ${interactionsEvaluated}/${interactionsDiscovered} interactions evaluated - observation incomplete`,
106
+ pagesAffected: pagesDiscovered - pagesEvaluated,
107
+ interactionsAffected: interactionsDiscovered - interactionsEvaluated
108
+ });
109
+ }
110
+ if (gaps.pages > 0) {
111
+ gapDetails.push({
112
+ outcome: CANONICAL_OUTCOMES.COVERAGE_GAP, // PHASE 2
113
+ type: 'pages_not_evaluated',
114
+ message: `${gaps.pages} page(s) discovered but not visited - observations for these pages are unavailable`,
115
+ count: gaps.pages
116
+ });
117
+ }
118
+ if (gaps.interactions > 0) {
119
+ gapDetails.push({
120
+ outcome: CANONICAL_OUTCOMES.COVERAGE_GAP, // PHASE 2
121
+ type: 'interactions_not_evaluated',
122
+ message: `${gaps.interactions} interaction(s) discovered but not executed - behavior of these interactions is unknown`,
123
+ count: gaps.interactions
124
+ });
125
+ }
126
+ if (gaps.expectations > 0) {
127
+ gapDetails.push({
128
+ outcome: CANONICAL_OUTCOMES.COVERAGE_GAP, // PHASE 2
129
+ type: 'expectations_not_evaluated',
130
+ message: `${gaps.expectations} expectation(s) defined but not evaluated - cannot determine if code matches reality for these`,
131
+ count: gaps.expectations,
132
+ details: coverageGaps.slice(0, 10) // Limit detail list
133
+ });
134
+ }
135
+ if (gaps.skippedInteractions > 0) {
136
+ gapDetails.push({
137
+ outcome: CANONICAL_OUTCOMES.UNPROVEN_INTERACTION, // PHASE 2: Executed but outcome not asserted
138
+ type: 'interactions_skipped',
139
+ message: `${gaps.skippedInteractions} interaction(s) executed but outcomes not evaluated (safety policy, ambiguous state, or technical limitations)`,
140
+ count: gaps.skippedInteractions
141
+ });
142
+ }
143
+
144
+ // Build observation summary
145
+ const summary = {
146
+ toolStatus: 'completed',
147
+ observations: {
148
+ discrepanciesObserved: findings?.length || 0,
149
+ discrepanciesByType: findingsByType,
150
+ discrepanciesByConfidence: findingsByConfidence,
151
+ discrepanciesByOutcome: findingsByOutcome, // PHASE 2: Canonical outcomes
152
+ discrepanciesByPromise: findingsByPromise, // PHASE 3: Promise types
153
+ findings: findings || []
154
+ },
155
+ coverage: {
156
+ pagesEvaluated,
157
+ pagesDiscovered,
158
+ pageRatio: pageRatio !== null ? pageRatio : undefined,
159
+ interactionsEvaluated,
160
+ interactionsDiscovered,
161
+ interactionRatio: interactionRatio !== null ? interactionRatio : undefined,
162
+ expectationsEvaluated,
163
+ expectationsDiscovered: expectationTotal,
164
+ expectationRatio: expectationRatio !== null ? expectationRatio : undefined
165
+ },
166
+ gaps: {
167
+ total: Object.values(gaps).reduce((a, b) => a + b, 0),
168
+ pages: gaps.pages,
169
+ interactions: gaps.interactions + gaps.skippedInteractions,
170
+ expectations: gaps.expectations,
171
+ unprovenResults: gaps.unprovenResults,
172
+ details: gapDetails
173
+ },
174
+ // SILENCE TRACKING: Attach all silence data for explicit reporting
175
+ silences: detectTruth?.silences || null,
176
+ // PHASE 4: Add silence impact accounting
177
+ silenceImpactSummary: detectTruth?.silences?.entries ?
178
+ createImpactSummary(detectTruth.silences.entries) :
179
+ null,
180
+ evidenceIndex: evidenceBuild.evidenceIndex,
181
+ observedAt: new Date().toISOString()
182
+ };
183
+
184
+ return summary;
185
+ }
186
+
187
+ /**
188
+ * Format observation summary for console output.
189
+ *
190
+ * PHASE 2: Includes canonical outcome classifications
191
+ *
192
+ * SILENCE PHILOSOPHY:
193
+ * - Gaps/unknowns ALWAYS shown, even if zero (no silent success)
194
+ * - Timeouts, caps, skips, sensor failures explicitly reported
195
+ * - Zero counts are explicit: "(No gaps)" not hidden
196
+ * - Nothing unobserved is allowed to disappear
197
+ *
198
+ * Observational, not judgmental. Reports facts: what was observed, gaps, unknowns.
199
+ *
200
+ * @param {Object} observationSummary - Observation summary object
201
+ * @returns {string} Formatted observation report
202
+ */
203
+ export function formatObservationSummary(observationSummary) {
204
+ const lines = [];
205
+
206
+ lines.push('\n═══════════════════════════════════════');
207
+ lines.push('OBSERVATION REPORT');
208
+ lines.push('═══════════════════════════════════════');
209
+
210
+ // Tool status (factual only)
211
+ lines.push('');
212
+ lines.push(`Tool Status: ${observationSummary.toolStatus || 'completed'}`);
213
+ lines.push(`(Indicates tool execution status, not site quality or safety)`);
214
+
215
+ // What was observed
216
+ lines.push('');
217
+ lines.push('DISCREPANCIES OBSERVED:');
218
+ const obs = observationSummary.observations || {};
219
+ lines.push(` Count: ${obs.discrepanciesObserved || 0}`);
220
+
221
+ if (obs.discrepanciesObserved > 0) {
222
+ lines.push(' Types observed:');
223
+ for (const [type, count] of Object.entries(obs.discrepanciesByType || {})) {
224
+ lines.push(` - ${type}: ${count}`);
225
+ }
226
+
227
+ // PHASE 2: Show outcomes
228
+ if (obs.discrepanciesByOutcome && Object.keys(obs.discrepanciesByOutcome).length > 0) {
229
+ lines.push(' By outcome classification:');
230
+ for (const [outcome, count] of Object.entries(obs.discrepanciesByOutcome)) {
231
+ if (count > 0) {
232
+ lines.push(` - ${outcome}: ${count}`);
233
+ }
234
+ }
235
+ }
236
+
237
+ // PHASE 3: Show promises
238
+ if (obs.discrepanciesByPromise && Object.keys(obs.discrepanciesByPromise).length > 0) {
239
+ lines.push(' By promise type:');
240
+ for (const [promise, count] of Object.entries(obs.discrepanciesByPromise)) {
241
+ if (count > 0) {
242
+ const promiseLabel = promise.replace(/_PROMISE$/, '').replace(/_/g, ' ');
243
+ lines.push(` - ${promiseLabel}: ${count}`);
244
+ }
245
+ }
246
+ }
247
+
248
+ lines.push(' By confidence level:');
249
+ const conf = obs.discrepanciesByConfidence || {};
250
+ if (conf.HIGH > 0) lines.push(` - HIGH: ${conf.HIGH}`);
251
+ if (conf.MEDIUM > 0) lines.push(` - MEDIUM: ${conf.MEDIUM}`);
252
+ if (conf.LOW > 0) lines.push(` - LOW: ${conf.LOW}`);
253
+ if (conf.UNKNOWN > 0) lines.push(` - UNKNOWN: ${conf.UNKNOWN}`);
254
+ } else {
255
+ lines.push(' No discrepancies observed between code promises and runtime behavior');
256
+ }
257
+
258
+ // Coverage facts
259
+ lines.push('');
260
+ lines.push('WHAT WAS EVALUATED:');
261
+ const cov = observationSummary.coverage || {};
262
+ lines.push(` Pages: ${cov.pagesEvaluated || 0} of ${cov.pagesDiscovered || 0} discovered${cov.pageRatio !== undefined ? ` (${(cov.pageRatio * 100).toFixed(1)}% evaluated)` : ''}`);
263
+ lines.push(` Interactions: ${cov.interactionsEvaluated || 0} of ${cov.interactionsDiscovered || 0} discovered${cov.interactionRatio !== undefined ? ` (${(cov.interactionRatio * 100).toFixed(1)}% evaluated)` : ''}`);
264
+ lines.push(` Expectations: ${cov.expectationsEvaluated || 0} of ${cov.expectationsDiscovered || 0} discovered${cov.expectationRatio !== undefined ? ` (${(cov.expectationRatio * 100).toFixed(1)}% evaluated)` : ''}`);
265
+
266
+ // Gaps explicitly reported (always shown, even if zero)
267
+ const gaps = observationSummary.gaps || {};
268
+ lines.push('');
269
+ lines.push('EVALUATION GAPS (NOT evaluated - observations incomplete for these items):');
270
+ lines.push(` Pages: ${gaps.pages || 0} not evaluated`);
271
+ lines.push(` Interactions: ${gaps.interactions || 0} not evaluated`);
272
+ lines.push(` Expectations: ${gaps.expectations || 0} not evaluated`);
273
+ if (gaps.unprovenResults > 0) {
274
+ lines.push(` Interactions without PROVEN expectations: ${gaps.unprovenResults}`);
275
+ }
276
+
277
+ if (gaps.details && gaps.details.length > 0) {
278
+ lines.push(' Gap reasons:');
279
+ for (const gap of gaps.details.slice(0, 5)) {
280
+ lines.push(` - ${gap.message}`);
281
+ }
282
+ } else if (gaps.total === 0) {
283
+ lines.push(' (No gaps reported - all discovered items were evaluated)');
284
+ }
285
+
286
+ // SILENCE TRACKING: Explicitly show all silences (timeouts, skips, sensor failures, caps)
287
+ // PHASE 4: Include lifecycle information (type, status, promise association, confidence impact)
288
+ const silences = observationSummary.silences;
289
+ if (silences && silences.totalSilences > 0) {
290
+ lines.push('');
291
+ lines.push('UNKNOWNS (Silences - things attempted but outcome unknown):');
292
+ lines.push(` Total silence events: ${silences.totalSilences}`);
293
+
294
+ // PHASE 2: Show outcomes in silence
295
+ if (silences.summary && silences.summary.byOutcome && Object.keys(silences.summary.byOutcome).length > 0) {
296
+ lines.push(' By outcome classification:');
297
+ for (const [outcome, count] of Object.entries(silences.summary.byOutcome)) {
298
+ if (count > 0) {
299
+ lines.push(` - ${outcome}: ${count}`);
300
+ }
301
+ }
302
+ }
303
+
304
+ // PHASE 4: Show silence lifecycle metrics
305
+ if (silences.summary && silences.summary.byType && Object.keys(silences.summary.byType).length > 0) {
306
+ lines.push(' By silence type:');
307
+ const types = Object.entries(silences.summary.byType)
308
+ .filter(([_, count]) => count > 0)
309
+ .sort((a, b) => b[1] - a[1])
310
+ .slice(0, 5);
311
+ for (const [type, count] of types) {
312
+ const typeLabel = type.replace(/_/g, ' ').toLowerCase();
313
+ lines.push(` - ${typeLabel}: ${count}`);
314
+ }
315
+ }
316
+
317
+ // PHASE 4: Show evaluation status distribution
318
+ if (silences.summary && silences.summary.byEvaluationStatus && Object.keys(silences.summary.byEvaluationStatus).length > 0) {
319
+ lines.push(' By evaluation status:');
320
+ for (const [status, count] of Object.entries(silences.summary.byEvaluationStatus)) {
321
+ if (count > 0) {
322
+ lines.push(` - ${status}: ${count}`);
323
+ }
324
+ }
325
+ }
326
+
327
+ // PHASE 4: Show promise association count
328
+ if (silences.summary && silences.summary.withPromiseAssociation) {
329
+ lines.push(` Silences with promise association: ${silences.summary.withPromiseAssociation}`);
330
+ }
331
+
332
+ // PHASE 4: Show confidence impact
333
+ if (silences.summary && silences.summary.confidenceImpact) {
334
+ const impact = silences.summary.confidenceImpact;
335
+ if (impact.coverage !== 0 || impact.promise_verification !== 0 || impact.overall !== 0) {
336
+ lines.push(' Confidence impact:');
337
+ if (impact.coverage !== 0) lines.push(` - Coverage confidence: ${impact.coverage > 0 ? '+' : ''}${impact.coverage}%`);
338
+ if (impact.promise_verification !== 0) lines.push(` - Promise verification confidence: ${impact.promise_verification > 0 ? '+' : ''}${impact.promise_verification}%`);
339
+ if (impact.overall !== 0) lines.push(` - Overall confidence: ${impact.overall > 0 ? '+' : ''}${impact.overall}%`);
340
+ }
341
+ }
342
+
343
+
344
+ if (silences.byCategory) {
345
+ lines.push(' By category:');
346
+ for (const [category, count] of Object.entries(silences.byCategory)) {
347
+ if (count > 0) {
348
+ lines.push(` - ${category}: ${count}`);
349
+ }
350
+ }
351
+ }
352
+
353
+ if (silences.byReason) {
354
+ lines.push(' By reason:');
355
+ const sortedReasons = Object.entries(silences.byReason)
356
+ .filter(([_, count]) => count > 0)
357
+ .sort((a, b) => b[1] - a[1])
358
+ .slice(0, 5);
359
+ for (const [reason, count] of sortedReasons) {
360
+ lines.push(` - ${reason.replace(/_/g, ' ')}: ${count}`);
361
+ }
362
+ }
363
+ } else {
364
+ lines.push('');
365
+ lines.push('UNKNOWNS (Silences):');
366
+ lines.push(' No silence events recorded (all attempted actions completed)');
367
+ }
368
+
369
+ // PHASE 4: Show silence impact summary
370
+ const impactSummary = observationSummary.silenceImpactSummary;
371
+ if (impactSummary && impactSummary.total_silences > 0) {
372
+ lines.push('');
373
+ lines.push('SILENCE IMPACT ON CONFIDENCE:');
374
+ const impact = impactSummary.aggregated_impact;
375
+ if (impact) {
376
+ lines.push(` Aggregated impact: ${impactSummary.confidence_interpretation}`);
377
+ lines.push(` Coverage confidence: ${impact.coverage > 0 ? '+' : ''}${impact.coverage}%`);
378
+ lines.push(` Promise verification confidence: ${impact.promise_verification > 0 ? '+' : ''}${impact.promise_verification}%`);
379
+ lines.push(` Overall observation confidence: ${impact.overall > 0 ? '+' : ''}${impact.overall}%`);
380
+ }
381
+
382
+ if (impactSummary.by_severity && Object.values(impactSummary.by_severity).some(v => v > 0)) {
383
+ lines.push(' Silences by severity:');
384
+ const sev = impactSummary.by_severity;
385
+ if (sev.critical > 0) lines.push(` - CRITICAL: ${sev.critical} events`);
386
+ if (sev.high > 0) lines.push(` - HIGH: ${sev.high} events`);
387
+ if (sev.medium > 0) lines.push(` - MEDIUM: ${sev.medium} events`);
388
+ if (sev.low > 0) lines.push(` - LOW: ${sev.low} events`);
389
+ }
390
+
391
+ if (impactSummary.most_impactful_types && impactSummary.most_impactful_types.length > 0) {
392
+ lines.push(' Most impactful silence types:');
393
+ for (const impactType of impactSummary.most_impactful_types.slice(0, 3)) {
394
+ lines.push(` - ${impactType.type.replace(/_/g, ' ')}: ${impactType.count} events, avg impact ${impactType.average_impact}%`);
395
+ }
396
+ }
397
+ }
398
+
399
+ if (obs.discrepanciesObserved > 0 && obs.findings && obs.findings.length > 0) {
400
+ lines.push('');
401
+ lines.push('DISCREPANCIES OBSERVED (sample):');
402
+ for (const finding of obs.findings.slice(0, 3)) {
403
+ const outcome = finding.outcome ? ` [${finding.outcome}]` : '';
404
+ const promiseInfo = finding.promise ? ` (${finding.promise.type.replace(/_PROMISE$/, '')})` : '';
405
+ const confStr = finding.confidence?.level ? ` (${finding.confidence.level} confidence)` : '';
406
+ const userStmt = finding.what_happened ? `User: ${finding.what_happened}` : '';
407
+ lines.push(` • ${finding.type}${outcome}${promiseInfo}`);
408
+ if (userStmt) lines.push(` ${userStmt}`);
409
+ if (finding.what_was_expected) lines.push(` Expected: ${finding.what_was_expected}`);
410
+ if (finding.what_was_observed) lines.push(` Observed: ${finding.what_was_observed}`);
411
+ if (finding.promise?.expected_signal) lines.push(` Promise signal: ${finding.promise.expected_signal}`);
412
+ }
413
+ }
414
+
415
+ lines.push('');
416
+ lines.push('═══════════════════════════════════════');
417
+ lines.push('');
418
+
419
+ return lines.join('\n');
420
+ }
421
+
422
+
423
+ /**
424
+ * Build evidence index from traces array.
425
+ * Maps expectations and interactions to evidence (screenshots, trace files).
426
+ */
427
+ /**
428
+ * Build evidence index from traces array.
429
+ *
430
+ * PHASE 3: EVIDENCE INTEGRITY
431
+ * - Validates that evidence files actually exist
432
+ * - Missing evidence files are tracked as silence
433
+ * - Only includes verifiable evidence in index
434
+ *
435
+ * Maps expectations and interactions to evidence (screenshots, trace files).
436
+ */
437
+ // buildEvidenceIndex moved to evidence-index.js - imported above
438
+
439
+ /**
440
+ * PHASE 4: Associate silences with promises where applicable
441
+ *
442
+ * RULE: A silence can only be associated with a promise if we can infer what promise
443
+ * the user was attempting to verify when the silence occurred.
444
+ *
445
+ * Conservative approach:
446
+ * - Navigation timeouts → NAVIGATION_PROMISE
447
+ * - Interaction timeouts → infer from interaction type
448
+ * - Safety blocks → related promise
449
+ * - Budget/discovery failures → no promise (unevaluated)
450
+ *
451
+ * @param {Object} silence - SilenceEntry with silence_type, scope, context
452
+ * @returns {Object|null} Promise object with type/expected_signal, or null if cannot infer
453
+ */
454
+ export function inferPromiseForSilence(silence) {
455
+ if (!silence) return null;
456
+
457
+ const { silence_type, scope, reason, context } = silence;
458
+
459
+ // Navigation-related silences
460
+ if (silence_type === SILENCE_TYPES.NAVIGATION_TIMEOUT ||
461
+ silence_type === SILENCE_TYPES.PROMISE_VERIFICATION_BLOCKED ||
462
+ (reason && reason.includes('navigation'))) {
463
+ return {
464
+ type: 'NAVIGATION_PROMISE',
465
+ expected_signal: 'URL change or navigation settled',
466
+ reason_no_association: null
467
+ };
468
+ }
469
+
470
+ // Interaction-related silences
471
+ if (silence_type === SILENCE_TYPES.INTERACTION_TIMEOUT) {
472
+ // Try to infer from context if available
473
+ if (context && context.interaction) {
474
+ return inferPromiseFromInteraction(context.interaction);
475
+ }
476
+ return {
477
+ type: 'FEEDBACK_PROMISE',
478
+ expected_signal: 'User feedback or interaction acknowledgment',
479
+ reason_no_association: 'Interaction type unknown in context'
480
+ };
481
+ }
482
+
483
+ // Safety blocks - the promise being blocked
484
+ if (silence_type === SILENCE_TYPES.SAFETY_POLICY_BLOCK) {
485
+ if (context && context.interaction) {
486
+ const inferred = inferPromiseFromInteraction(context.interaction);
487
+ return {
488
+ ...inferred,
489
+ blocked_by_safety: true
490
+ };
491
+ }
492
+ return null; // Cannot infer without interaction context
493
+ }
494
+
495
+ // Discovery/sensor failures - no promise can be evaluated
496
+ if (silence_type === SILENCE_TYPES.DISCOVERY_FAILURE ||
497
+ silence_type === SILENCE_TYPES.SENSOR_FAILURE) {
498
+ return {
499
+ type: null,
500
+ reason_no_association: 'Observation infrastructure failure - no promise evaluatable'
501
+ };
502
+ }
503
+
504
+ // Budget/incremental/ambiguous - no promise
505
+ if (silence_type === SILENCE_TYPES.BUDGET_LIMIT_EXCEEDED ||
506
+ silence_type === SILENCE_TYPES.INCREMENTAL_REUSE ||
507
+ silence_type === SILENCE_TYPES.PROMISE_NOT_EVALUATED) {
508
+ return {
509
+ type: null,
510
+ reason_no_association: 'Promise not yet evaluated'
511
+ };
512
+ }
513
+
514
+ // Conservative default: no association
515
+ return null;
516
+ }
517
+
518
+ /**
519
+ * Validate that a silence event makes forensic sense
520
+ * RULE: Silence can NEVER appear as success. It is always a gap in observation.
521
+ *
522
+ * @param {Object} silence - SilenceEntry
523
+ * @returns {Object} Validation result: { valid: boolean, reason: string }
524
+ */
525
+ export function validateSilenceIntegrity(silence) {
526
+ if (!silence) {
527
+ return { valid: false, reason: 'Silence entry is null/undefined' };
528
+ }
529
+
530
+ // Silence can NEVER have outcome === INFORMATIONAL or any "success" outcome
531
+ const prohibitedOutcomes = ['SUCCESS', 'PASS', 'VERIFIED', 'CONFIRMED'];
532
+ if (prohibitedOutcomes.includes(silence.outcome?.toUpperCase())) {
533
+ return {
534
+ valid: false,
535
+ reason: `Silence cannot have outcome "${silence.outcome}" - silence is always a gap`
536
+ };
537
+ }
538
+
539
+ // Silence must have a valid scope
540
+ const validScopes = ['page', 'interaction', 'expectation', 'sensor', 'navigation', 'settle'];
541
+ if (!silence.scope || !validScopes.includes(silence.scope)) {
542
+ return { valid: false, reason: `Invalid scope: "${silence.scope}"` };
543
+ }
544
+
545
+ // Must have evaluation_status (Phase 4)
546
+ const validStatuses = Object.values(EVALUATION_STATUS);
547
+ if (!silence.evaluation_status || !validStatuses.includes(silence.evaluation_status)) {
548
+ return {
549
+ valid: false,
550
+ reason: `Invalid evaluation_status: "${silence.evaluation_status}". Must be one of: ${validStatuses.join(', ')}`
551
+ };
552
+ }
553
+
554
+ return { valid: true, reason: null };
555
+ }
556
+
557
+ function clampRatio(ratio) {
558
+ const clamped = Math.max(0, Math.min(1, ratio));
559
+ return Math.round(clamped * 10000) / 10000; // 4 decimal places
560
+ }
561
+
562
+ // writeEvidenceIndex moved to evidence-index.js - re-exported below
563
+ export { buildEvidenceIndex, writeEvidenceIndex } from './evidence-index.js';
@@ -0,0 +1,61 @@
1
+ import { resolve, dirname } from 'path';
2
+ import { mkdirSync, writeFileSync } from 'fs';
3
+
4
+ function resolveScreenshotPath(screenshotsDir, relativePath) {
5
+ if (!relativePath || !screenshotsDir) return null;
6
+ const observeDir = dirname(screenshotsDir);
7
+ return resolve(observeDir, relativePath);
8
+ }
9
+
10
+ function findEvidenceForFinding(finding, evidenceIndex) {
11
+ if (!Array.isArray(evidenceIndex)) return null;
12
+ if (finding.expectationId) {
13
+ const byExpectation = evidenceIndex.find(e => e.expectationId === finding.expectationId);
14
+ if (byExpectation) return byExpectation;
15
+ }
16
+ const selector = finding.interaction?.selector;
17
+ if (selector) {
18
+ const bySelector = evidenceIndex.find(e => e.interaction?.selector === selector);
19
+ if (bySelector) return bySelector;
20
+ }
21
+ return evidenceIndex[0] || null;
22
+ }
23
+
24
+ function buildEvidenceEntries(findings, evidenceIndex, tracesPath, screenshotsDir) {
25
+ const seen = new Set();
26
+ const entries = [];
27
+ (findings || []).forEach((finding, idx) => {
28
+ const evidence = findEvidenceForFinding(finding, evidenceIndex);
29
+ const findingId = finding.findingId || finding.id || `finding-${idx}`;
30
+ if (seen.has(findingId)) return;
31
+ seen.add(findingId);
32
+ entries.push({
33
+ findingId,
34
+ findingType: finding.type || 'finding',
35
+ expectationId: finding.expectationId || null,
36
+ interactionSelector: finding.interaction?.selector || null,
37
+ evidenceId: evidence?.id || null,
38
+ paths: {
39
+ beforeScreenshot: resolveScreenshotPath(screenshotsDir, evidence?.evidence?.beforeScreenshot || null),
40
+ afterScreenshot: resolveScreenshotPath(screenshotsDir, evidence?.evidence?.afterScreenshot || null),
41
+ traceFile: tracesPath || null,
42
+ networkTrace: null
43
+ }
44
+ });
45
+ });
46
+ return entries;
47
+ }
48
+
49
+ export function writeEvidenceIndex(projectDir, findings, verdict, tracesPath, screenshotsDir) {
50
+ const artifactsDir = resolve(projectDir, 'artifacts');
51
+ mkdirSync(artifactsDir, { recursive: true });
52
+ const items = buildEvidenceEntries(findings, verdict?.evidenceIndex || [], tracesPath, screenshotsDir);
53
+ const evidenceIndexPath = resolve(artifactsDir, 'evidence-index.json');
54
+ const payload = {
55
+ version: 1,
56
+ tracesPath,
57
+ items
58
+ };
59
+ writeFileSync(evidenceIndexPath, JSON.stringify(payload, null, 2) + '\n');
60
+ return { evidenceIndexPath, items };
61
+ }