@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,97 @@
|
|
|
1
|
+
import { learn } from './learn/index.js';
|
|
2
|
+
import { observe } from './observe/index.js';
|
|
3
|
+
import { detect } from './detect/index.js';
|
|
4
|
+
import { writeScanSummary } from './scan-summary-writer.js';
|
|
5
|
+
import { validateRoutes } from './learn/route-validator.js';
|
|
6
|
+
import { initArtifactPaths, generateRunId } from './shared/artifact-manager.js';
|
|
7
|
+
|
|
8
|
+
export async function scan(projectDir, url, manifestPath = null, runId = null) {
|
|
9
|
+
// Generate runId for this scan if not provided
|
|
10
|
+
const scanRunId = runId || generateRunId();
|
|
11
|
+
const artifactPaths = initArtifactPaths(projectDir, scanRunId);
|
|
12
|
+
// If manifestPath is provided, read it first before learn() overwrites it
|
|
13
|
+
let loadedManifest = null;
|
|
14
|
+
if (manifestPath) {
|
|
15
|
+
const { readFileSync } = await import('fs');
|
|
16
|
+
try {
|
|
17
|
+
const manifestContent = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
18
|
+
loadedManifest = manifestContent;
|
|
19
|
+
} catch (e) {
|
|
20
|
+
// Fall through to learn if we can't read the manifest
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const learnedManifest = await learn(projectDir);
|
|
25
|
+
|
|
26
|
+
// Merge: prefer loaded manifest for routes, but use learned for project type and truth
|
|
27
|
+
let manifest;
|
|
28
|
+
if (loadedManifest) {
|
|
29
|
+
manifest = {
|
|
30
|
+
...learnedManifest,
|
|
31
|
+
projectType: loadedManifest.projectType || learnedManifest.projectType,
|
|
32
|
+
publicRoutes: loadedManifest.publicRoutes || learnedManifest.publicRoutes,
|
|
33
|
+
routes: loadedManifest.routes || learnedManifest.routes,
|
|
34
|
+
internalRoutes: loadedManifest.internalRoutes || learnedManifest.internalRoutes,
|
|
35
|
+
manifestPath: manifestPath
|
|
36
|
+
};
|
|
37
|
+
} else {
|
|
38
|
+
manifest = learnedManifest;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!url) {
|
|
42
|
+
return { manifest, observation: null, findings: null, scanSummary: null, validation: null };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// manifestForValidation uses the provided/merged manifest (which may have been modified)
|
|
46
|
+
const manifestForValidation = {
|
|
47
|
+
projectType: manifest.projectType,
|
|
48
|
+
publicRoutes: manifest.publicRoutes,
|
|
49
|
+
routes: manifest.routes,
|
|
50
|
+
manifestPath: manifest.manifestPath
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const validation = await validateRoutes(manifestForValidation, url);
|
|
54
|
+
|
|
55
|
+
if (validation.warnings && validation.warnings.length > 0) {
|
|
56
|
+
if (!manifest.learnTruth.warnings) {
|
|
57
|
+
manifest.learnTruth.warnings = [];
|
|
58
|
+
}
|
|
59
|
+
manifest.learnTruth.warnings.push(...validation.warnings);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const usedManifestPath = manifestPath || manifest.manifestPath;
|
|
63
|
+
const observation = await observe(url, usedManifestPath, artifactPaths);
|
|
64
|
+
|
|
65
|
+
// For detect, we need tracesPath - use observation.tracesPath if available, otherwise use artifact paths
|
|
66
|
+
let tracesPathForDetect = observation.tracesPath;
|
|
67
|
+
if (artifactPaths && !tracesPathForDetect) {
|
|
68
|
+
tracesPathForDetect = artifactPaths.traces;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const findings = await detect(usedManifestPath, tracesPathForDetect, validation, artifactPaths);
|
|
72
|
+
|
|
73
|
+
const learnTruthWithValidation = {
|
|
74
|
+
...manifest.learnTruth,
|
|
75
|
+
validation: validation
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const scanSummary = writeScanSummary(
|
|
79
|
+
projectDir,
|
|
80
|
+
url,
|
|
81
|
+
manifest.projectType,
|
|
82
|
+
learnTruthWithValidation,
|
|
83
|
+
observation.observeTruth,
|
|
84
|
+
findings.detectTruth,
|
|
85
|
+
manifest.manifestPath,
|
|
86
|
+
observation.tracesPath,
|
|
87
|
+
findings.findingsPath,
|
|
88
|
+
artifactPaths
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return { manifest, observation, findings, scanSummary, validation };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export { learn } from './learn/index.js';
|
|
95
|
+
export { observe } from './observe/index.js';
|
|
96
|
+
export { detect } from './detect/index.js';
|
|
97
|
+
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wave 5 — Action Contract Extractor
|
|
3
|
+
*
|
|
4
|
+
* Extracts PROVEN network action contracts from JSX/React source files.
|
|
5
|
+
* Uses AST analysis to find onClick/onSubmit handlers that call fetch/axios
|
|
6
|
+
* with static URL literals.
|
|
7
|
+
*
|
|
8
|
+
* NO HEURISTICS. Only static, deterministic analysis.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { parse } from '@babel/parser';
|
|
12
|
+
import traverse from '@babel/traverse';
|
|
13
|
+
import { readFileSync } from 'fs';
|
|
14
|
+
import { resolve, relative, sep } from 'path';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extract action contracts from a source file.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} filePath - Absolute path to source file
|
|
20
|
+
* @param {string} workspaceRoot - Workspace root for relative paths
|
|
21
|
+
* @returns {Array<Object>} - Array of contract objects
|
|
22
|
+
*/
|
|
23
|
+
export function extractActionContracts(filePath, workspaceRoot) {
|
|
24
|
+
const contracts = [];
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const code = readFileSync(filePath, 'utf-8');
|
|
28
|
+
const ast = parse(code, {
|
|
29
|
+
sourceType: 'module',
|
|
30
|
+
plugins: ['jsx', 'typescript'],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Track function declarations and arrow function assignments
|
|
34
|
+
const functionBodies = new Map(); // name -> AST node
|
|
35
|
+
|
|
36
|
+
traverse.default(ast, {
|
|
37
|
+
// Track function declarations
|
|
38
|
+
FunctionDeclaration(path) {
|
|
39
|
+
if (path.node.id && path.node.id.name) {
|
|
40
|
+
functionBodies.set(path.node.id.name, path.node.body);
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
// Track arrow function variable assignments
|
|
45
|
+
VariableDeclarator(path) {
|
|
46
|
+
if (
|
|
47
|
+
path.node.id.type === 'Identifier' &&
|
|
48
|
+
path.node.init &&
|
|
49
|
+
path.node.init.type === 'ArrowFunctionExpression'
|
|
50
|
+
) {
|
|
51
|
+
functionBodies.set(path.node.id.name, path.node.init.body);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
// Find JSX elements with onClick or onSubmit
|
|
56
|
+
JSXAttribute(path) {
|
|
57
|
+
const attrName = path.node.name.name;
|
|
58
|
+
if (attrName !== 'onClick' && attrName !== 'onSubmit') {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const value = path.node.value;
|
|
63
|
+
if (!value) return;
|
|
64
|
+
|
|
65
|
+
// Only analyze inline arrow functions for now
|
|
66
|
+
// Case 1: Inline arrow function: onClick={() => fetch(...)}
|
|
67
|
+
if (
|
|
68
|
+
value.type === 'JSXExpressionContainer' &&
|
|
69
|
+
value.expression.type === 'ArrowFunctionExpression'
|
|
70
|
+
) {
|
|
71
|
+
const handlerBody = value.expression.body;
|
|
72
|
+
const networkCalls = findNetworkCallsInNode(handlerBody);
|
|
73
|
+
|
|
74
|
+
for (const call of networkCalls) {
|
|
75
|
+
const loc = path.node.loc;
|
|
76
|
+
const sourceRef = formatSourceRef(filePath, workspaceRoot, loc);
|
|
77
|
+
|
|
78
|
+
contracts.push({
|
|
79
|
+
kind: 'NETWORK_ACTION',
|
|
80
|
+
method: call.method,
|
|
81
|
+
urlPath: call.url,
|
|
82
|
+
source: sourceRef,
|
|
83
|
+
elementType: path.parent.name.name, // button, form, etc.
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Case 2: Function reference - analyze the function declaration
|
|
88
|
+
else if (
|
|
89
|
+
value.type === 'JSXExpressionContainer' &&
|
|
90
|
+
value.expression.type === 'Identifier'
|
|
91
|
+
) {
|
|
92
|
+
const refName = value.expression.name;
|
|
93
|
+
const handlerBody = functionBodies.get(refName);
|
|
94
|
+
|
|
95
|
+
if (handlerBody) {
|
|
96
|
+
const networkCalls = findNetworkCallsInNode(handlerBody);
|
|
97
|
+
|
|
98
|
+
for (const call of networkCalls) {
|
|
99
|
+
const loc = path.node.loc;
|
|
100
|
+
const sourceRef = formatSourceRef(filePath, workspaceRoot, loc);
|
|
101
|
+
|
|
102
|
+
contracts.push({
|
|
103
|
+
kind: 'NETWORK_ACTION',
|
|
104
|
+
method: call.method,
|
|
105
|
+
urlPath: call.url,
|
|
106
|
+
source: sourceRef,
|
|
107
|
+
elementType: path.parent.name.name,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
} catch (err) {
|
|
115
|
+
// Parse errors are not fatal; just return empty contracts
|
|
116
|
+
console.warn(`Failed to parse ${filePath}: ${err.message}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return contracts;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Find network calls (fetch, axios) in an AST node by recursively scanning.
|
|
124
|
+
* Only returns calls with static URL literals.
|
|
125
|
+
*
|
|
126
|
+
* @param {Object} node - AST node to scan
|
|
127
|
+
* @returns {Array<Object>} - Array of {method, url}
|
|
128
|
+
*/
|
|
129
|
+
function findNetworkCallsInNode(node) {
|
|
130
|
+
const calls = [];
|
|
131
|
+
|
|
132
|
+
// Recursive function to scan all nodes
|
|
133
|
+
function scan(n) {
|
|
134
|
+
if (!n || typeof n !== 'object') return;
|
|
135
|
+
|
|
136
|
+
// Check if this is a CallExpression
|
|
137
|
+
if (n.type === 'CallExpression') {
|
|
138
|
+
const callee = n.callee;
|
|
139
|
+
|
|
140
|
+
// Case 1: fetch(url, options)
|
|
141
|
+
if (callee.type === 'Identifier' && callee.name === 'fetch') {
|
|
142
|
+
const urlArg = n.arguments[0];
|
|
143
|
+
const optionsArg = n.arguments[1];
|
|
144
|
+
|
|
145
|
+
// Only accept static string literals
|
|
146
|
+
if (urlArg && urlArg.type === 'StringLiteral') {
|
|
147
|
+
let method = 'GET'; // default
|
|
148
|
+
|
|
149
|
+
// Try to extract method from options
|
|
150
|
+
if (optionsArg && optionsArg.type === 'ObjectExpression') {
|
|
151
|
+
const methodProp = optionsArg.properties.find(
|
|
152
|
+
(p) => p.key && p.key.name === 'method'
|
|
153
|
+
);
|
|
154
|
+
if (methodProp && methodProp.value.type === 'StringLiteral') {
|
|
155
|
+
method = methodProp.value.value.toUpperCase();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
calls.push({ method, url: urlArg.value });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Case 2: axios.get(url), axios.post(url, data), etc.
|
|
164
|
+
if (
|
|
165
|
+
callee.type === 'MemberExpression' &&
|
|
166
|
+
callee.object.type === 'Identifier' &&
|
|
167
|
+
callee.object.name === 'axios' &&
|
|
168
|
+
callee.property.type === 'Identifier'
|
|
169
|
+
) {
|
|
170
|
+
const methodName = callee.property.name.toUpperCase();
|
|
171
|
+
const urlArg = n.arguments[0];
|
|
172
|
+
|
|
173
|
+
if (urlArg && urlArg.type === 'StringLiteral') {
|
|
174
|
+
calls.push({ method: methodName, url: urlArg.value });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Case 3: XMLHttpRequest (optional, basic support)
|
|
179
|
+
if (
|
|
180
|
+
callee.type === 'MemberExpression' &&
|
|
181
|
+
callee.property.type === 'Identifier' &&
|
|
182
|
+
callee.property.name === 'open'
|
|
183
|
+
) {
|
|
184
|
+
// xhr.open(method, url)
|
|
185
|
+
const methodArg = n.arguments[0];
|
|
186
|
+
const urlArg = n.arguments[1];
|
|
187
|
+
|
|
188
|
+
if (
|
|
189
|
+
methodArg && methodArg.type === 'StringLiteral' &&
|
|
190
|
+
urlArg && urlArg.type === 'StringLiteral'
|
|
191
|
+
) {
|
|
192
|
+
calls.push({
|
|
193
|
+
method: methodArg.value.toUpperCase(),
|
|
194
|
+
url: urlArg.value,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Recursively scan all properties
|
|
201
|
+
for (const key in n) {
|
|
202
|
+
if (n.hasOwnProperty(key)) {
|
|
203
|
+
const value = n[key];
|
|
204
|
+
if (Array.isArray(value)) {
|
|
205
|
+
value.forEach(item => scan(item));
|
|
206
|
+
} else if (value && typeof value === 'object') {
|
|
207
|
+
scan(value);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
scan(node);
|
|
214
|
+
return calls;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Format source reference as "file:line:col"
|
|
219
|
+
* Normalizes Windows paths to use forward slashes.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} filePath - Absolute file path
|
|
222
|
+
* @param {string} workspaceRoot - Workspace root
|
|
223
|
+
* @param {Object} loc - Location object from AST
|
|
224
|
+
* @returns {string} - Formatted source reference
|
|
225
|
+
*/
|
|
226
|
+
function formatSourceRef(filePath, workspaceRoot, loc) {
|
|
227
|
+
let relPath = relative(workspaceRoot, filePath);
|
|
228
|
+
|
|
229
|
+
// Normalize to forward slashes
|
|
230
|
+
relPath = relPath.split(sep).join('/');
|
|
231
|
+
|
|
232
|
+
const line = loc.start.line;
|
|
233
|
+
const col = loc.start.column;
|
|
234
|
+
|
|
235
|
+
return `${relPath}:${line}:${col}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Scan a directory tree for source files and extract all contracts.
|
|
240
|
+
*
|
|
241
|
+
* @param {string} rootPath - Root directory to scan
|
|
242
|
+
* @param {string} workspaceRoot - Workspace root
|
|
243
|
+
* @returns {Array<Object>} - All contracts found
|
|
244
|
+
*/
|
|
245
|
+
export async function scanForContracts(rootPath, workspaceRoot) {
|
|
246
|
+
const contracts = [];
|
|
247
|
+
|
|
248
|
+
// For now, just scan known patterns
|
|
249
|
+
// In real implementation, would recurse through directories
|
|
250
|
+
const { readdirSync, statSync } = await import('fs');
|
|
251
|
+
const { join } = await import('path');
|
|
252
|
+
|
|
253
|
+
function walk(dir) {
|
|
254
|
+
try {
|
|
255
|
+
const entries = readdirSync(dir);
|
|
256
|
+
|
|
257
|
+
for (const entry of entries) {
|
|
258
|
+
const fullPath = join(dir, entry);
|
|
259
|
+
const stat = statSync(fullPath);
|
|
260
|
+
|
|
261
|
+
if (stat.isDirectory()) {
|
|
262
|
+
// Skip node_modules, .git, etc.
|
|
263
|
+
if (!entry.startsWith('.') && entry !== 'node_modules') {
|
|
264
|
+
walk(fullPath);
|
|
265
|
+
}
|
|
266
|
+
} else if (stat.isFile()) {
|
|
267
|
+
// Only process .js, .jsx, .ts, .tsx files
|
|
268
|
+
if (/\.(jsx?|tsx?)$/.test(entry)) {
|
|
269
|
+
const fileContracts = extractActionContracts(fullPath, workspaceRoot);
|
|
270
|
+
contracts.push(...fileContracts);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} catch (err) {
|
|
275
|
+
// Ignore errors (permission issues, etc.)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
walk(rootPath);
|
|
280
|
+
return contracts;
|
|
281
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { parse } from '@babel/parser';
|
|
2
|
+
import traverse from '@babel/traverse';
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
import { glob } from 'glob';
|
|
5
|
+
import { resolve } from 'path';
|
|
6
|
+
import { ExpectationProof } from '../shared/expectation-proof.js';
|
|
7
|
+
|
|
8
|
+
const MAX_FILES_TO_SCAN = 200;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Extracts static string value from JSX attribute or expression.
|
|
12
|
+
* Returns null if value is dynamic (variable, template with interpolation, etc.)
|
|
13
|
+
*
|
|
14
|
+
* Wave 1 - CODE TRUTH ENGINE: Only static literals are PROVEN.
|
|
15
|
+
*/
|
|
16
|
+
function extractStaticStringValue(node) {
|
|
17
|
+
if (!node) return null;
|
|
18
|
+
|
|
19
|
+
// Direct string literal: href="/about"
|
|
20
|
+
if (node.type === 'StringLiteral') {
|
|
21
|
+
return node.value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// JSX expression: href={'/about'} or href={`/about`}
|
|
25
|
+
if (node.type === 'JSXExpressionContainer') {
|
|
26
|
+
const expr = node.expression;
|
|
27
|
+
|
|
28
|
+
// String literal in expression: {'/about'}
|
|
29
|
+
if (expr.type === 'StringLiteral') {
|
|
30
|
+
return expr.value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Template literal WITHOUT interpolation: {`/about`}
|
|
34
|
+
if (expr.type === 'TemplateLiteral' && expr.expressions.length === 0) {
|
|
35
|
+
// Only if there are no ${...} expressions
|
|
36
|
+
if (expr.quasis.length === 1) {
|
|
37
|
+
return expr.quasis[0].value.cooked;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Anything else (variables, interpolated templates, etc.) is UNKNOWN
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Extracts PROVEN navigation contracts from JSX elements and imperative calls.
|
|
50
|
+
*
|
|
51
|
+
* Supported patterns (all require static string literals):
|
|
52
|
+
* - Next.js: <Link href="/about">
|
|
53
|
+
* - React Router: <Link to="/about"> or <NavLink to="/about">
|
|
54
|
+
* - Plain JSX: <a href="/about">
|
|
55
|
+
* - Imperative: navigate("/about"), router.push("/about")
|
|
56
|
+
*
|
|
57
|
+
* Returns array of contracts with:
|
|
58
|
+
* - kind: 'NAVIGATION'
|
|
59
|
+
* - targetPath: string
|
|
60
|
+
* - sourceFile: string
|
|
61
|
+
* - element: 'Link' | 'NavLink' | 'a' | 'navigate' | 'router'
|
|
62
|
+
* - attribute: 'href' | 'to' (for runtime matching)
|
|
63
|
+
* - proof: PROVEN_EXPECTATION
|
|
64
|
+
* - line: number (optional)
|
|
65
|
+
*/
|
|
66
|
+
function extractContractsFromFile(filePath, fileContent) {
|
|
67
|
+
const contracts = [];
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
// Parse with Babel - support JSX and TypeScript
|
|
71
|
+
const ast = parse(fileContent, {
|
|
72
|
+
sourceType: 'module',
|
|
73
|
+
plugins: ['jsx', 'typescript']
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
traverse.default(ast, {
|
|
77
|
+
// JSX elements: <Link href>, <a href>, <NavLink to>
|
|
78
|
+
JSXElement(path) {
|
|
79
|
+
const openingElement = path.node.openingElement;
|
|
80
|
+
const elementName = openingElement.name.name;
|
|
81
|
+
|
|
82
|
+
// Check for Link, NavLink, or a elements
|
|
83
|
+
if (elementName === 'Link' || elementName === 'NavLink' || elementName === 'a') {
|
|
84
|
+
let targetPath = null;
|
|
85
|
+
let attributeName = null;
|
|
86
|
+
|
|
87
|
+
// Find href or to attribute
|
|
88
|
+
for (const attr of openingElement.attributes) {
|
|
89
|
+
if (attr.type === 'JSXAttribute') {
|
|
90
|
+
const attrName = attr.name.name;
|
|
91
|
+
|
|
92
|
+
if (attrName === 'href' || attrName === 'to') {
|
|
93
|
+
targetPath = extractStaticStringValue(attr.value);
|
|
94
|
+
attributeName = attrName;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Only create contract if we have a static target path
|
|
101
|
+
if (targetPath && !targetPath.startsWith('http://') && !targetPath.startsWith('https://') && !targetPath.startsWith('mailto:') && !targetPath.startsWith('tel:')) {
|
|
102
|
+
// Normalize path
|
|
103
|
+
const normalized = targetPath.startsWith('/') ? targetPath : '/' + targetPath;
|
|
104
|
+
|
|
105
|
+
contracts.push({
|
|
106
|
+
kind: 'NAVIGATION',
|
|
107
|
+
targetPath: normalized,
|
|
108
|
+
sourceFile: filePath,
|
|
109
|
+
element: elementName,
|
|
110
|
+
attribute: attributeName,
|
|
111
|
+
proof: ExpectationProof.PROVEN_EXPECTATION,
|
|
112
|
+
line: openingElement.loc?.start.line || null
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
// Imperative navigation calls: navigate("/about"), router.push("/about")
|
|
119
|
+
// Note: These are recorded but not used for element-based expectations
|
|
120
|
+
// since we cannot reliably tie them to clicked elements
|
|
121
|
+
CallExpression(path) {
|
|
122
|
+
const callee = path.node.callee;
|
|
123
|
+
|
|
124
|
+
// navigate("/about") - useNavigate hook
|
|
125
|
+
if (callee.type === 'Identifier' && callee.name === 'navigate') {
|
|
126
|
+
const firstArg = path.node.arguments[0];
|
|
127
|
+
if (firstArg && firstArg.type === 'StringLiteral') {
|
|
128
|
+
const targetPath = firstArg.value;
|
|
129
|
+
if (targetPath.startsWith('/')) {
|
|
130
|
+
contracts.push({
|
|
131
|
+
kind: 'NAVIGATION',
|
|
132
|
+
targetPath: targetPath,
|
|
133
|
+
sourceFile: filePath,
|
|
134
|
+
element: 'navigate',
|
|
135
|
+
attribute: null,
|
|
136
|
+
proof: ExpectationProof.PROVEN_EXPECTATION,
|
|
137
|
+
line: path.node.loc?.start.line || null,
|
|
138
|
+
imperativeOnly: true // Cannot match to DOM element
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// router.push("/about") or router.replace("/about")
|
|
145
|
+
if (callee.type === 'MemberExpression' &&
|
|
146
|
+
callee.object.type === 'Identifier' &&
|
|
147
|
+
callee.object.name === 'router' &&
|
|
148
|
+
callee.property.type === 'Identifier' &&
|
|
149
|
+
(callee.property.name === 'push' || callee.property.name === 'replace')) {
|
|
150
|
+
const firstArg = path.node.arguments[0];
|
|
151
|
+
if (firstArg && firstArg.type === 'StringLiteral') {
|
|
152
|
+
const targetPath = firstArg.value;
|
|
153
|
+
if (targetPath.startsWith('/')) {
|
|
154
|
+
contracts.push({
|
|
155
|
+
kind: 'NAVIGATION',
|
|
156
|
+
targetPath: targetPath,
|
|
157
|
+
sourceFile: filePath,
|
|
158
|
+
element: 'router',
|
|
159
|
+
attribute: null,
|
|
160
|
+
proof: ExpectationProof.PROVEN_EXPECTATION,
|
|
161
|
+
line: path.node.loc?.start.line || null,
|
|
162
|
+
imperativeOnly: true // Cannot match to DOM element
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
} catch (error) {
|
|
170
|
+
// Parse error - skip this file
|
|
171
|
+
// This is expected for files with syntax errors or unsupported syntax
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return contracts;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Scans a project directory for navigation contracts using AST analysis.
|
|
180
|
+
* Returns array of PROVEN navigation contracts.
|
|
181
|
+
*
|
|
182
|
+
* Wave 1 - CODE TRUTH ENGINE: AST-derived PROVEN expectations only.
|
|
183
|
+
*/
|
|
184
|
+
export async function extractASTContracts(projectDir) {
|
|
185
|
+
const contracts = [];
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
// Find all JS/JSX/TS/TSX files (excluding node_modules, dist, build)
|
|
189
|
+
const files = await glob('**/*.{js,jsx,ts,tsx}', {
|
|
190
|
+
cwd: projectDir,
|
|
191
|
+
absolute: false,
|
|
192
|
+
ignore: ['node_modules/**', 'dist/**', 'build/**', '.next/**', 'out/**']
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Limit files to scan
|
|
196
|
+
const filesToScan = files.slice(0, MAX_FILES_TO_SCAN);
|
|
197
|
+
|
|
198
|
+
for (const file of filesToScan) {
|
|
199
|
+
try {
|
|
200
|
+
const filePath = resolve(projectDir, file);
|
|
201
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
202
|
+
const fileContracts = extractContractsFromFile(file, content);
|
|
203
|
+
contracts.push(...fileContracts);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
// Skip files that can't be read
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} catch (error) {
|
|
210
|
+
// If glob fails, return empty
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return contracts;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Converts AST contracts to manifest expectations format.
|
|
219
|
+
* Only includes contracts that can be matched at runtime (excludes imperativeOnly).
|
|
220
|
+
*/
|
|
221
|
+
export function contractsToExpectations(contracts, projectType) {
|
|
222
|
+
const expectations = [];
|
|
223
|
+
const seenPaths = new Set();
|
|
224
|
+
|
|
225
|
+
for (const contract of contracts) {
|
|
226
|
+
// Skip imperative-only contracts (navigate, router.push)
|
|
227
|
+
// These cannot be reliably matched to DOM elements
|
|
228
|
+
if (contract.imperativeOnly) {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Skip duplicates
|
|
233
|
+
const key = `${contract.targetPath}:${contract.attribute}`;
|
|
234
|
+
if (seenPaths.has(key)) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
seenPaths.add(key);
|
|
238
|
+
|
|
239
|
+
// Create expectation for runtime matching
|
|
240
|
+
// We don't specify fromPath since we'll match by href/to attribute value
|
|
241
|
+
expectations.push({
|
|
242
|
+
type: 'spa_navigation',
|
|
243
|
+
targetPath: contract.targetPath,
|
|
244
|
+
proof: ExpectationProof.PROVEN_EXPECTATION,
|
|
245
|
+
matchAttribute: contract.attribute, // 'href' or 'to'
|
|
246
|
+
evidence: {
|
|
247
|
+
source: contract.sourceFile,
|
|
248
|
+
element: contract.element,
|
|
249
|
+
line: contract.line
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return expectations;
|
|
255
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { resolve } from 'path';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { detectProjectType } from './project-detector.js';
|
|
4
|
+
import { extractRoutes } from './route-extractor.js';
|
|
5
|
+
import { writeManifest } from './manifest-writer.js';
|
|
6
|
+
|
|
7
|
+
export async function learn(projectDir) {
|
|
8
|
+
const absoluteProjectDir = resolve(projectDir);
|
|
9
|
+
|
|
10
|
+
if (!existsSync(absoluteProjectDir)) {
|
|
11
|
+
throw new Error(`Project directory does not exist: ${absoluteProjectDir}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const projectType = await detectProjectType(absoluteProjectDir);
|
|
15
|
+
const routes = await extractRoutes(absoluteProjectDir, projectType);
|
|
16
|
+
|
|
17
|
+
return await writeManifest(absoluteProjectDir, projectType, routes);
|
|
18
|
+
}
|