@veraxhq/verax 0.1.0 → 0.2.1

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 (135) hide show
  1. package/README.md +123 -88
  2. package/bin/verax.js +11 -452
  3. package/package.json +24 -36
  4. package/src/cli/commands/default.js +681 -0
  5. package/src/cli/commands/doctor.js +197 -0
  6. package/src/cli/commands/inspect.js +109 -0
  7. package/src/cli/commands/run.js +586 -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 +297 -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 +110 -0
  14. package/src/cli/util/expectation-extractor.js +388 -0
  15. package/src/cli/util/findings-writer.js +32 -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 +412 -0
  19. package/src/cli/util/observe-writer.js +25 -0
  20. package/src/cli/util/paths.js +30 -0
  21. package/src/cli/util/project-discovery.js +297 -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/runtime-budget.js +147 -0
  26. package/src/cli/util/summary-writer.js +43 -0
  27. package/src/types/global.d.ts +28 -0
  28. package/src/types/ts-ast.d.ts +24 -0
  29. package/src/verax/cli/ci-summary.js +35 -0
  30. package/src/verax/cli/context-explanation.js +89 -0
  31. package/src/verax/cli/doctor.js +277 -0
  32. package/src/verax/cli/error-normalizer.js +154 -0
  33. package/src/verax/cli/explain-output.js +105 -0
  34. package/src/verax/cli/finding-explainer.js +130 -0
  35. package/src/verax/cli/init.js +237 -0
  36. package/src/verax/cli/run-overview.js +163 -0
  37. package/src/verax/cli/url-safety.js +111 -0
  38. package/src/verax/cli/wizard.js +109 -0
  39. package/src/verax/cli/zero-findings-explainer.js +57 -0
  40. package/src/verax/cli/zero-interaction-explainer.js +127 -0
  41. package/src/verax/core/action-classifier.js +86 -0
  42. package/src/verax/core/budget-engine.js +218 -0
  43. package/src/verax/core/canonical-outcomes.js +157 -0
  44. package/src/verax/core/decision-snapshot.js +335 -0
  45. package/src/verax/core/determinism-model.js +432 -0
  46. package/src/verax/core/incremental-store.js +245 -0
  47. package/src/verax/core/invariants.js +356 -0
  48. package/src/verax/core/promise-model.js +230 -0
  49. package/src/verax/core/replay-validator.js +350 -0
  50. package/src/verax/core/replay.js +222 -0
  51. package/src/verax/core/run-id.js +175 -0
  52. package/src/verax/core/run-manifest.js +99 -0
  53. package/src/verax/core/silence-impact.js +369 -0
  54. package/src/verax/core/silence-model.js +523 -0
  55. package/src/verax/detect/comparison.js +7 -34
  56. package/src/verax/detect/confidence-engine.js +764 -329
  57. package/src/verax/detect/detection-engine.js +293 -0
  58. package/src/verax/detect/evidence-index.js +127 -0
  59. package/src/verax/detect/expectation-model.js +241 -168
  60. package/src/verax/detect/explanation-helpers.js +187 -0
  61. package/src/verax/detect/finding-detector.js +450 -0
  62. package/src/verax/detect/findings-writer.js +41 -12
  63. package/src/verax/detect/flow-detector.js +366 -0
  64. package/src/verax/detect/index.js +200 -288
  65. package/src/verax/detect/interactive-findings.js +612 -0
  66. package/src/verax/detect/signal-mapper.js +308 -0
  67. package/src/verax/detect/skip-classifier.js +4 -4
  68. package/src/verax/detect/verdict-engine.js +561 -0
  69. package/src/verax/evidence-index-writer.js +61 -0
  70. package/src/verax/flow/flow-engine.js +3 -2
  71. package/src/verax/flow/flow-spec.js +1 -2
  72. package/src/verax/index.js +103 -15
  73. package/src/verax/intel/effect-detector.js +368 -0
  74. package/src/verax/intel/handler-mapper.js +249 -0
  75. package/src/verax/intel/index.js +281 -0
  76. package/src/verax/intel/route-extractor.js +280 -0
  77. package/src/verax/intel/ts-program.js +256 -0
  78. package/src/verax/intel/vue-navigation-extractor.js +642 -0
  79. package/src/verax/intel/vue-router-extractor.js +325 -0
  80. package/src/verax/learn/action-contract-extractor.js +338 -104
  81. package/src/verax/learn/ast-contract-extractor.js +148 -6
  82. package/src/verax/learn/flow-extractor.js +172 -0
  83. package/src/verax/learn/index.js +36 -2
  84. package/src/verax/learn/manifest-writer.js +122 -58
  85. package/src/verax/learn/project-detector.js +40 -0
  86. package/src/verax/learn/route-extractor.js +28 -97
  87. package/src/verax/learn/route-validator.js +8 -7
  88. package/src/verax/learn/state-extractor.js +212 -0
  89. package/src/verax/learn/static-extractor-navigation.js +114 -0
  90. package/src/verax/learn/static-extractor-validation.js +88 -0
  91. package/src/verax/learn/static-extractor.js +119 -10
  92. package/src/verax/learn/truth-assessor.js +24 -21
  93. package/src/verax/learn/ts-contract-resolver.js +14 -12
  94. package/src/verax/observe/aria-sensor.js +211 -0
  95. package/src/verax/observe/browser.js +30 -6
  96. package/src/verax/observe/console-sensor.js +2 -18
  97. package/src/verax/observe/domain-boundary.js +10 -1
  98. package/src/verax/observe/expectation-executor.js +513 -0
  99. package/src/verax/observe/flow-matcher.js +143 -0
  100. package/src/verax/observe/focus-sensor.js +196 -0
  101. package/src/verax/observe/human-driver.js +660 -273
  102. package/src/verax/observe/index.js +910 -26
  103. package/src/verax/observe/interaction-discovery.js +378 -15
  104. package/src/verax/observe/interaction-runner.js +562 -197
  105. package/src/verax/observe/loading-sensor.js +145 -0
  106. package/src/verax/observe/navigation-sensor.js +255 -0
  107. package/src/verax/observe/network-sensor.js +55 -7
  108. package/src/verax/observe/observed-expectation-deriver.js +186 -0
  109. package/src/verax/observe/observed-expectation.js +305 -0
  110. package/src/verax/observe/page-frontier.js +234 -0
  111. package/src/verax/observe/settle.js +38 -17
  112. package/src/verax/observe/state-sensor.js +393 -0
  113. package/src/verax/observe/state-ui-sensor.js +7 -1
  114. package/src/verax/observe/timing-sensor.js +228 -0
  115. package/src/verax/observe/traces-writer.js +73 -21
  116. package/src/verax/observe/ui-signal-sensor.js +143 -17
  117. package/src/verax/scan-summary-writer.js +80 -15
  118. package/src/verax/shared/artifact-manager.js +111 -9
  119. package/src/verax/shared/budget-profiles.js +136 -0
  120. package/src/verax/shared/caching.js +1 -1
  121. package/src/verax/shared/ci-detection.js +39 -0
  122. package/src/verax/shared/config-loader.js +169 -0
  123. package/src/verax/shared/dynamic-route-utils.js +224 -0
  124. package/src/verax/shared/expectation-coverage.js +44 -0
  125. package/src/verax/shared/expectation-prover.js +81 -0
  126. package/src/verax/shared/expectation-tracker.js +201 -0
  127. package/src/verax/shared/expectations-writer.js +60 -0
  128. package/src/verax/shared/first-run.js +44 -0
  129. package/src/verax/shared/progress-reporter.js +171 -0
  130. package/src/verax/shared/retry-policy.js +9 -1
  131. package/src/verax/shared/root-artifacts.js +49 -0
  132. package/src/verax/shared/scan-budget.js +86 -0
  133. package/src/verax/shared/url-normalizer.js +162 -0
  134. package/src/verax/shared/zip-artifacts.js +66 -0
  135. package/src/verax/validate/context-validator.js +244 -0
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Event emitter for run progress
3
+ */
4
+ export class RunEventEmitter {
5
+ constructor() {
6
+ this.events = [];
7
+ this.listeners = [];
8
+ this.heartbeatInterval = null;
9
+ this.heartbeatStartTime = null;
10
+ this.currentPhase = null;
11
+ this.heartbeatIntervalMs = 2500; // 2.5 seconds
12
+ }
13
+
14
+ on(event, handler) {
15
+ this.listeners.push({ event, handler });
16
+ }
17
+
18
+ emit(type, data = {}) {
19
+ const event = {
20
+ type,
21
+ timestamp: new Date().toISOString(),
22
+ ...data,
23
+ };
24
+
25
+ this.events.push(event);
26
+
27
+ // Call registered listeners
28
+ this.listeners.forEach(({ event: listenEvent, handler }) => {
29
+ if (listenEvent === type || listenEvent === '*') {
30
+ handler(event);
31
+ }
32
+ });
33
+ }
34
+
35
+ getEvents() {
36
+ return this.events;
37
+ }
38
+
39
+ /**
40
+ * Start heartbeat for a phase
41
+ * @param {string} phase - Current phase name
42
+ * @param {boolean} jsonMode - Whether in JSON output mode
43
+ */
44
+ startHeartbeat(phase, jsonMode = false) {
45
+ this.currentPhase = phase;
46
+ this.heartbeatStartTime = Date.now();
47
+
48
+ if (this.heartbeatInterval) {
49
+ clearInterval(this.heartbeatInterval);
50
+ }
51
+
52
+ this.heartbeatInterval = setInterval(() => {
53
+ const elapsedMs = Date.now() - this.heartbeatStartTime;
54
+ const elapsedSeconds = Math.floor(elapsedMs / 1000);
55
+
56
+ const heartbeatEvent = {
57
+ type: 'heartbeat',
58
+ phase: this.currentPhase,
59
+ elapsedMs,
60
+ elapsedSeconds,
61
+ timestamp: new Date().toISOString(),
62
+ };
63
+
64
+ // Add to events array
65
+ this.events.push(heartbeatEvent);
66
+
67
+ // Emit to listeners
68
+ this.listeners.forEach(({ event: listenEvent, handler }) => {
69
+ if (listenEvent === 'heartbeat' || listenEvent === '*') {
70
+ if (jsonMode) {
71
+ // In JSON mode, emit as JSON line
72
+ handler(heartbeatEvent);
73
+ } else {
74
+ // Human-readable format: single line, overwrite-friendly
75
+ process.stdout.write(`\r…still working (phase=${this.currentPhase}, elapsed=${elapsedSeconds}s)`);
76
+ }
77
+ }
78
+ });
79
+ }, this.heartbeatIntervalMs);
80
+
81
+ // CRITICAL: Unref the interval so it doesn't keep the process alive
82
+ // This allows tests to exit cleanly even if stopHeartbeat() is not called
83
+ if (this.heartbeatInterval && this.heartbeatInterval.unref) {
84
+ this.heartbeatInterval.unref();
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Stop heartbeat
90
+ */
91
+ stopHeartbeat() {
92
+ if (this.heartbeatInterval) {
93
+ clearInterval(this.heartbeatInterval);
94
+ this.heartbeatInterval = null;
95
+ }
96
+ // Clear the progress line in human mode
97
+ process.stdout.write('\r' + ' '.repeat(60) + '\r');
98
+ this.currentPhase = null;
99
+ this.heartbeatStartTime = null;
100
+ }
101
+
102
+ /**
103
+ * Update current phase for heartbeat
104
+ * @param {string} phase - New phase name
105
+ */
106
+ updatePhase(phase) {
107
+ this.currentPhase = phase;
108
+ this.heartbeatStartTime = Date.now();
109
+ }
110
+ }
@@ -0,0 +1,388 @@
1
+ import { readdirSync, readFileSync, statSync } from 'fs';
2
+ import { join, relative, resolve } from 'path';
3
+ import { expIdFromHash, compareExpectations } from './idgen.js';
4
+
5
+ /**
6
+ * Static Expectation Extractor
7
+ * Extracts explicit, static expectations from source files
8
+ */
9
+
10
+ export async function extractExpectations(projectProfile, _srcPath) {
11
+ const expectations = [];
12
+ const skipped = {
13
+ dynamic: 0,
14
+ computed: 0,
15
+ external: 0,
16
+ parseError: 0,
17
+ other: 0,
18
+ };
19
+
20
+ const sourceRoot = resolve(projectProfile.sourceRoot);
21
+ const scanPaths = getScanPaths(projectProfile, sourceRoot);
22
+
23
+ for (const scanPath of scanPaths) {
24
+ const fileExpectations = await scanDirectory(scanPath, sourceRoot, skipped);
25
+ expectations.push(...fileExpectations);
26
+ }
27
+
28
+ // Sort expectations deterministically by file, line, column, kind, value
29
+ expectations.sort(compareExpectations);
30
+
31
+ // Generate deterministic IDs based on content (order-independent)
32
+ expectations.forEach((exp) => {
33
+ exp.id = expIdFromHash(
34
+ exp.source.file,
35
+ exp.source.line,
36
+ exp.source.column,
37
+ exp.promise.kind,
38
+ exp.promise.value
39
+ );
40
+ });
41
+
42
+ return {
43
+ expectations,
44
+ skipped,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Get directories to scan based on framework
50
+ */
51
+ function getScanPaths(projectProfile, sourceRoot) {
52
+ const { framework, router } = projectProfile;
53
+
54
+ if (framework === 'nextjs') {
55
+ if (router === 'app') {
56
+ return [resolve(sourceRoot, 'app')];
57
+ } else if (router === 'pages') {
58
+ return [resolve(sourceRoot, 'pages')];
59
+ }
60
+ return [];
61
+ }
62
+
63
+ if (framework === 'react-vite' || framework === 'react-cra') {
64
+ return [resolve(sourceRoot, 'src')];
65
+ }
66
+
67
+ if (framework === 'static-html') {
68
+ return [sourceRoot];
69
+ }
70
+
71
+ // Unknown framework or no framework detected - check if it's a static HTML project
72
+ // (This handles cases where framework detection failed but HTML files exist)
73
+ if (framework === 'unknown') {
74
+ const htmlFiles = readdirSync(sourceRoot, { withFileTypes: true })
75
+ .filter(e => e.isFile() && e.name.endsWith('.html'));
76
+ if (htmlFiles.length > 0) {
77
+ return [sourceRoot];
78
+ }
79
+ }
80
+
81
+ // Unknown framework - scan src if it exists
82
+ const srcPath = resolve(sourceRoot, 'src');
83
+ try {
84
+ if (statSync(srcPath).isDirectory()) {
85
+ return [srcPath];
86
+ }
87
+ } catch (error) {
88
+ // src doesn't exist
89
+ }
90
+
91
+ return [];
92
+ }
93
+
94
+ /**
95
+ * Recursively scan directory for expectations
96
+ */
97
+ async function scanDirectory(dirPath, sourceRoot, skipped) {
98
+ const expectations = [];
99
+
100
+ try {
101
+ const entries = readdirSync(dirPath, { withFileTypes: true });
102
+
103
+ for (const entry of entries) {
104
+ // Skip node_modules, .next, dist, build, etc.
105
+ if (shouldSkipDirectory(entry.name)) {
106
+ continue;
107
+ }
108
+
109
+ const fullPath = join(dirPath, entry.name);
110
+
111
+ if (entry.isDirectory()) {
112
+ const dirExpectations = await scanDirectory(fullPath, sourceRoot, skipped);
113
+ expectations.push(...dirExpectations);
114
+ } else if (entry.isFile() && shouldScanFile(entry.name)) {
115
+ const fileExpectations = scanFile(fullPath, sourceRoot, skipped);
116
+ expectations.push(...fileExpectations);
117
+ }
118
+ }
119
+ } catch (error) {
120
+ // Silently skip directories we can't read
121
+ }
122
+
123
+ return expectations;
124
+ }
125
+
126
+ /**
127
+ * Check if directory should be skipped
128
+ */
129
+ function shouldSkipDirectory(name) {
130
+ const skipPatterns = [
131
+ 'node_modules',
132
+ '.next',
133
+ 'dist',
134
+ 'build',
135
+ '.git',
136
+ '.venv',
137
+ '__pycache__',
138
+ '.env',
139
+ 'public',
140
+ '.cache',
141
+ 'coverage',
142
+ ];
143
+
144
+ return skipPatterns.includes(name) || name.startsWith('.');
145
+ }
146
+
147
+ /**
148
+ * Check if file should be scanned
149
+ */
150
+ function shouldScanFile(name) {
151
+ const extensions = ['.js', '.jsx', '.ts', '.tsx', '.html', '.mjs'];
152
+ return extensions.some(ext => name.endsWith(ext));
153
+ }
154
+
155
+ /**
156
+ * Scan a single file for expectations
157
+ */
158
+ function scanFile(filePath, sourceRoot, skipped) {
159
+ const expectations = [];
160
+
161
+ try {
162
+ const content = readFileSync(filePath, 'utf8');
163
+ const relPath = relative(sourceRoot, filePath);
164
+
165
+ if (filePath.endsWith('.html')) {
166
+ const htmlExpectations = extractHtmlExpectations(content, filePath, relPath);
167
+ expectations.push(...htmlExpectations);
168
+ } else {
169
+ const jsExpectations = extractJsExpectations(content, filePath, relPath, skipped);
170
+ expectations.push(...jsExpectations);
171
+ }
172
+ } catch (error) {
173
+ skipped.parseError++;
174
+ }
175
+
176
+ return expectations;
177
+ }
178
+
179
+ /**
180
+ * Extract expectations from HTML files
181
+ */
182
+ function extractHtmlExpectations(content, filePath, relPath) {
183
+ const expectations = [];
184
+
185
+ // Extract <a href="/path"> links
186
+ const hrefRegex = /<a\s+[^>]*href=["']([^"']+)["']/gi;
187
+ let match;
188
+
189
+ while ((match = hrefRegex.exec(content)) !== null) {
190
+ const href = match[1];
191
+ const lineNum = content.substring(0, match.index).split('\n').length;
192
+
193
+ // Skip dynamic/absolute URLs
194
+ if (!href.startsWith('#') && !href.startsWith('http') && !href.includes('${')) {
195
+ expectations.push({
196
+ type: 'navigation',
197
+ promise: {
198
+ kind: 'navigate',
199
+ value: href,
200
+ },
201
+ source: {
202
+ file: relPath,
203
+ line: lineNum,
204
+ column: match.index - content.lastIndexOf('\n', match.index),
205
+ },
206
+ confidence: 1.0,
207
+ });
208
+ }
209
+ }
210
+
211
+ return expectations;
212
+ }
213
+
214
+ /**
215
+ * Extract expectations from JavaScript/TypeScript files
216
+ */
217
+ function extractJsExpectations(content, filePath, relPath, skipped) {
218
+ const expectations = [];
219
+ const lines = content.split('\n');
220
+
221
+ lines.forEach((line, lineIdx) => {
222
+ const lineNum = lineIdx + 1;
223
+
224
+ // Skip comments
225
+ if (line.trim().startsWith('//') || line.trim().startsWith('*')) {
226
+ return;
227
+ }
228
+
229
+ // Extract Next.js <Link href="/path">
230
+ const linkRegex = /<Link\s+[^>]*href=["']([^"']+)["']/g;
231
+ let match;
232
+ while ((match = linkRegex.exec(line)) !== null) {
233
+ const href = match[1];
234
+
235
+ // Skip dynamic hrefs
236
+ if (!href.includes('${') && !href.includes('+') && !href.includes('`')) {
237
+ expectations.push({
238
+ type: 'navigation',
239
+ promise: {
240
+ kind: 'navigate',
241
+ value: href,
242
+ },
243
+ source: {
244
+ file: relPath,
245
+ line: lineNum,
246
+ column: match.index,
247
+ },
248
+ confidence: 1.0,
249
+ });
250
+ } else {
251
+ skipped.dynamic++;
252
+ }
253
+ }
254
+
255
+ // Extract router.push("/path")
256
+ const routerPushRegex = /router\.push\(["']([^"']+)["']\)/g;
257
+ while ((match = routerPushRegex.exec(line)) !== null) {
258
+ const path = match[1];
259
+
260
+ if (!path.includes('${') && !path.includes('+') && !path.includes('`')) {
261
+ expectations.push({
262
+ type: 'navigation',
263
+ promise: {
264
+ kind: 'navigate',
265
+ value: path,
266
+ },
267
+ source: {
268
+ file: relPath,
269
+ line: lineNum,
270
+ column: match.index,
271
+ },
272
+ confidence: 1.0,
273
+ });
274
+ } else {
275
+ skipped.dynamic++;
276
+ }
277
+ }
278
+
279
+ // Extract fetch("https://...")
280
+ const fetchRegex = /fetch\(["']([^"']+)["']\)/g;
281
+ while ((match = fetchRegex.exec(line)) !== null) {
282
+ const url = match[1];
283
+
284
+ // Only extract absolute URLs (https://)
285
+ if (url.startsWith('http://') || url.startsWith('https://')) {
286
+ expectations.push({
287
+ type: 'network',
288
+ promise: {
289
+ kind: 'request',
290
+ value: url,
291
+ },
292
+ source: {
293
+ file: relPath,
294
+ line: lineNum,
295
+ column: match.index,
296
+ },
297
+ confidence: 1.0,
298
+ });
299
+ } else if (!url.includes('${') && !url.includes('+') && !url.includes('`')) {
300
+ skipped.external++;
301
+ } else {
302
+ skipped.dynamic++;
303
+ }
304
+ }
305
+
306
+ // Extract axios.get/post("https://...")
307
+ const axiosRegex = /axios\.(get|post|put|delete|patch)\(["']([^"']+)["']\)/g;
308
+ while ((match = axiosRegex.exec(line)) !== null) {
309
+ const url = match[2];
310
+
311
+ if (url.startsWith('http://') || url.startsWith('https://')) {
312
+ expectations.push({
313
+ type: 'network',
314
+ promise: {
315
+ kind: 'request',
316
+ value: url,
317
+ },
318
+ source: {
319
+ file: relPath,
320
+ line: lineNum,
321
+ column: match.index,
322
+ },
323
+ confidence: 1.0,
324
+ });
325
+ } else if (!url.includes('${') && !url.includes('+') && !url.includes('`')) {
326
+ skipped.external++;
327
+ } else {
328
+ skipped.dynamic++;
329
+ }
330
+ }
331
+
332
+ // Extract useState setters
333
+ const useStateRegex = /useState\([^)]*\)/g;
334
+ if ((match = useStateRegex.exec(line)) !== null) {
335
+ expectations.push({
336
+ type: 'state',
337
+ promise: {
338
+ kind: 'state_mutation',
339
+ value: 'state management',
340
+ },
341
+ source: {
342
+ file: relPath,
343
+ line: lineNum,
344
+ column: match.index,
345
+ },
346
+ confidence: 0.8,
347
+ });
348
+ }
349
+
350
+ // Extract Redux dispatch calls
351
+ const dispatchRegex = /dispatch\(\{/g;
352
+ if ((match = dispatchRegex.exec(line)) !== null) {
353
+ expectations.push({
354
+ type: 'state',
355
+ promise: {
356
+ kind: 'state_mutation',
357
+ value: 'state management',
358
+ },
359
+ source: {
360
+ file: relPath,
361
+ line: lineNum,
362
+ column: match.index,
363
+ },
364
+ confidence: 0.8,
365
+ });
366
+ }
367
+
368
+ // Extract Zustand set() calls
369
+ const zustandRegex = /set\(\{/g;
370
+ if ((match = zustandRegex.exec(line)) !== null && line.includes('zustand') || line.includes('store')) {
371
+ expectations.push({
372
+ type: 'state',
373
+ promise: {
374
+ kind: 'state_mutation',
375
+ value: 'state management',
376
+ },
377
+ source: {
378
+ file: relPath,
379
+ line: lineNum,
380
+ column: match.index,
381
+ },
382
+ confidence: 0.8,
383
+ });
384
+ }
385
+ });
386
+
387
+ return expectations;
388
+ }
@@ -0,0 +1,32 @@
1
+ import { atomicWriteJson } from './atomic-write.js';
2
+ import { resolve } from 'path';
3
+ import { findingIdFromExpectationId } from './idgen.js';
4
+
5
+ /**
6
+ * Write findings.json artifact with deterministic IDs
7
+ */
8
+ export function writeFindingsJson(runDir, findingsData) {
9
+ const findingsPath = resolve(runDir, 'findings.json');
10
+
11
+ // Add deterministic finding IDs based on expectation IDs
12
+ const findingsWithIds = (findingsData.findings || []).map(finding => ({
13
+ ...finding,
14
+ findingId: findingIdFromExpectationId(finding.id),
15
+ }));
16
+
17
+ const payload = {
18
+ findings: findingsWithIds,
19
+ total: findingsData.stats?.total || 0,
20
+ stats: {
21
+ total: findingsData.stats?.total || 0,
22
+ silentFailures: findingsData.stats?.silentFailures || 0,
23
+ observed: findingsData.stats?.observed || 0,
24
+ coverageGaps: findingsData.stats?.coverageGaps || 0,
25
+ unproven: findingsData.stats?.unproven || 0,
26
+ informational: findingsData.stats?.informational || 0,
27
+ },
28
+ detectedAt: findingsData.detectedAt || new Date().toISOString(),
29
+ };
30
+
31
+ atomicWriteJson(findingsPath, payload);
32
+ }
@@ -0,0 +1,87 @@
1
+ import crypto from 'crypto';
2
+
3
+ /**
4
+ * Deterministic ID Generation (Phase 8.3)
5
+ * Generate stable, hash-based IDs independent of discovery order.
6
+ */
7
+
8
+ /**
9
+ * Generate deterministic expectation ID from stable attributes
10
+ * ID format: exp_<6-character-hex-hash>
11
+ * Input order-independent: hash only depends on content, not discovery order
12
+ *
13
+ * @param {string} file - Source file path (relative)
14
+ * @param {number} line - Line number
15
+ * @param {number} column - Column number
16
+ * @param {string} kind - Promise kind (e.g., 'navigate', 'click')
17
+ * @param {string} value - Promise value (e.g., '/products', 'button.id')
18
+ * @returns {string} Deterministic ID like 'exp_a1b2c3'
19
+ */
20
+ export function expIdFromHash(file, line, column, kind, value) {
21
+ // Create stable hash input: normalize file path and combine all attributes
22
+ const normalizedFile = (file || '')
23
+ .replace(/\\/g, '/') // Convert Windows backslashes to forward slashes
24
+ .toLowerCase();
25
+
26
+ const hashInput = JSON.stringify({
27
+ file: normalizedFile,
28
+ line: Number(line) || 0,
29
+ column: Number(column) || 0,
30
+ kind: String(kind || '').toLowerCase(),
31
+ value: String(value || ''),
32
+ });
33
+
34
+ // Generate deterministic hash
35
+ const hash = crypto.createHash('sha256').update(hashInput).digest('hex');
36
+
37
+ // Use first 6 characters for brevity while maintaining low collision risk
38
+ const hashSuffix = hash.substring(0, 6);
39
+
40
+ return `exp_${hashSuffix}`;
41
+ }
42
+
43
+ /**
44
+ * Generate deterministic finding ID from expectation ID
45
+ * ID format: finding_<expectationId>
46
+ *
47
+ * @param {string} expectationId - The parent expectation ID
48
+ * @returns {string} Finding ID like 'finding_exp_a1b2c3'
49
+ */
50
+ export function findingIdFromExpectationId(expectationId) {
51
+ return `finding_${expectationId}`;
52
+ }
53
+
54
+ /**
55
+ * Comparator for stable expectation ordering
56
+ * Sort by: file, line, column, kind, value
57
+ * Returns comparable value suitable for .sort()
58
+ */
59
+ export function compareExpectations(a, b) {
60
+ const aFile = (a.source?.file || '').toLowerCase();
61
+ const bFile = (b.source?.file || '').toLowerCase();
62
+ if (aFile !== bFile) {
63
+ return aFile.localeCompare(bFile);
64
+ }
65
+
66
+ const aLine = a.source?.line || 0;
67
+ const bLine = b.source?.line || 0;
68
+ if (aLine !== bLine) {
69
+ return aLine - bLine;
70
+ }
71
+
72
+ const aCol = a.source?.column || 0;
73
+ const bCol = b.source?.column || 0;
74
+ if (aCol !== bCol) {
75
+ return aCol - bCol;
76
+ }
77
+
78
+ const aKind = (a.promise?.kind || '').toLowerCase();
79
+ const bKind = (b.promise?.kind || '').toLowerCase();
80
+ if (aKind !== bKind) {
81
+ return aKind.localeCompare(bKind);
82
+ }
83
+
84
+ const aValue = String(a.promise?.value || '').toLowerCase();
85
+ const bValue = String(b.promise?.value || '').toLowerCase();
86
+ return aValue.localeCompare(bValue);
87
+ }
@@ -0,0 +1,39 @@
1
+ import { resolve } from 'path';
2
+ import { atomicWriteJson } from './atomic-write.js';
3
+ import { compareExpectations } from './idgen.js';
4
+
5
+ /**
6
+ * Write learn.json artifact
7
+ * Maintains deterministic ordering for stable output
8
+ */
9
+ export function writeLearnJson(runPaths, expectations, skipped) {
10
+ const learnJsonPath = resolve(runPaths.baseDir, 'learn.json');
11
+
12
+ // Sort expectations deterministically for stable output
13
+ const sortedExpectations = [...expectations].sort(compareExpectations);
14
+
15
+ const learnJson = {
16
+ expectations: sortedExpectations,
17
+ stats: {
18
+ totalExpectations: sortedExpectations.length,
19
+ byType: {
20
+ navigation: sortedExpectations.filter(e => e.type === 'navigation').length,
21
+ network: sortedExpectations.filter(e => e.type === 'network').length,
22
+ state: sortedExpectations.filter(e => e.type === 'state').length,
23
+ },
24
+ },
25
+ skipped: {
26
+ dynamic: skipped.dynamic,
27
+ computed: skipped.computed,
28
+ external: skipped.external,
29
+ parseError: skipped.parseError,
30
+ other: skipped.other,
31
+ total: Object.values(skipped).reduce((a, b) => a + b, 0),
32
+ },
33
+ learnedAt: new Date().toISOString(),
34
+ };
35
+
36
+ atomicWriteJson(learnJsonPath, learnJson);
37
+
38
+ return learnJsonPath;
39
+ }