@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.
- package/README.md +123 -88
- package/bin/verax.js +11 -452
- package/package.json +14 -36
- package/src/cli/commands/default.js +523 -0
- package/src/cli/commands/doctor.js +165 -0
- package/src/cli/commands/inspect.js +109 -0
- package/src/cli/commands/run.js +402 -0
- package/src/cli/entry.js +196 -0
- package/src/cli/util/atomic-write.js +37 -0
- package/src/cli/util/detection-engine.js +296 -0
- package/src/cli/util/env-url.js +33 -0
- package/src/cli/util/errors.js +44 -0
- package/src/cli/util/events.js +34 -0
- package/src/cli/util/expectation-extractor.js +378 -0
- package/src/cli/util/findings-writer.js +31 -0
- package/src/cli/util/idgen.js +87 -0
- package/src/cli/util/learn-writer.js +39 -0
- package/src/cli/util/observation-engine.js +366 -0
- package/src/cli/util/observe-writer.js +25 -0
- package/src/cli/util/paths.js +29 -0
- package/src/cli/util/project-discovery.js +277 -0
- package/src/cli/util/project-writer.js +26 -0
- package/src/cli/util/redact.js +128 -0
- package/src/cli/util/run-id.js +30 -0
- package/src/cli/util/summary-writer.js +32 -0
- package/src/verax/cli/ci-summary.js +35 -0
- package/src/verax/cli/context-explanation.js +89 -0
- package/src/verax/cli/doctor.js +277 -0
- package/src/verax/cli/error-normalizer.js +154 -0
- package/src/verax/cli/explain-output.js +105 -0
- package/src/verax/cli/finding-explainer.js +130 -0
- package/src/verax/cli/init.js +237 -0
- package/src/verax/cli/run-overview.js +163 -0
- package/src/verax/cli/url-safety.js +101 -0
- package/src/verax/cli/wizard.js +98 -0
- package/src/verax/cli/zero-findings-explainer.js +57 -0
- package/src/verax/cli/zero-interaction-explainer.js +127 -0
- package/src/verax/core/action-classifier.js +86 -0
- package/src/verax/core/budget-engine.js +218 -0
- package/src/verax/core/canonical-outcomes.js +157 -0
- package/src/verax/core/decision-snapshot.js +335 -0
- package/src/verax/core/determinism-model.js +403 -0
- package/src/verax/core/incremental-store.js +237 -0
- package/src/verax/core/invariants.js +356 -0
- package/src/verax/core/promise-model.js +230 -0
- package/src/verax/core/replay-validator.js +350 -0
- package/src/verax/core/replay.js +222 -0
- package/src/verax/core/run-id.js +175 -0
- package/src/verax/core/run-manifest.js +99 -0
- package/src/verax/core/silence-impact.js +369 -0
- package/src/verax/core/silence-model.js +521 -0
- package/src/verax/detect/comparison.js +2 -34
- package/src/verax/detect/confidence-engine.js +764 -329
- package/src/verax/detect/detection-engine.js +293 -0
- package/src/verax/detect/evidence-index.js +177 -0
- package/src/verax/detect/expectation-model.js +194 -172
- package/src/verax/detect/explanation-helpers.js +187 -0
- package/src/verax/detect/finding-detector.js +450 -0
- package/src/verax/detect/findings-writer.js +44 -8
- package/src/verax/detect/flow-detector.js +366 -0
- package/src/verax/detect/index.js +172 -286
- package/src/verax/detect/interactive-findings.js +613 -0
- package/src/verax/detect/signal-mapper.js +308 -0
- package/src/verax/detect/verdict-engine.js +563 -0
- package/src/verax/evidence-index-writer.js +61 -0
- package/src/verax/index.js +90 -14
- package/src/verax/intel/effect-detector.js +368 -0
- package/src/verax/intel/handler-mapper.js +249 -0
- package/src/verax/intel/index.js +281 -0
- package/src/verax/intel/route-extractor.js +280 -0
- package/src/verax/intel/ts-program.js +256 -0
- package/src/verax/intel/vue-navigation-extractor.js +579 -0
- package/src/verax/intel/vue-router-extractor.js +323 -0
- package/src/verax/learn/action-contract-extractor.js +335 -101
- package/src/verax/learn/ast-contract-extractor.js +95 -5
- package/src/verax/learn/flow-extractor.js +172 -0
- package/src/verax/learn/manifest-writer.js +97 -47
- package/src/verax/learn/project-detector.js +40 -0
- package/src/verax/learn/route-extractor.js +27 -96
- package/src/verax/learn/state-extractor.js +212 -0
- package/src/verax/learn/static-extractor-navigation.js +114 -0
- package/src/verax/learn/static-extractor-validation.js +88 -0
- package/src/verax/learn/static-extractor.js +112 -4
- package/src/verax/learn/truth-assessor.js +24 -21
- package/src/verax/observe/aria-sensor.js +211 -0
- package/src/verax/observe/browser.js +10 -5
- package/src/verax/observe/console-sensor.js +1 -17
- package/src/verax/observe/domain-boundary.js +10 -1
- package/src/verax/observe/expectation-executor.js +512 -0
- package/src/verax/observe/flow-matcher.js +143 -0
- package/src/verax/observe/focus-sensor.js +196 -0
- package/src/verax/observe/human-driver.js +643 -275
- package/src/verax/observe/index.js +908 -27
- package/src/verax/observe/index.js.backup +1 -0
- package/src/verax/observe/interaction-discovery.js +365 -14
- package/src/verax/observe/interaction-runner.js +563 -198
- package/src/verax/observe/loading-sensor.js +139 -0
- package/src/verax/observe/navigation-sensor.js +255 -0
- package/src/verax/observe/network-sensor.js +55 -7
- package/src/verax/observe/observed-expectation-deriver.js +186 -0
- package/src/verax/observe/observed-expectation.js +305 -0
- package/src/verax/observe/page-frontier.js +234 -0
- package/src/verax/observe/settle.js +37 -17
- package/src/verax/observe/state-sensor.js +389 -0
- package/src/verax/observe/timing-sensor.js +228 -0
- package/src/verax/observe/traces-writer.js +61 -20
- package/src/verax/observe/ui-signal-sensor.js +136 -17
- package/src/verax/scan-summary-writer.js +77 -15
- package/src/verax/shared/artifact-manager.js +110 -8
- package/src/verax/shared/budget-profiles.js +136 -0
- package/src/verax/shared/ci-detection.js +39 -0
- package/src/verax/shared/config-loader.js +170 -0
- package/src/verax/shared/dynamic-route-utils.js +218 -0
- package/src/verax/shared/expectation-coverage.js +44 -0
- package/src/verax/shared/expectation-prover.js +81 -0
- package/src/verax/shared/expectation-tracker.js +201 -0
- package/src/verax/shared/expectations-writer.js +60 -0
- package/src/verax/shared/first-run.js +44 -0
- package/src/verax/shared/progress-reporter.js +171 -0
- package/src/verax/shared/retry-policy.js +14 -1
- package/src/verax/shared/root-artifacts.js +49 -0
- package/src/verax/shared/scan-budget.js +86 -0
- package/src/verax/shared/url-normalizer.js +162 -0
- package/src/verax/shared/zip-artifacts.js +65 -0
- package/src/verax/validate/context-validator.js +244 -0
- package/src/verax/validate/context-validator.js.bak +0 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
import { writeFileSync, mkdirSync } from 'fs';
|
|
3
|
+
import { resolve, join } 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
|
|
64
|
+
try {
|
|
65
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
|
|
66
|
+
} catch (error) {
|
|
67
|
+
// Continue even if initial load fails
|
|
68
|
+
if (onProgress) {
|
|
69
|
+
onProgress({
|
|
70
|
+
event: 'observe:warning',
|
|
71
|
+
message: `Failed to load base URL: ${error.message}`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Track visited URLs for navigation observations
|
|
77
|
+
const visitedUrls = new Set([url]);
|
|
78
|
+
|
|
79
|
+
// Process each expectation
|
|
80
|
+
for (let i = 0; i < expectations.length; i++) {
|
|
81
|
+
const exp = expectations[i];
|
|
82
|
+
const expNum = i + 1;
|
|
83
|
+
|
|
84
|
+
if (onProgress) {
|
|
85
|
+
onProgress({
|
|
86
|
+
event: 'observe:attempt',
|
|
87
|
+
index: expNum,
|
|
88
|
+
total: expectations.length,
|
|
89
|
+
type: exp.type,
|
|
90
|
+
promise: exp.promise,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const observation = {
|
|
95
|
+
id: exp.id,
|
|
96
|
+
type: exp.type,
|
|
97
|
+
promise: exp.promise,
|
|
98
|
+
source: exp.source,
|
|
99
|
+
observed: false,
|
|
100
|
+
observedAt: null,
|
|
101
|
+
evidenceFiles: [],
|
|
102
|
+
reason: null,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
let result = false;
|
|
107
|
+
let evidence = null;
|
|
108
|
+
|
|
109
|
+
if (exp.type === 'navigation') {
|
|
110
|
+
result = await observeNavigation(
|
|
111
|
+
page,
|
|
112
|
+
exp,
|
|
113
|
+
url,
|
|
114
|
+
visitedUrls,
|
|
115
|
+
evidencePath,
|
|
116
|
+
expNum
|
|
117
|
+
);
|
|
118
|
+
evidence = result ? `nav_${expNum}_after.png` : null;
|
|
119
|
+
} else if (exp.type === 'network') {
|
|
120
|
+
result = await observeNetwork(page, exp, networkLogs, 5000);
|
|
121
|
+
if (result) {
|
|
122
|
+
const evidenceFile = `network_${expNum}.json`;
|
|
123
|
+
try {
|
|
124
|
+
mkdirSync(evidencePath, { recursive: true });
|
|
125
|
+
const targetUrl = exp.promise.value;
|
|
126
|
+
const relevant = networkLogs.filter((log) =>
|
|
127
|
+
log.url === targetUrl || log.url.includes(targetUrl) || targetUrl.includes(log.url)
|
|
128
|
+
);
|
|
129
|
+
writeFileSync(resolve(evidencePath, evidenceFile), JSON.stringify(relevant, null, 2), 'utf-8');
|
|
130
|
+
} catch {
|
|
131
|
+
// best effort
|
|
132
|
+
}
|
|
133
|
+
evidence = evidenceFile;
|
|
134
|
+
} else {
|
|
135
|
+
evidence = null;
|
|
136
|
+
}
|
|
137
|
+
} else if (exp.type === 'state') {
|
|
138
|
+
result = await observeState(page, exp, evidencePath, expNum);
|
|
139
|
+
evidence = result ? `state_${expNum}_after.png` : null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (result) {
|
|
143
|
+
observation.observed = true;
|
|
144
|
+
observation.observedAt = new Date().toISOString();
|
|
145
|
+
if (evidence) observation.evidenceFiles.push(evidence);
|
|
146
|
+
observed++;
|
|
147
|
+
} else {
|
|
148
|
+
observation.reason = 'No matching event observed';
|
|
149
|
+
notObserved++;
|
|
150
|
+
}
|
|
151
|
+
} catch (error) {
|
|
152
|
+
observation.reason = `Error: ${error.message}`;
|
|
153
|
+
notObserved++;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
observations.push(observation);
|
|
157
|
+
|
|
158
|
+
if (onProgress) {
|
|
159
|
+
onProgress({
|
|
160
|
+
event: 'observe:result',
|
|
161
|
+
index: expNum,
|
|
162
|
+
observed: observation.observed,
|
|
163
|
+
reason: observation.reason,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Persist shared evidence
|
|
169
|
+
try {
|
|
170
|
+
mkdirSync(evidencePath, { recursive: true });
|
|
171
|
+
const networkPath = resolve(evidencePath, 'network_logs.json');
|
|
172
|
+
writeFileSync(networkPath, JSON.stringify(networkLogs, null, 2), 'utf-8');
|
|
173
|
+
const consolePath = resolve(evidencePath, 'console_logs.json');
|
|
174
|
+
writeFileSync(consolePath, JSON.stringify(consoleLogs, null, 2), 'utf-8');
|
|
175
|
+
} catch {
|
|
176
|
+
// Best effort; do not throw
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
observations,
|
|
181
|
+
stats: {
|
|
182
|
+
attempted: expectations.length,
|
|
183
|
+
observed,
|
|
184
|
+
notObserved,
|
|
185
|
+
},
|
|
186
|
+
redaction: getRedactionCounters(redactionCounters),
|
|
187
|
+
observedAt: new Date().toISOString(),
|
|
188
|
+
};
|
|
189
|
+
} finally {
|
|
190
|
+
// Clean up
|
|
191
|
+
if (page) {
|
|
192
|
+
try {
|
|
193
|
+
await page.close();
|
|
194
|
+
} catch (e) {
|
|
195
|
+
// Ignore close errors
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (browser) {
|
|
199
|
+
try {
|
|
200
|
+
await browser.close();
|
|
201
|
+
} catch (e) {
|
|
202
|
+
// Ignore close errors
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Observe navigation expectation
|
|
210
|
+
* Attempts to find and click element, observes URL/SPA changes
|
|
211
|
+
*/
|
|
212
|
+
async function observeNavigation(page, expectation, baseUrl, visitedUrls, evidencePath, expNum) {
|
|
213
|
+
const targetPath = expectation.promise.value;
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
// Screenshot before interaction
|
|
217
|
+
const beforePath = resolve(evidencePath, `nav_${expNum}_before.png`);
|
|
218
|
+
await page.screenshot({ path: beforePath }).catch(() => {});
|
|
219
|
+
|
|
220
|
+
// Find element by searching all anchor tags
|
|
221
|
+
const element = await page.evaluate((path) => {
|
|
222
|
+
const anchors = Array.from(document.querySelectorAll('a'));
|
|
223
|
+
const found = anchors.find(a => {
|
|
224
|
+
const href = a.getAttribute('href');
|
|
225
|
+
return href === path || href.includes(path);
|
|
226
|
+
});
|
|
227
|
+
return found ? { tag: 'a', href: found.getAttribute('href') } : null;
|
|
228
|
+
}, targetPath);
|
|
229
|
+
|
|
230
|
+
if (!element) {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const urlBefore = page.url();
|
|
235
|
+
const contentBefore = await page.content();
|
|
236
|
+
|
|
237
|
+
// Click the element - try multiple approaches
|
|
238
|
+
try {
|
|
239
|
+
await page.locator(`a[href="${element.href}"]`).click({ timeout: 3000 });
|
|
240
|
+
} catch (e) {
|
|
241
|
+
try {
|
|
242
|
+
await page.click(`a[href="${element.href}"]`);
|
|
243
|
+
} catch (e2) {
|
|
244
|
+
// Try clicking by text content
|
|
245
|
+
const text = await page.evaluate((href) => {
|
|
246
|
+
const anchors = Array.from(document.querySelectorAll('a'));
|
|
247
|
+
const found = anchors.find(a => a.getAttribute('href') === href);
|
|
248
|
+
return found ? found.textContent : null;
|
|
249
|
+
}, element.href);
|
|
250
|
+
|
|
251
|
+
if (text) {
|
|
252
|
+
await page.click(`a:has-text("${text}")`).catch(() => {});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Wait for navigation or SPA update
|
|
258
|
+
try {
|
|
259
|
+
await page.waitForNavigation({ waitUntil: 'networkidle', timeout: 2000 }).catch(() => {});
|
|
260
|
+
} catch (e) {
|
|
261
|
+
// Navigation might not happen
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Wait for potential SPA updates
|
|
265
|
+
await page.waitForTimeout(300);
|
|
266
|
+
|
|
267
|
+
// Screenshot after interaction
|
|
268
|
+
const afterPath = resolve(evidencePath, `nav_${expNum}_after.png`);
|
|
269
|
+
await page.screenshot({ path: afterPath }).catch(() => {});
|
|
270
|
+
|
|
271
|
+
const urlAfter = page.url();
|
|
272
|
+
const contentAfter = await page.content();
|
|
273
|
+
|
|
274
|
+
// Check if URL changed or content changed
|
|
275
|
+
if (urlBefore !== urlAfter || contentBefore !== contentAfter) {
|
|
276
|
+
visitedUrls.add(urlAfter);
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return false;
|
|
281
|
+
} catch (error) {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Observe network expectation
|
|
288
|
+
* Checks if matching request was made
|
|
289
|
+
*/
|
|
290
|
+
async function observeNetwork(page, expectation, networkLogs, timeoutMs) {
|
|
291
|
+
const targetUrl = expectation.promise.value;
|
|
292
|
+
const startTime = Date.now();
|
|
293
|
+
|
|
294
|
+
return new Promise((resolve) => {
|
|
295
|
+
const checkTimer = setInterval(() => {
|
|
296
|
+
const found = networkLogs.some((log) => {
|
|
297
|
+
return (
|
|
298
|
+
log.url === targetUrl ||
|
|
299
|
+
log.url.includes(targetUrl) ||
|
|
300
|
+
targetUrl.includes(log.url)
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
if (found) {
|
|
305
|
+
clearInterval(checkTimer);
|
|
306
|
+
resolve(true);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (Date.now() - startTime > timeoutMs) {
|
|
310
|
+
clearInterval(checkTimer);
|
|
311
|
+
resolve(false);
|
|
312
|
+
}
|
|
313
|
+
}, 100);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Observe state expectation
|
|
319
|
+
* Detects DOM changes or loading indicators
|
|
320
|
+
*/
|
|
321
|
+
async function observeState(page, expectation, evidencePath, expNum) {
|
|
322
|
+
try {
|
|
323
|
+
// Screenshot before
|
|
324
|
+
const beforePath = resolve(evidencePath, `state_${expNum}_before.png`);
|
|
325
|
+
await page.screenshot({ path: beforePath });
|
|
326
|
+
|
|
327
|
+
const htmlBefore = await page.content();
|
|
328
|
+
|
|
329
|
+
// Wait briefly for potential state changes
|
|
330
|
+
await page.waitForTimeout(2000);
|
|
331
|
+
|
|
332
|
+
const htmlAfter = await page.content();
|
|
333
|
+
|
|
334
|
+
// Screenshot after
|
|
335
|
+
const afterPath = resolve(evidencePath, `state_${expNum}_after.png`);
|
|
336
|
+
await page.screenshot({ path: afterPath });
|
|
337
|
+
|
|
338
|
+
// Check if DOM changed
|
|
339
|
+
if (htmlBefore !== htmlAfter) {
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Check for common state indicators (loading, error, success messages)
|
|
344
|
+
const hasStateIndicators =
|
|
345
|
+
(await page.$('.loading')) ||
|
|
346
|
+
(await page.$('[role="status"]')) ||
|
|
347
|
+
(await page.$('.toast')) ||
|
|
348
|
+
(await page.$('[aria-live]'));
|
|
349
|
+
|
|
350
|
+
return !!hasStateIndicators;
|
|
351
|
+
} catch (error) {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Check if page content changed (for SPA detection)
|
|
358
|
+
*/
|
|
359
|
+
async function checkPageContentChanged(page) {
|
|
360
|
+
try {
|
|
361
|
+
const bodyText = await page.locator('body').textContent();
|
|
362
|
+
return bodyText && bodyText.length > 0;
|
|
363
|
+
} catch (error) {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
@@ -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,29 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { mkdirSync } from 'fs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build run artifact paths
|
|
6
|
+
*/
|
|
7
|
+
export function getRunPaths(projectRoot, outDir, runId) {
|
|
8
|
+
const baseDir = join(projectRoot, outDir, 'runs', runId);
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
baseDir,
|
|
12
|
+
runStatusJson: join(baseDir, 'run.status.json'),
|
|
13
|
+
runMetaJson: join(baseDir, 'run.meta.json'),
|
|
14
|
+
summaryJson: join(baseDir, 'summary.json'),
|
|
15
|
+
findingsJson: join(baseDir, 'findings.json'),
|
|
16
|
+
tracesJsonl: join(baseDir, 'traces.jsonl'),
|
|
17
|
+
evidenceDir: join(baseDir, 'evidence'),
|
|
18
|
+
learnJson: join(baseDir, 'learn.json'),
|
|
19
|
+
observeJson: join(baseDir, 'observe.json'),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Ensure all required directories exist
|
|
25
|
+
*/
|
|
26
|
+
export function ensureRunDirectories(paths) {
|
|
27
|
+
mkdirSync(paths.baseDir, { recursive: true });
|
|
28
|
+
mkdirSync(paths.evidenceDir, { recursive: true });
|
|
29
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
2
|
+
import { resolve, dirname } from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Project Discovery Module
|
|
6
|
+
* Detects framework, router, source root, and dev server configuration
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export async function discoverProject(srcPath) {
|
|
10
|
+
const projectRoot = resolve(srcPath);
|
|
11
|
+
|
|
12
|
+
// Find the nearest package.json
|
|
13
|
+
const packageJsonPath = findPackageJson(projectRoot);
|
|
14
|
+
|
|
15
|
+
// If there's a package.json, use its directory
|
|
16
|
+
// Otherwise, use the srcPath (even if it's a static HTML project)
|
|
17
|
+
const projectDir = packageJsonPath ? dirname(packageJsonPath) : projectRoot;
|
|
18
|
+
|
|
19
|
+
let packageJson = null;
|
|
20
|
+
if (packageJsonPath && existsSync(packageJsonPath)) {
|
|
21
|
+
try {
|
|
22
|
+
packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
23
|
+
} catch (error) {
|
|
24
|
+
packageJson = null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Detect framework
|
|
29
|
+
const framework = detectFramework(projectDir, packageJson);
|
|
30
|
+
const router = detectRouter(framework, projectDir);
|
|
31
|
+
|
|
32
|
+
// Determine package manager
|
|
33
|
+
const packageManager = detectPackageManager(projectDir);
|
|
34
|
+
|
|
35
|
+
// Extract scripts
|
|
36
|
+
const scripts = {
|
|
37
|
+
dev: packageJson?.scripts?.dev || null,
|
|
38
|
+
build: packageJson?.scripts?.build || null,
|
|
39
|
+
start: packageJson?.scripts?.start || null,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
framework,
|
|
44
|
+
router,
|
|
45
|
+
sourceRoot: projectDir,
|
|
46
|
+
packageManager,
|
|
47
|
+
scripts,
|
|
48
|
+
detectedAt: new Date().toISOString(),
|
|
49
|
+
packageJsonPath,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Find the nearest package.json by walking up directories
|
|
55
|
+
*/
|
|
56
|
+
function findPackageJson(startPath) {
|
|
57
|
+
let currentPath = resolve(startPath);
|
|
58
|
+
|
|
59
|
+
// First check if package.json exists in startPath itself
|
|
60
|
+
const immediatePackage = resolve(currentPath, 'package.json');
|
|
61
|
+
if (existsSync(immediatePackage)) {
|
|
62
|
+
return immediatePackage;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Then walk up (limit to 5 levels for monorepos, not 10)
|
|
66
|
+
for (let i = 0; i < 5; i++) {
|
|
67
|
+
const parentPath = dirname(currentPath);
|
|
68
|
+
if (parentPath === currentPath) {
|
|
69
|
+
// Reached filesystem root
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
currentPath = parentPath;
|
|
74
|
+
const packageJsonPath = resolve(currentPath, 'package.json');
|
|
75
|
+
if (existsSync(packageJsonPath)) {
|
|
76
|
+
return packageJsonPath;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Detect the framework type
|
|
85
|
+
*/
|
|
86
|
+
function detectFramework(projectDir, packageJson) {
|
|
87
|
+
// Check for Next.js
|
|
88
|
+
if (hasNextJs(projectDir, packageJson)) {
|
|
89
|
+
return 'nextjs';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check for Vite + React
|
|
93
|
+
if (hasViteReact(projectDir, packageJson)) {
|
|
94
|
+
return 'react-vite';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Check for Create React App
|
|
98
|
+
if (hasCreateReactApp(packageJson)) {
|
|
99
|
+
return 'react-cra';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Check for static HTML
|
|
103
|
+
if (hasStaticHtml(projectDir)) {
|
|
104
|
+
return 'static-html';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Unknown framework
|
|
108
|
+
return 'unknown';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Detect Next.js
|
|
113
|
+
*/
|
|
114
|
+
function hasNextJs(projectDir, packageJson) {
|
|
115
|
+
// Check for next.config.js or next.config.mjs
|
|
116
|
+
const hasNextConfig = existsSync(resolve(projectDir, 'next.config.js')) ||
|
|
117
|
+
existsSync(resolve(projectDir, 'next.config.mjs')) ||
|
|
118
|
+
existsSync(resolve(projectDir, 'next.config.ts'));
|
|
119
|
+
|
|
120
|
+
if (hasNextConfig) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Check for 'next' dependency
|
|
125
|
+
if (packageJson?.dependencies?.next || packageJson?.devDependencies?.next) {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Detect router type for Next.js
|
|
134
|
+
*/
|
|
135
|
+
function detectRouter(framework, projectDir) {
|
|
136
|
+
if (framework !== 'nextjs') {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Check for /app directory (app router) - must contain actual files
|
|
141
|
+
const appPath = resolve(projectDir, 'app');
|
|
142
|
+
if (existsSync(appPath) && hasRouteFiles(appPath)) {
|
|
143
|
+
return 'app';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Check for /pages directory (pages router)
|
|
147
|
+
if (existsSync(resolve(projectDir, 'pages'))) {
|
|
148
|
+
return 'pages';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Check if a directory contains route files (not just an empty scaffold)
|
|
156
|
+
*/
|
|
157
|
+
function hasRouteFiles(dirPath) {
|
|
158
|
+
try {
|
|
159
|
+
const entries = readdirSync(dirPath);
|
|
160
|
+
return entries.some(entry => {
|
|
161
|
+
// Look for .js, .ts, .jsx, .tsx files (not just directories)
|
|
162
|
+
return /\.(js|ts|jsx|tsx)$/.test(entry);
|
|
163
|
+
});
|
|
164
|
+
} catch (error) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Detect Vite + React
|
|
171
|
+
*/
|
|
172
|
+
function hasViteReact(projectDir, packageJson) {
|
|
173
|
+
const hasViteConfig = existsSync(resolve(projectDir, 'vite.config.js')) ||
|
|
174
|
+
existsSync(resolve(projectDir, 'vite.config.ts')) ||
|
|
175
|
+
existsSync(resolve(projectDir, 'vite.config.mjs'));
|
|
176
|
+
|
|
177
|
+
if (!hasViteConfig) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Check for react dependency
|
|
182
|
+
if (packageJson?.dependencies?.react || packageJson?.devDependencies?.react) {
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Detect Create React App
|
|
191
|
+
*/
|
|
192
|
+
function hasCreateReactApp(packageJson) {
|
|
193
|
+
return !!(packageJson?.dependencies?.['react-scripts'] ||
|
|
194
|
+
packageJson?.devDependencies?.['react-scripts']);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Detect static HTML (no framework)
|
|
199
|
+
*/
|
|
200
|
+
function hasStaticHtml(projectDir) {
|
|
201
|
+
return existsSync(resolve(projectDir, 'index.html'));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Detect package manager
|
|
206
|
+
*/
|
|
207
|
+
function detectPackageManager(projectDir) {
|
|
208
|
+
// Check for pnpm-lock.yaml
|
|
209
|
+
if (existsSync(resolve(projectDir, 'pnpm-lock.yaml'))) {
|
|
210
|
+
return 'pnpm';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Check for yarn.lock
|
|
214
|
+
if (existsSync(resolve(projectDir, 'yarn.lock'))) {
|
|
215
|
+
return 'yarn';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Check for package-lock.json (npm)
|
|
219
|
+
if (existsSync(resolve(projectDir, 'package-lock.json'))) {
|
|
220
|
+
return 'npm';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Default to npm if none found but package.json exists
|
|
224
|
+
if (existsSync(resolve(projectDir, 'package.json'))) {
|
|
225
|
+
return 'npm';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return 'unknown';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get human-readable framework name
|
|
233
|
+
*/
|
|
234
|
+
export function getFrameworkDisplayName(framework, router) {
|
|
235
|
+
if (framework === 'nextjs') {
|
|
236
|
+
const routerType = router === 'app' ? 'app router' : router === 'pages' ? 'pages router' : 'unknown router';
|
|
237
|
+
return `Next.js (${routerType})`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (framework === 'react-vite') {
|
|
241
|
+
return 'Vite + React';
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (framework === 'react-cra') {
|
|
245
|
+
return 'Create React App';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (framework === 'static-html') {
|
|
249
|
+
return 'Static HTML';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return 'Unknown';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Extract probable port from dev script
|
|
257
|
+
*/
|
|
258
|
+
export function extractPortFromScript(script) {
|
|
259
|
+
if (!script) return null;
|
|
260
|
+
|
|
261
|
+
// Common patterns:
|
|
262
|
+
// - --port 3000
|
|
263
|
+
// - -p 3000
|
|
264
|
+
// - PORT=3000
|
|
265
|
+
|
|
266
|
+
const portMatch = script.match(/(?:--port|-p)\s+(\d+)/);
|
|
267
|
+
if (portMatch) {
|
|
268
|
+
return parseInt(portMatch[1], 10);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const portEnvMatch = script.match(/PORT=(\d+)/);
|
|
272
|
+
if (portEnvMatch) {
|
|
273
|
+
return parseInt(portEnvMatch[1], 10);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return null;
|
|
277
|
+
}
|