@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,378 @@
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 - scan src if it exists
72
+ const srcPath = resolve(sourceRoot, 'src');
73
+ try {
74
+ if (statSync(srcPath).isDirectory()) {
75
+ return [srcPath];
76
+ }
77
+ } catch (error) {
78
+ // src doesn't exist
79
+ }
80
+
81
+ return [];
82
+ }
83
+
84
+ /**
85
+ * Recursively scan directory for expectations
86
+ */
87
+ async function scanDirectory(dirPath, sourceRoot, skipped) {
88
+ const expectations = [];
89
+
90
+ try {
91
+ const entries = readdirSync(dirPath, { withFileTypes: true });
92
+
93
+ for (const entry of entries) {
94
+ // Skip node_modules, .next, dist, build, etc.
95
+ if (shouldSkipDirectory(entry.name)) {
96
+ continue;
97
+ }
98
+
99
+ const fullPath = join(dirPath, entry.name);
100
+
101
+ if (entry.isDirectory()) {
102
+ const dirExpectations = await scanDirectory(fullPath, sourceRoot, skipped);
103
+ expectations.push(...dirExpectations);
104
+ } else if (entry.isFile() && shouldScanFile(entry.name)) {
105
+ const fileExpectations = scanFile(fullPath, sourceRoot, skipped);
106
+ expectations.push(...fileExpectations);
107
+ }
108
+ }
109
+ } catch (error) {
110
+ // Silently skip directories we can't read
111
+ }
112
+
113
+ return expectations;
114
+ }
115
+
116
+ /**
117
+ * Check if directory should be skipped
118
+ */
119
+ function shouldSkipDirectory(name) {
120
+ const skipPatterns = [
121
+ 'node_modules',
122
+ '.next',
123
+ 'dist',
124
+ 'build',
125
+ '.git',
126
+ '.venv',
127
+ '__pycache__',
128
+ '.env',
129
+ 'public',
130
+ '.cache',
131
+ 'coverage',
132
+ ];
133
+
134
+ return skipPatterns.includes(name) || name.startsWith('.');
135
+ }
136
+
137
+ /**
138
+ * Check if file should be scanned
139
+ */
140
+ function shouldScanFile(name) {
141
+ const extensions = ['.js', '.jsx', '.ts', '.tsx', '.html', '.mjs'];
142
+ return extensions.some(ext => name.endsWith(ext));
143
+ }
144
+
145
+ /**
146
+ * Scan a single file for expectations
147
+ */
148
+ function scanFile(filePath, sourceRoot, skipped) {
149
+ const expectations = [];
150
+
151
+ try {
152
+ const content = readFileSync(filePath, 'utf8');
153
+ const relPath = relative(sourceRoot, filePath);
154
+
155
+ if (filePath.endsWith('.html')) {
156
+ const htmlExpectations = extractHtmlExpectations(content, filePath, relPath);
157
+ expectations.push(...htmlExpectations);
158
+ } else {
159
+ const jsExpectations = extractJsExpectations(content, filePath, relPath, skipped);
160
+ expectations.push(...jsExpectations);
161
+ }
162
+ } catch (error) {
163
+ skipped.parseError++;
164
+ }
165
+
166
+ return expectations;
167
+ }
168
+
169
+ /**
170
+ * Extract expectations from HTML files
171
+ */
172
+ function extractHtmlExpectations(content, filePath, relPath) {
173
+ const expectations = [];
174
+
175
+ // Extract <a href="/path"> links
176
+ const hrefRegex = /<a\s+[^>]*href=["']([^"']+)["']/gi;
177
+ let match;
178
+
179
+ while ((match = hrefRegex.exec(content)) !== null) {
180
+ const href = match[1];
181
+ const lineNum = content.substring(0, match.index).split('\n').length;
182
+
183
+ // Skip dynamic/absolute URLs
184
+ if (!href.startsWith('#') && !href.startsWith('http') && !href.includes('${')) {
185
+ expectations.push({
186
+ type: 'navigation',
187
+ promise: {
188
+ kind: 'navigate',
189
+ value: href,
190
+ },
191
+ source: {
192
+ file: relPath,
193
+ line: lineNum,
194
+ column: match.index - content.lastIndexOf('\n', match.index),
195
+ },
196
+ confidence: 1.0,
197
+ });
198
+ }
199
+ }
200
+
201
+ return expectations;
202
+ }
203
+
204
+ /**
205
+ * Extract expectations from JavaScript/TypeScript files
206
+ */
207
+ function extractJsExpectations(content, filePath, relPath, skipped) {
208
+ const expectations = [];
209
+ const lines = content.split('\n');
210
+
211
+ lines.forEach((line, lineIdx) => {
212
+ const lineNum = lineIdx + 1;
213
+
214
+ // Skip comments
215
+ if (line.trim().startsWith('//') || line.trim().startsWith('*')) {
216
+ return;
217
+ }
218
+
219
+ // Extract Next.js <Link href="/path">
220
+ const linkRegex = /<Link\s+[^>]*href=["']([^"']+)["']/g;
221
+ let match;
222
+ while ((match = linkRegex.exec(line)) !== null) {
223
+ const href = match[1];
224
+
225
+ // Skip dynamic hrefs
226
+ if (!href.includes('${') && !href.includes('+') && !href.includes('`')) {
227
+ expectations.push({
228
+ type: 'navigation',
229
+ promise: {
230
+ kind: 'navigate',
231
+ value: href,
232
+ },
233
+ source: {
234
+ file: relPath,
235
+ line: lineNum,
236
+ column: match.index,
237
+ },
238
+ confidence: 1.0,
239
+ });
240
+ } else {
241
+ skipped.dynamic++;
242
+ }
243
+ }
244
+
245
+ // Extract router.push("/path")
246
+ const routerPushRegex = /router\.push\(["']([^"']+)["']\)/g;
247
+ while ((match = routerPushRegex.exec(line)) !== null) {
248
+ const path = match[1];
249
+
250
+ if (!path.includes('${') && !path.includes('+') && !path.includes('`')) {
251
+ expectations.push({
252
+ type: 'navigation',
253
+ promise: {
254
+ kind: 'navigate',
255
+ value: path,
256
+ },
257
+ source: {
258
+ file: relPath,
259
+ line: lineNum,
260
+ column: match.index,
261
+ },
262
+ confidence: 1.0,
263
+ });
264
+ } else {
265
+ skipped.dynamic++;
266
+ }
267
+ }
268
+
269
+ // Extract fetch("https://...")
270
+ const fetchRegex = /fetch\(["']([^"']+)["']\)/g;
271
+ while ((match = fetchRegex.exec(line)) !== null) {
272
+ const url = match[1];
273
+
274
+ // Only extract absolute URLs (https://)
275
+ if (url.startsWith('http://') || url.startsWith('https://')) {
276
+ expectations.push({
277
+ type: 'network',
278
+ promise: {
279
+ kind: 'request',
280
+ value: url,
281
+ },
282
+ source: {
283
+ file: relPath,
284
+ line: lineNum,
285
+ column: match.index,
286
+ },
287
+ confidence: 1.0,
288
+ });
289
+ } else if (!url.includes('${') && !url.includes('+') && !url.includes('`')) {
290
+ skipped.external++;
291
+ } else {
292
+ skipped.dynamic++;
293
+ }
294
+ }
295
+
296
+ // Extract axios.get/post("https://...")
297
+ const axiosRegex = /axios\.(get|post|put|delete|patch)\(["']([^"']+)["']\)/g;
298
+ while ((match = axiosRegex.exec(line)) !== null) {
299
+ const url = match[2];
300
+
301
+ if (url.startsWith('http://') || url.startsWith('https://')) {
302
+ expectations.push({
303
+ type: 'network',
304
+ promise: {
305
+ kind: 'request',
306
+ value: url,
307
+ },
308
+ source: {
309
+ file: relPath,
310
+ line: lineNum,
311
+ column: match.index,
312
+ },
313
+ confidence: 1.0,
314
+ });
315
+ } else if (!url.includes('${') && !url.includes('+') && !url.includes('`')) {
316
+ skipped.external++;
317
+ } else {
318
+ skipped.dynamic++;
319
+ }
320
+ }
321
+
322
+ // Extract useState setters
323
+ const useStateRegex = /useState\([^)]*\)/g;
324
+ if ((match = useStateRegex.exec(line)) !== null) {
325
+ expectations.push({
326
+ type: 'state',
327
+ promise: {
328
+ kind: 'state_mutation',
329
+ value: 'state management',
330
+ },
331
+ source: {
332
+ file: relPath,
333
+ line: lineNum,
334
+ column: match.index,
335
+ },
336
+ confidence: 0.8,
337
+ });
338
+ }
339
+
340
+ // Extract Redux dispatch calls
341
+ const dispatchRegex = /dispatch\(\{/g;
342
+ if ((match = dispatchRegex.exec(line)) !== null) {
343
+ expectations.push({
344
+ type: 'state',
345
+ promise: {
346
+ kind: 'state_mutation',
347
+ value: 'state management',
348
+ },
349
+ source: {
350
+ file: relPath,
351
+ line: lineNum,
352
+ column: match.index,
353
+ },
354
+ confidence: 0.8,
355
+ });
356
+ }
357
+
358
+ // Extract Zustand set() calls
359
+ const zustandRegex = /set\(\{/g;
360
+ if ((match = zustandRegex.exec(line)) !== null && line.includes('zustand') || line.includes('store')) {
361
+ expectations.push({
362
+ type: 'state',
363
+ promise: {
364
+ kind: 'state_mutation',
365
+ value: 'state management',
366
+ },
367
+ source: {
368
+ file: relPath,
369
+ line: lineNum,
370
+ column: match.index,
371
+ },
372
+ confidence: 0.8,
373
+ });
374
+ }
375
+ });
376
+
377
+ return expectations;
378
+ }
@@ -0,0 +1,31 @@
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
+ stats: {
20
+ total: findingsData.stats?.total || 0,
21
+ silentFailures: findingsData.stats?.silentFailures || 0,
22
+ observed: findingsData.stats?.observed || 0,
23
+ coverageGaps: findingsData.stats?.coverageGaps || 0,
24
+ unproven: findingsData.stats?.unproven || 0,
25
+ informational: findingsData.stats?.informational || 0,
26
+ },
27
+ detectedAt: findingsData.detectedAt || new Date().toISOString(),
28
+ };
29
+
30
+ atomicWriteJson(findingsPath, payload);
31
+ }
@@ -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
+ }