@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,412 @@
1
+ import { chromium } from 'playwright';
2
+ import { writeFileSync, mkdirSync } from 'fs';
3
+ import { resolve } from 'path';
4
+ import { redactHeaders, redactUrl, redactBody, redactConsole, getRedactionCounters } from './redact.js';
5
+
6
+ /**
7
+ * Real Browser Observation Engine
8
+ * Monitors expectations from learn.json using actual Playwright browser
9
+ */
10
+
11
+ export async function observeExpectations(expectations, url, evidencePath, onProgress) {
12
+ const observations = [];
13
+ let observed = 0;
14
+ let notObserved = 0;
15
+ const redactionCounters = { headersRedacted: 0, tokensRedacted: 0 };
16
+ let browser = null;
17
+ let page = null;
18
+
19
+ try {
20
+ // Launch browser
21
+ browser = await chromium.launch({
22
+ headless: true,
23
+ });
24
+
25
+ page = await browser.newPage({
26
+ viewport: { width: 1280, height: 800 },
27
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
28
+ });
29
+
30
+ // Set up network and console monitoring
31
+ const networkLogs = [];
32
+ const consoleLogs = [];
33
+
34
+ page.on('request', (request) => {
35
+ const redactedHeaders = redactHeaders(request.headers(), redactionCounters);
36
+ const redactedUrl = redactUrl(request.url(), redactionCounters);
37
+ let redactedBody = null;
38
+ try {
39
+ const body = request.postData();
40
+ redactedBody = body ? redactBody(body, redactionCounters) : null;
41
+ } catch {
42
+ redactedBody = null;
43
+ }
44
+
45
+ networkLogs.push({
46
+ url: redactedUrl,
47
+ method: request.method(),
48
+ headers: redactedHeaders,
49
+ body: redactedBody,
50
+ timestamp: new Date().toISOString(),
51
+ });
52
+ });
53
+
54
+ page.on('console', (msg) => {
55
+ const redactedText = redactConsole(msg.text(), redactionCounters);
56
+ consoleLogs.push({
57
+ type: msg.type(),
58
+ text: redactedText,
59
+ timestamp: new Date().toISOString(),
60
+ });
61
+ });
62
+
63
+ // Navigate to base URL first with explicit timeout
64
+ try {
65
+ await page.goto(url, {
66
+ waitUntil: 'domcontentloaded', // Use domcontentloaded instead of networkidle for faster timeout
67
+ timeout: 30000
68
+ });
69
+ // Wait for network idle with separate timeout
70
+ await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {
71
+ // Network idle timeout is acceptable, continue
72
+ });
73
+ } catch (error) {
74
+ // Continue even if initial load fails
75
+ if (onProgress) {
76
+ onProgress({
77
+ event: 'observe:warning',
78
+ message: `Failed to load base URL: ${error.message}`,
79
+ });
80
+ }
81
+ }
82
+
83
+ // Track visited URLs for navigation observations
84
+ const visitedUrls = new Set([url]);
85
+
86
+ // Process each expectation
87
+ for (let i = 0; i < expectations.length; i++) {
88
+ const exp = expectations[i];
89
+ const expNum = i + 1;
90
+
91
+ if (onProgress) {
92
+ onProgress({
93
+ event: 'observe:attempt',
94
+ index: expNum,
95
+ total: expectations.length,
96
+ type: exp.type,
97
+ promise: exp.promise,
98
+ });
99
+ }
100
+
101
+ const observation = {
102
+ id: exp.id,
103
+ type: exp.type,
104
+ promise: exp.promise,
105
+ source: exp.source,
106
+ attempted: false,
107
+ observed: false,
108
+ observedAt: null,
109
+ evidenceFiles: [],
110
+ reason: null,
111
+ };
112
+
113
+ try {
114
+ let result = false;
115
+ let evidence = null;
116
+
117
+ if (exp.type === 'navigation') {
118
+ observation.attempted = true; // Mark as attempted
119
+ result = await observeNavigation(
120
+ page,
121
+ exp,
122
+ url,
123
+ visitedUrls,
124
+ evidencePath,
125
+ expNum
126
+ );
127
+ evidence = result ? `nav_${expNum}_after.png` : null;
128
+ } else if (exp.type === 'network') {
129
+ observation.attempted = true; // Mark as attempted
130
+ result = await observeNetwork(page, exp, networkLogs, 5000);
131
+ if (result) {
132
+ const evidenceFile = `network_${expNum}.json`;
133
+ try {
134
+ mkdirSync(evidencePath, { recursive: true });
135
+ const targetUrl = exp.promise.value;
136
+ const relevant = networkLogs.filter((log) =>
137
+ log.url === targetUrl || log.url.includes(targetUrl) || targetUrl.includes(log.url)
138
+ );
139
+ writeFileSync(resolve(evidencePath, evidenceFile), JSON.stringify(relevant, null, 2), 'utf-8');
140
+ } catch {
141
+ // best effort
142
+ }
143
+ evidence = evidenceFile;
144
+ } else {
145
+ evidence = null;
146
+ }
147
+ } else if (exp.type === 'state') {
148
+ observation.attempted = true; // Mark as attempted
149
+ result = await observeState(page, exp, evidencePath, expNum);
150
+ evidence = result ? `state_${expNum}_after.png` : null;
151
+ }
152
+
153
+ if (result) {
154
+ observation.observed = true;
155
+ observation.observedAt = new Date().toISOString();
156
+ if (evidence) observation.evidenceFiles.push(evidence);
157
+ observed++;
158
+ } else {
159
+ observation.reason = 'No matching event observed';
160
+ notObserved++;
161
+ }
162
+ } catch (error) {
163
+ observation.reason = `Error: ${error.message}`;
164
+ notObserved++;
165
+ }
166
+
167
+ observations.push(observation);
168
+
169
+ if (onProgress) {
170
+ onProgress({
171
+ event: 'observe:result',
172
+ index: expNum,
173
+ observed: observation.observed,
174
+ reason: observation.reason,
175
+ });
176
+ }
177
+ }
178
+
179
+ // Persist shared evidence
180
+ try {
181
+ mkdirSync(evidencePath, { recursive: true });
182
+ const networkPath = resolve(evidencePath, 'network_logs.json');
183
+ writeFileSync(networkPath, JSON.stringify(networkLogs, null, 2), 'utf-8');
184
+ const consolePath = resolve(evidencePath, 'console_logs.json');
185
+ writeFileSync(consolePath, JSON.stringify(consoleLogs, null, 2), 'utf-8');
186
+ } catch {
187
+ // Best effort; do not throw
188
+ }
189
+
190
+ return {
191
+ observations,
192
+ stats: {
193
+ attempted: expectations.length,
194
+ observed,
195
+ notObserved,
196
+ },
197
+ redaction: getRedactionCounters(redactionCounters),
198
+ observedAt: new Date().toISOString(),
199
+ };
200
+ } finally {
201
+ // Robust cleanup: ensure browser/context/page are closed
202
+ // Remove all event listeners to prevent leaks
203
+ if (page) {
204
+ try {
205
+ // Remove all listeners
206
+ page.removeAllListeners();
207
+ // @ts-expect-error - Playwright page.close() doesn't accept timeout option, but we use it for safety
208
+ await page.close({ timeout: 5000 }).catch(() => {});
209
+ } catch (e) {
210
+ // Ignore close errors but emit warning if onProgress available
211
+ if (onProgress) {
212
+ onProgress({
213
+ event: 'observe:warning',
214
+ message: `Page cleanup warning: ${e.message}`,
215
+ });
216
+ }
217
+ }
218
+ }
219
+
220
+ // Close browser context if it exists
221
+ if (browser) {
222
+ try {
223
+ const contexts = browser.contexts();
224
+ for (const context of contexts) {
225
+ try {
226
+ // @ts-expect-error - Playwright context.close() doesn't accept timeout option, but we use it for safety
227
+ await context.close({ timeout: 5000 }).catch(() => {});
228
+ } catch (e) {
229
+ // Ignore context close errors
230
+ }
231
+ }
232
+ // @ts-expect-error - Playwright browser.close() doesn't accept timeout option, but we use it for safety
233
+ await browser.close({ timeout: 5000 }).catch(() => {});
234
+ } catch (e) {
235
+ // Ignore browser close errors but emit warning if onProgress available
236
+ if (onProgress) {
237
+ onProgress({
238
+ event: 'observe:warning',
239
+ message: `Browser cleanup warning: ${e.message}`,
240
+ });
241
+ }
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Observe navigation expectation
249
+ * Attempts to find and click element, observes URL/SPA changes
250
+ */
251
+ async function observeNavigation(page, expectation, baseUrl, visitedUrls, evidencePath, expNum) {
252
+ const targetPath = expectation.promise.value;
253
+
254
+ try {
255
+ // Screenshot before interaction
256
+ const beforePath = resolve(evidencePath, `nav_${expNum}_before.png`);
257
+ await page.screenshot({ path: beforePath }).catch(() => {});
258
+
259
+ // Find element by searching all anchor tags
260
+ const element = await page.evaluate((path) => {
261
+ const anchors = Array.from(document.querySelectorAll('a'));
262
+ const found = anchors.find(a => {
263
+ const href = a.getAttribute('href');
264
+ return href === path || href.includes(path);
265
+ });
266
+ return found ? { tag: 'a', href: found.getAttribute('href') } : null;
267
+ }, targetPath);
268
+
269
+ if (!element) {
270
+ return false;
271
+ }
272
+
273
+ const urlBefore = page.url();
274
+ const contentBefore = await page.content();
275
+
276
+ // Click the element - try multiple approaches
277
+ try {
278
+ await page.locator(`a[href="${element.href}"]`).click({ timeout: 3000 });
279
+ } catch (e) {
280
+ try {
281
+ await page.click(`a[href="${element.href}"]`);
282
+ } catch (e2) {
283
+ // Try clicking by text content
284
+ // eslint-disable-next-line no-undef
285
+ const text = await page.evaluate((href) => {
286
+ const anchors = Array.from(document.querySelectorAll('a'));
287
+ const found = anchors.find(a => a.getAttribute('href') === href);
288
+ return found ? found.textContent : null;
289
+ }, element.href);
290
+
291
+ if (text) {
292
+ await page.click(`a:has-text("${text}")`).catch(() => {});
293
+ }
294
+ }
295
+ }
296
+
297
+ // Wait for navigation or SPA update with explicit timeout
298
+ try {
299
+ await page.waitForNavigation({
300
+ waitUntil: 'domcontentloaded',
301
+ timeout: 5000
302
+ }).catch(() => {
303
+ // Navigation timeout is acceptable for SPAs
304
+ });
305
+ // Wait for network idle with separate timeout
306
+ await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
307
+ // Network idle timeout is acceptable
308
+ });
309
+ } catch (e) {
310
+ // Navigation might not happen, continue
311
+ }
312
+
313
+ // Wait for potential SPA updates (bounded)
314
+ await page.waitForTimeout(300);
315
+
316
+ // Screenshot after interaction
317
+ const afterPath = resolve(evidencePath, `nav_${expNum}_after.png`);
318
+ await page.screenshot({ path: afterPath }).catch(() => {});
319
+
320
+ const urlAfter = page.url();
321
+ const contentAfter = await page.content();
322
+
323
+ // Check if URL changed or content changed
324
+ if (urlBefore !== urlAfter || contentBefore !== contentAfter) {
325
+ visitedUrls.add(urlAfter);
326
+ return true;
327
+ }
328
+
329
+ return false;
330
+ } catch (error) {
331
+ return false;
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Observe network expectation
337
+ * Checks if matching request was made
338
+ */
339
+ async function observeNetwork(page, expectation, networkLogs, timeoutMs) {
340
+ const targetUrl = expectation.promise.value;
341
+ const startTime = Date.now();
342
+
343
+ return new Promise((resolve) => {
344
+ const checkTimer = setInterval(() => {
345
+ const found = networkLogs.some((log) => {
346
+ return (
347
+ log.url === targetUrl ||
348
+ log.url.includes(targetUrl) ||
349
+ targetUrl.includes(log.url)
350
+ );
351
+ });
352
+
353
+ if (found) {
354
+ clearInterval(checkTimer);
355
+ resolve(true);
356
+ return;
357
+ }
358
+
359
+ if (Date.now() - startTime > timeoutMs) {
360
+ clearInterval(checkTimer);
361
+ resolve(false);
362
+ return;
363
+ }
364
+ }, 100);
365
+
366
+ // CRITICAL: Unref the interval so it doesn't keep the process alive
367
+ // This allows tests to exit cleanly even if interval is not cleared
368
+ if (checkTimer && checkTimer.unref) {
369
+ checkTimer.unref();
370
+ }
371
+ });
372
+ }
373
+
374
+ /**
375
+ * Observe state expectation
376
+ * Detects DOM changes or loading indicators
377
+ */
378
+ async function observeState(page, expectation, evidencePath, expNum) {
379
+ try {
380
+ // Screenshot before
381
+ const beforePath = resolve(evidencePath, `state_${expNum}_before.png`);
382
+ await page.screenshot({ path: beforePath });
383
+
384
+ const htmlBefore = await page.content();
385
+
386
+ // Wait briefly for potential state changes
387
+ await page.waitForTimeout(2000);
388
+
389
+ const htmlAfter = await page.content();
390
+
391
+ // Screenshot after
392
+ const afterPath = resolve(evidencePath, `state_${expNum}_after.png`);
393
+ await page.screenshot({ path: afterPath });
394
+
395
+ // Check if DOM changed
396
+ if (htmlBefore !== htmlAfter) {
397
+ return true;
398
+ }
399
+
400
+ // Check for common state indicators (loading, error, success messages)
401
+ const hasStateIndicators =
402
+ (await page.$('.loading')) ||
403
+ (await page.$('[role="status"]')) ||
404
+ (await page.$('.toast')) ||
405
+ (await page.$('[aria-live]'));
406
+
407
+ return !!hasStateIndicators;
408
+ } catch (error) {
409
+ return false;
410
+ }
411
+ }
412
+
@@ -0,0 +1,25 @@
1
+ import { atomicWriteJson } from './atomic-write.js';
2
+ import { resolve } from 'path';
3
+
4
+ /**
5
+ * Write observe.json artifact
6
+ */
7
+ export function writeObserveJson(runDir, observeData) {
8
+ const observePath = resolve(runDir, 'observe.json');
9
+
10
+ const payload = {
11
+ observations: observeData.observations || [],
12
+ stats: {
13
+ attempted: observeData.stats?.attempted || 0,
14
+ observed: observeData.stats?.observed || 0,
15
+ notObserved: observeData.stats?.notObserved || 0,
16
+ },
17
+ redaction: {
18
+ headersRedacted: observeData.redaction?.headersRedacted || 0,
19
+ tokensRedacted: observeData.redaction?.tokensRedacted || 0,
20
+ },
21
+ observedAt: observeData.observedAt || new Date().toISOString(),
22
+ };
23
+
24
+ atomicWriteJson(observePath, payload);
25
+ }
@@ -0,0 +1,30 @@
1
+ import { join, isAbsolute } from 'path';
2
+ import { mkdirSync } from 'fs';
3
+
4
+ /**
5
+ * Build run artifact paths
6
+ */
7
+ export function getRunPaths(projectRoot, outDir, runId) {
8
+ const outBase = isAbsolute(outDir) ? outDir : join(projectRoot, outDir);
9
+ const baseDir = join(outBase, 'runs', runId);
10
+
11
+ return {
12
+ baseDir,
13
+ runStatusJson: join(baseDir, 'run.status.json'),
14
+ runMetaJson: join(baseDir, 'run.meta.json'),
15
+ summaryJson: join(baseDir, 'summary.json'),
16
+ findingsJson: join(baseDir, 'findings.json'),
17
+ tracesJsonl: join(baseDir, 'traces.jsonl'),
18
+ evidenceDir: join(baseDir, 'evidence'),
19
+ learnJson: join(baseDir, 'learn.json'),
20
+ observeJson: join(baseDir, 'observe.json'),
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Ensure all required directories exist
26
+ */
27
+ export function ensureRunDirectories(paths) {
28
+ mkdirSync(paths.baseDir, { recursive: true });
29
+ mkdirSync(paths.evidenceDir, { recursive: true });
30
+ }