@veraxhq/verax 0.1.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/LICENSE +21 -0
- package/README.md +237 -0
- package/bin/verax.js +452 -0
- package/package.json +57 -0
- package/src/verax/detect/comparison.js +69 -0
- package/src/verax/detect/confidence-engine.js +498 -0
- package/src/verax/detect/evidence-validator.js +33 -0
- package/src/verax/detect/expectation-model.js +204 -0
- package/src/verax/detect/findings-writer.js +31 -0
- package/src/verax/detect/index.js +397 -0
- package/src/verax/detect/skip-classifier.js +202 -0
- package/src/verax/flow/flow-engine.js +265 -0
- package/src/verax/flow/flow-spec.js +145 -0
- package/src/verax/flow/redaction.js +74 -0
- package/src/verax/index.js +97 -0
- package/src/verax/learn/action-contract-extractor.js +281 -0
- package/src/verax/learn/ast-contract-extractor.js +255 -0
- package/src/verax/learn/index.js +18 -0
- package/src/verax/learn/manifest-writer.js +97 -0
- package/src/verax/learn/project-detector.js +87 -0
- package/src/verax/learn/react-router-extractor.js +73 -0
- package/src/verax/learn/route-extractor.js +122 -0
- package/src/verax/learn/route-validator.js +215 -0
- package/src/verax/learn/source-instrumenter.js +214 -0
- package/src/verax/learn/static-extractor.js +222 -0
- package/src/verax/learn/truth-assessor.js +96 -0
- package/src/verax/learn/ts-contract-resolver.js +395 -0
- package/src/verax/observe/browser.js +22 -0
- package/src/verax/observe/console-sensor.js +166 -0
- package/src/verax/observe/dom-signature.js +23 -0
- package/src/verax/observe/domain-boundary.js +38 -0
- package/src/verax/observe/evidence-capture.js +5 -0
- package/src/verax/observe/human-driver.js +376 -0
- package/src/verax/observe/index.js +67 -0
- package/src/verax/observe/interaction-discovery.js +269 -0
- package/src/verax/observe/interaction-runner.js +410 -0
- package/src/verax/observe/network-sensor.js +173 -0
- package/src/verax/observe/selector-generator.js +74 -0
- package/src/verax/observe/settle.js +155 -0
- package/src/verax/observe/state-ui-sensor.js +200 -0
- package/src/verax/observe/traces-writer.js +82 -0
- package/src/verax/observe/ui-signal-sensor.js +197 -0
- package/src/verax/resolve-workspace-root.js +173 -0
- package/src/verax/scan-summary-writer.js +41 -0
- package/src/verax/shared/artifact-manager.js +139 -0
- package/src/verax/shared/caching.js +104 -0
- package/src/verax/shared/expectation-proof.js +4 -0
- package/src/verax/shared/redaction.js +227 -0
- package/src/verax/shared/retry-policy.js +89 -0
- package/src/verax/shared/timing-metrics.js +44 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAVE 3: UI Signal Sensor
|
|
3
|
+
* Detects user-visible feedback signals: loading states, dialogs, error messages
|
|
4
|
+
* Conservative: only count signals with accessibility semantics or explicit attributes
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export class UISignalSensor {
|
|
8
|
+
/**
|
|
9
|
+
* Snapshot current UI signals on the page.
|
|
10
|
+
* Returns: { hasLoadingIndicator, hasDialog, buttonStateChanged, errorSignals, explanation }
|
|
11
|
+
*/
|
|
12
|
+
async snapshot(page) {
|
|
13
|
+
const signals = await page.evaluate(() => {
|
|
14
|
+
const result = {
|
|
15
|
+
hasLoadingIndicator: false,
|
|
16
|
+
hasDialog: false,
|
|
17
|
+
hasErrorSignal: false,
|
|
18
|
+
hasStatusSignal: false,
|
|
19
|
+
hasLiveRegion: false,
|
|
20
|
+
disabledElements: [],
|
|
21
|
+
explanation: []
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Check for loading indicators with accessibility semantics
|
|
25
|
+
// aria-busy="true"
|
|
26
|
+
const ariaBusy = document.querySelector('[aria-busy="true"]');
|
|
27
|
+
if (ariaBusy) {
|
|
28
|
+
result.hasLoadingIndicator = true;
|
|
29
|
+
result.explanation.push('Found [aria-busy="true"]');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// [data-loading] or [aria-label] with "loading" text
|
|
33
|
+
const dataLoading = document.querySelector('[data-loading]');
|
|
34
|
+
if (dataLoading) {
|
|
35
|
+
result.hasLoadingIndicator = true;
|
|
36
|
+
result.explanation.push('Found [data-loading]');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// role=status or role=alert with aria-live
|
|
40
|
+
const statusRegions = document.querySelectorAll('[role="status"], [role="alert"]');
|
|
41
|
+
if (statusRegions.length > 0) {
|
|
42
|
+
result.hasStatusSignal = true;
|
|
43
|
+
result.explanation.push(`Found ${statusRegions.length} status/alert region(s)`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// aria-live region
|
|
47
|
+
const liveRegions = document.querySelectorAll('[aria-live]');
|
|
48
|
+
if (liveRegions.length > 0) {
|
|
49
|
+
result.hasLiveRegion = true;
|
|
50
|
+
result.explanation.push(`Found ${liveRegions.length} aria-live region(s)`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check for dialogs
|
|
54
|
+
const dialog = document.querySelector('[role="dialog"], [aria-modal="true"]');
|
|
55
|
+
if (dialog && dialog.offsetParent !== null) {
|
|
56
|
+
// offsetParent is null if element is hidden
|
|
57
|
+
result.hasDialog = true;
|
|
58
|
+
result.explanation.push('Found dialog/modal');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check for disabled/loading buttons
|
|
62
|
+
const disabledButtons = document.querySelectorAll('button[disabled], button[aria-busy="true"]');
|
|
63
|
+
disabledButtons.forEach((btn) => {
|
|
64
|
+
result.disabledElements.push({
|
|
65
|
+
type: 'button',
|
|
66
|
+
text: (btn.textContent || '').trim().slice(0, 50),
|
|
67
|
+
attributes: {
|
|
68
|
+
disabled: btn.hasAttribute('disabled'),
|
|
69
|
+
ariaBusy: btn.getAttribute('aria-busy'),
|
|
70
|
+
class: btn.className.slice(0, 100)
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (result.disabledElements.length > 0) {
|
|
76
|
+
result.explanation.push(
|
|
77
|
+
`Found ${result.disabledElements.length} disabled/loading button(s)`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Check for error signals: aria-invalid visible elements
|
|
82
|
+
const invalidElements = document.querySelectorAll('[aria-invalid="true"]');
|
|
83
|
+
if (invalidElements.length > 0) {
|
|
84
|
+
result.hasErrorSignal = true;
|
|
85
|
+
result.explanation.push(`Found ${invalidElements.length} invalid element(s)`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Check for common error message patterns with accessibility attributes
|
|
89
|
+
const errorMessages = document.querySelectorAll(
|
|
90
|
+
'[role="alert"], [class*="error"], [class*="danger"]'
|
|
91
|
+
);
|
|
92
|
+
if (errorMessages.length > 0) {
|
|
93
|
+
for (const elem of errorMessages) {
|
|
94
|
+
const text = elem.textContent.trim().slice(0, 50);
|
|
95
|
+
if (text && (text.toLowerCase().includes('error') || text.toLowerCase().includes('fail'))) {
|
|
96
|
+
result.hasErrorSignal = true;
|
|
97
|
+
result.explanation.push(`Found error message: "${text}"`);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return result;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return signals;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Compute diff between two snapshots.
|
|
111
|
+
* Returns: { changed: boolean, explanation: string[], summary: {...} }
|
|
112
|
+
*/
|
|
113
|
+
diff(before, after) {
|
|
114
|
+
const result = {
|
|
115
|
+
changed: false,
|
|
116
|
+
explanation: [],
|
|
117
|
+
summary: {
|
|
118
|
+
loadingStateChanged: before.hasLoadingIndicator !== after.hasLoadingIndicator,
|
|
119
|
+
dialogStateChanged: before.hasDialog !== after.hasDialog,
|
|
120
|
+
errorSignalChanged: before.hasErrorSignal !== after.hasErrorSignal,
|
|
121
|
+
statusSignalChanged: before.hasStatusSignal !== after.hasStatusSignal,
|
|
122
|
+
liveRegionStateChanged: before.hasLiveRegion !== after.hasLiveRegion,
|
|
123
|
+
disabledButtonsChanged: before.disabledElements.length !== after.disabledElements.length
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Check what changed
|
|
128
|
+
if (result.summary.loadingStateChanged) {
|
|
129
|
+
result.changed = true;
|
|
130
|
+
result.explanation.push(
|
|
131
|
+
`Loading indicator: ${before.hasLoadingIndicator} → ${after.hasLoadingIndicator}`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (result.summary.dialogStateChanged) {
|
|
136
|
+
result.changed = true;
|
|
137
|
+
result.explanation.push(
|
|
138
|
+
`Dialog present: ${before.hasDialog} → ${after.hasDialog}`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (result.summary.errorSignalChanged) {
|
|
143
|
+
result.changed = true;
|
|
144
|
+
result.explanation.push(
|
|
145
|
+
`Error signal: ${before.hasErrorSignal} → ${after.hasErrorSignal}`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (result.summary.statusSignalChanged) {
|
|
150
|
+
result.changed = true;
|
|
151
|
+
result.explanation.push(
|
|
152
|
+
`Status signal: ${before.hasStatusSignal} → ${after.hasStatusSignal}`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (result.summary.liveRegionStateChanged) {
|
|
157
|
+
result.changed = true;
|
|
158
|
+
result.explanation.push(
|
|
159
|
+
`Live region: ${before.hasLiveRegion} → ${after.hasLiveRegion}`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (result.summary.disabledButtonsChanged) {
|
|
164
|
+
result.changed = true;
|
|
165
|
+
result.explanation.push(
|
|
166
|
+
`Disabled buttons: ${before.disabledElements.length} → ${after.disabledElements.length}`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Check if any feedback signal is present.
|
|
175
|
+
*/
|
|
176
|
+
hasAnyFeedback(signals) {
|
|
177
|
+
return (
|
|
178
|
+
signals.hasLoadingIndicator ||
|
|
179
|
+
signals.hasDialog ||
|
|
180
|
+
signals.hasErrorSignal ||
|
|
181
|
+
signals.hasStatusSignal ||
|
|
182
|
+
signals.hasLiveRegion ||
|
|
183
|
+
signals.disabledElements.length > 0
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Check if any error/failure feedback is present.
|
|
189
|
+
*/
|
|
190
|
+
hasErrorFeedback(signals) {
|
|
191
|
+
return (
|
|
192
|
+
signals.hasErrorSignal ||
|
|
193
|
+
signals.hasDialog || // Dialog might be error confirmation
|
|
194
|
+
(signals.hasStatusSignal && signals.explanation.some((ex) => ex.includes('error')))
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { dirname, join, resolve } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = dirname(__filename);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Determines if a path looks like the verax repository root
|
|
10
|
+
* (contains src/verax directory)
|
|
11
|
+
*/
|
|
12
|
+
function isVeraxRepoRoot(dir) {
|
|
13
|
+
try {
|
|
14
|
+
const veraxPath = join(dir, 'src', 'verax');
|
|
15
|
+
const indexPath = join(veraxPath, 'index.js');
|
|
16
|
+
return existsSync(indexPath);
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Detects project type by looking for marker files/directories
|
|
24
|
+
*/
|
|
25
|
+
function detectProjectMarker(dir) {
|
|
26
|
+
const markers = [
|
|
27
|
+
{ file: 'package.json', check: (content) => {
|
|
28
|
+
try {
|
|
29
|
+
const pkg = JSON.parse(content);
|
|
30
|
+
if (pkg.private === false && pkg.homepage) return true;
|
|
31
|
+
if (pkg.scripts && pkg.scripts.start && (pkg.dependencies?.react || pkg.dependencies?.next)) return true;
|
|
32
|
+
if (pkg.scripts && pkg.scripts.dev) return true;
|
|
33
|
+
return !!pkg.name && pkg.name !== '@verax/verax';
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}},
|
|
38
|
+
{ file: 'index.html', check: () => true },
|
|
39
|
+
{ file: 'next.config.js', check: () => true },
|
|
40
|
+
{ file: 'next.config.mjs', check: () => true },
|
|
41
|
+
{ file: 'next.config.ts', check: () => true },
|
|
42
|
+
{ file: 'vite.config.js', check: () => true },
|
|
43
|
+
{ file: 'vite.config.ts', check: () => true },
|
|
44
|
+
{ file: 'app.js', check: () => true },
|
|
45
|
+
{ file: 'App.tsx', check: () => true },
|
|
46
|
+
{ file: 'App.jsx', check: () => true },
|
|
47
|
+
{ dir: 'src', check: () => true }
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
for (const marker of markers) {
|
|
51
|
+
const markerPath = join(dir, marker.file || marker.dir);
|
|
52
|
+
if (existsSync(markerPath)) {
|
|
53
|
+
if (marker.file) {
|
|
54
|
+
try {
|
|
55
|
+
const content = readFileSync(markerPath, 'utf-8');
|
|
56
|
+
if (marker.check(content)) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// Continue if we can't read the file
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
// Directory marker
|
|
65
|
+
if (marker.check()) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolves the workspace root (project directory) for verax scanning.
|
|
76
|
+
*
|
|
77
|
+
* Priority:
|
|
78
|
+
* 1. If --project-dir is provided → use it (absolute path)
|
|
79
|
+
* 2. Else → auto-detect nearest parent directory containing project markers
|
|
80
|
+
* 3. If no project marker found and cwd is not repo root → return cwd
|
|
81
|
+
* 4. If no project marker found and cwd IS repo root → raise error
|
|
82
|
+
*
|
|
83
|
+
* @param {string} projectDirArg - The value of --project-dir if provided
|
|
84
|
+
* @param {string} currentWorkingDir - Current working directory (defaults to process.cwd())
|
|
85
|
+
* @returns {object} { workspaceRoot, autoDetected, isRepoRoot }
|
|
86
|
+
* @throws {Error} If unable to resolve a valid workspace root
|
|
87
|
+
*/
|
|
88
|
+
export function resolveWorkspaceRoot(projectDirArg = null, currentWorkingDir = process.cwd()) {
|
|
89
|
+
const cwd = resolve(currentWorkingDir);
|
|
90
|
+
|
|
91
|
+
// 1. If --project-dir is provided, use it
|
|
92
|
+
if (projectDirArg) {
|
|
93
|
+
const projectDir = resolve(projectDirArg);
|
|
94
|
+
if (!existsSync(projectDir)) {
|
|
95
|
+
throw new Error(`Project directory does not exist: ${projectDir}`);
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
workspaceRoot: projectDir,
|
|
99
|
+
autoDetected: false,
|
|
100
|
+
isRepoRoot: isVeraxRepoRoot(projectDir)
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2. Auto-detect: search upward from cwd for project markers
|
|
105
|
+
let searchDir = cwd;
|
|
106
|
+
const repoRootPath = isVeraxRepoRoot(cwd) ? cwd : null;
|
|
107
|
+
|
|
108
|
+
// Search upward from cwd, but stop at repo root if we find it
|
|
109
|
+
while (searchDir !== dirname(searchDir)) {
|
|
110
|
+
// If we detect a project marker, use this directory
|
|
111
|
+
if (detectProjectMarker(searchDir)) {
|
|
112
|
+
const isRepoRoot = isVeraxRepoRoot(searchDir);
|
|
113
|
+
|
|
114
|
+
// CRITICAL GUARD: If this is the repo root AND we found a marker (e.g., shared test artifact),
|
|
115
|
+
// refuse to use repo root as workspace root
|
|
116
|
+
if (isRepoRoot && searchDir === repoRootPath) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
'verax verax: Refusing to write artifacts in repository root. ' +
|
|
119
|
+
'Use --project-dir to specify the target project directory.'
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
workspaceRoot: searchDir,
|
|
125
|
+
autoDetected: true,
|
|
126
|
+
isRepoRoot: false
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
searchDir = dirname(searchDir);
|
|
131
|
+
|
|
132
|
+
// If we hit the repo root during search, stop (don't use repo root implicitly)
|
|
133
|
+
if (isVeraxRepoRoot(searchDir) && searchDir !== cwd) {
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 3. Fallback: if we found repo root in upward search, refuse it
|
|
139
|
+
if (isVeraxRepoRoot(cwd)) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
'verax verax: Refusing to write artifacts in repository root. ' +
|
|
142
|
+
'Use --project-dir to specify the target project directory.'
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 4. Use cwd if no markers found (last resort for edge cases)
|
|
147
|
+
return {
|
|
148
|
+
workspaceRoot: cwd,
|
|
149
|
+
autoDetected: false,
|
|
150
|
+
isRepoRoot: false
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Validates that an artifact path is within the workspace root
|
|
156
|
+
* @throws {Error} If artifact path is outside workspace root
|
|
157
|
+
*/
|
|
158
|
+
export function assertArtifactPathInWorkspace(artifactPath, workspaceRoot) {
|
|
159
|
+
const resolvedArtifact = resolve(artifactPath);
|
|
160
|
+
const resolvedWorkspace = resolve(workspaceRoot);
|
|
161
|
+
|
|
162
|
+
// Normalize paths for comparison
|
|
163
|
+
const artifactNorm = resolvedArtifact.replace(/\\/g, '/');
|
|
164
|
+
const workspaceNorm = resolvedWorkspace.replace(/\\/g, '/');
|
|
165
|
+
|
|
166
|
+
if (!artifactNorm.startsWith(workspaceNorm + '/') && artifactNorm !== workspaceNorm) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`verax verax: Artifact path outside workspace root.\n` +
|
|
169
|
+
`Workspace: ${workspaceRoot}\n` +
|
|
170
|
+
`Artifact: ${artifactPath}`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { resolve } from 'path';
|
|
2
|
+
import { writeFileSync, mkdirSync } from 'fs';
|
|
3
|
+
|
|
4
|
+
export function writeScanSummary(projectDir, url, projectType, learnTruth, observeTruth, detectTruth, manifestPath, tracesPath, findingsPath, artifactPaths = null) {
|
|
5
|
+
const summary = {
|
|
6
|
+
version: 1,
|
|
7
|
+
scannedAt: new Date().toISOString(),
|
|
8
|
+
url: url,
|
|
9
|
+
projectType: projectType,
|
|
10
|
+
truth: {
|
|
11
|
+
learn: learnTruth,
|
|
12
|
+
observe: observeTruth,
|
|
13
|
+
detect: detectTruth
|
|
14
|
+
},
|
|
15
|
+
paths: {
|
|
16
|
+
manifest: manifestPath,
|
|
17
|
+
traces: tracesPath,
|
|
18
|
+
findings: findingsPath
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
let summaryPath;
|
|
23
|
+
if (artifactPaths) {
|
|
24
|
+
// Use new artifact structure
|
|
25
|
+
summaryPath = artifactPaths.summary;
|
|
26
|
+
// Write the scan summary with truth data
|
|
27
|
+
writeFileSync(summaryPath, JSON.stringify(summary, null, 2) + '\n');
|
|
28
|
+
} else {
|
|
29
|
+
// Legacy structure
|
|
30
|
+
const scanDir = resolve(projectDir, '.veraxverax', 'scan');
|
|
31
|
+
mkdirSync(scanDir, { recursive: true });
|
|
32
|
+
summaryPath = resolve(scanDir, 'scan-summary.json');
|
|
33
|
+
writeFileSync(summaryPath, JSON.stringify(summary, null, 2) + '\n');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
...summary,
|
|
38
|
+
summaryPath: summaryPath
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wave 9 — Artifact Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages the new artifact directory structure:
|
|
5
|
+
* .verax/runs/<runId>/
|
|
6
|
+
* - summary.json (overall scan results, metrics)
|
|
7
|
+
* - findings.json (all findings)
|
|
8
|
+
* - traces.jsonl (one JSON per line, per interaction)
|
|
9
|
+
* - evidence/ (screenshots, network logs, etc.)
|
|
10
|
+
* - flows/ (flow diagrams if enabled)
|
|
11
|
+
*
|
|
12
|
+
* All artifacts are redacted before writing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, mkdirSync, writeFileSync, appendFileSync } from 'fs';
|
|
16
|
+
import { resolve, join } from 'path';
|
|
17
|
+
import { randomBytes } from 'crypto';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generate a unique run ID.
|
|
21
|
+
* @returns {string} - 8-character hex ID
|
|
22
|
+
*/
|
|
23
|
+
export function generateRunId() {
|
|
24
|
+
return randomBytes(4).toString('hex');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create artifact directory structure.
|
|
29
|
+
* @param {string} projectRoot - Project root directory
|
|
30
|
+
* @param {string} runId - Run identifier
|
|
31
|
+
* @returns {Object} - Paths to each artifact location
|
|
32
|
+
*/
|
|
33
|
+
export function initArtifactPaths(projectRoot, runId = null) {
|
|
34
|
+
const id = runId || generateRunId();
|
|
35
|
+
const runDir = resolve(projectRoot, '.verax', 'runs', id);
|
|
36
|
+
|
|
37
|
+
const paths = {
|
|
38
|
+
runId: id,
|
|
39
|
+
runDir,
|
|
40
|
+
summary: resolve(runDir, 'summary.json'),
|
|
41
|
+
findings: resolve(runDir, 'findings.json'),
|
|
42
|
+
traces: resolve(runDir, 'traces.jsonl'),
|
|
43
|
+
evidence: resolve(runDir, 'evidence'),
|
|
44
|
+
flows: resolve(runDir, 'flows'),
|
|
45
|
+
artifacts: resolve(projectRoot, '.verax', 'artifacts') // Legacy compat
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Create directories
|
|
49
|
+
[runDir, paths.evidence, paths.flows].forEach(dir => {
|
|
50
|
+
if (!existsSync(dir)) {
|
|
51
|
+
mkdirSync(dir, { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return paths;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Write summary.json with metadata and metrics.
|
|
60
|
+
* @param {Object} paths - Artifact paths from initArtifactPaths
|
|
61
|
+
* @param {Object} summary - Summary data { url, duration, findings, metrics }
|
|
62
|
+
*/
|
|
63
|
+
export function writeSummary(paths, summary) {
|
|
64
|
+
const data = {
|
|
65
|
+
runId: paths.runId,
|
|
66
|
+
timestamp: new Date().toISOString(),
|
|
67
|
+
url: summary.url,
|
|
68
|
+
projectRoot: summary.projectRoot,
|
|
69
|
+
metrics: summary.metrics || {
|
|
70
|
+
parseMs: 0,
|
|
71
|
+
resolveMs: 0,
|
|
72
|
+
observeMs: 0,
|
|
73
|
+
detectMs: 0,
|
|
74
|
+
totalMs: 0
|
|
75
|
+
},
|
|
76
|
+
findingsCounts: summary.findingsCounts || {
|
|
77
|
+
HIGH: 0,
|
|
78
|
+
MEDIUM: 0,
|
|
79
|
+
LOW: 0,
|
|
80
|
+
UNKNOWN: 0
|
|
81
|
+
},
|
|
82
|
+
topFindings: summary.topFindings || [],
|
|
83
|
+
cacheStats: summary.cacheStats || {}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
writeFileSync(paths.summary, JSON.stringify(data, null, 2) + '\n');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Write findings.json with all detected issues.
|
|
91
|
+
* @param {Object} paths - Artifact paths
|
|
92
|
+
* @param {Array} findings - Array of finding objects
|
|
93
|
+
*/
|
|
94
|
+
export function writeFindings(paths, findings) {
|
|
95
|
+
const data = {
|
|
96
|
+
runId: paths.runId,
|
|
97
|
+
timestamp: new Date().toISOString(),
|
|
98
|
+
total: findings.length,
|
|
99
|
+
findings
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
writeFileSync(paths.findings, JSON.stringify(data, null, 2) + '\n');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Append a trace to traces.jsonl (one JSON object per line).
|
|
107
|
+
* @param {Object} paths - Artifact paths
|
|
108
|
+
* @param {Object} trace - Trace object to append
|
|
109
|
+
*/
|
|
110
|
+
export function appendTrace(paths, trace) {
|
|
111
|
+
const line = JSON.stringify(trace) + '\n';
|
|
112
|
+
appendFileSync(paths.traces, line);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Write evidence file (screenshot, network log, etc.).
|
|
117
|
+
* @param {Object} paths - Artifact paths
|
|
118
|
+
* @param {string} filename - Evidence filename
|
|
119
|
+
* @param {*} data - Data to write (string or buffer)
|
|
120
|
+
*/
|
|
121
|
+
export function writeEvidence(paths, filename, data) {
|
|
122
|
+
const filepath = resolve(paths.evidence, filename);
|
|
123
|
+
|
|
124
|
+
if (typeof data === 'string') {
|
|
125
|
+
writeFileSync(filepath, data);
|
|
126
|
+
} else {
|
|
127
|
+
writeFileSync(filepath, data);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get all artifact paths for a given run.
|
|
133
|
+
* @param {string} projectRoot - Project root
|
|
134
|
+
* @param {string} runId - Run identifier
|
|
135
|
+
* @returns {Object} - Paths object
|
|
136
|
+
*/
|
|
137
|
+
export function getArtifactPaths(projectRoot, runId) {
|
|
138
|
+
return initArtifactPaths(projectRoot, runId);
|
|
139
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wave 9 — Performance Caching Layer
|
|
3
|
+
*
|
|
4
|
+
* Provides in-memory and optional disk caching for TS Program, symbol resolution,
|
|
5
|
+
* and AST extraction results. Deterministic cache keys based on file content hashes.
|
|
6
|
+
*
|
|
7
|
+
* Cache is keyed by: projectRoot + tsconfig path + file mtimes hash
|
|
8
|
+
* Entries are computed only once per unique key within a single run.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHash } from 'crypto';
|
|
12
|
+
import { readFileSync, existsSync, statSync } from 'fs';
|
|
13
|
+
import { resolve, join } from 'path';
|
|
14
|
+
|
|
15
|
+
const memoryCache = new Map(); // Global in-memory cache
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Compute a deterministic cache key from project root, tsconfig, and source files.
|
|
19
|
+
* @param {string} projectRoot - Project root directory
|
|
20
|
+
* @param {string} tsconfigPath - Path to tsconfig.json (optional)
|
|
21
|
+
* @param {Array<string>} sourceFiles - Array of source file paths
|
|
22
|
+
* @returns {string} - Deterministic cache key
|
|
23
|
+
*/
|
|
24
|
+
export function computeCacheKey(projectRoot, tsconfigPath, sourceFiles = []) {
|
|
25
|
+
const hash = createHash('sha256');
|
|
26
|
+
|
|
27
|
+
// Hash the project root
|
|
28
|
+
hash.update(projectRoot);
|
|
29
|
+
|
|
30
|
+
// Hash the tsconfig if present
|
|
31
|
+
if (tsconfigPath && existsSync(tsconfigPath)) {
|
|
32
|
+
try {
|
|
33
|
+
const tsconfigContent = readFileSync(tsconfigPath, 'utf-8');
|
|
34
|
+
hash.update(tsconfigContent);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
// If tsconfig can't be read, just use the path
|
|
37
|
+
hash.update(tsconfigPath);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Hash file modification times (more efficient than file content)
|
|
42
|
+
for (const filePath of sourceFiles) {
|
|
43
|
+
try {
|
|
44
|
+
if (existsSync(filePath)) {
|
|
45
|
+
const stats = statSync(filePath);
|
|
46
|
+
hash.update(filePath + ':' + stats.mtimeMs);
|
|
47
|
+
}
|
|
48
|
+
} catch (e) {
|
|
49
|
+
// Skip files that can't be stat'd
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return hash.digest('hex').substring(0, 16); // Use first 16 chars for brevity
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get a value from cache. Calls computeFn if not cached.
|
|
58
|
+
* @param {string} key - Cache key
|
|
59
|
+
* @param {Function} computeFn - Function to compute value if not cached
|
|
60
|
+
* @returns {*} - Cached or computed value
|
|
61
|
+
*/
|
|
62
|
+
export function getOrCompute(key, computeFn) {
|
|
63
|
+
if (memoryCache.has(key)) {
|
|
64
|
+
return memoryCache.get(key);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const value = computeFn();
|
|
68
|
+
memoryCache.set(key, value);
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Clear the in-memory cache (useful between test runs).
|
|
74
|
+
*/
|
|
75
|
+
export function clearCache() {
|
|
76
|
+
memoryCache.clear();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get cache statistics (for diagnostics).
|
|
81
|
+
* @returns {Object} - { size, hitRate, entries }
|
|
82
|
+
*/
|
|
83
|
+
export function getCacheStats() {
|
|
84
|
+
return {
|
|
85
|
+
size: memoryCache.size,
|
|
86
|
+
keys: Array.from(memoryCache.keys()).slice(0, 5) // First 5 keys for inspection
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Create a cache key specifically for TS Program resolution.
|
|
92
|
+
*/
|
|
93
|
+
export function getTSProgramCacheKey(rootDir, files) {
|
|
94
|
+
const tsconfigPath = resolve(rootDir, 'tsconfig.json');
|
|
95
|
+
return `ts-program:${computeCacheKey(rootDir, tsconfigPath, files)}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Create a cache key specifically for AST extraction.
|
|
100
|
+
*/
|
|
101
|
+
export function getASTCacheKey(projectDir, files) {
|
|
102
|
+
const tsconfigPath = resolve(projectDir, 'tsconfig.json');
|
|
103
|
+
return `ast:${computeCacheKey(projectDir, tsconfigPath, files)}`;
|
|
104
|
+
}
|