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