@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,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wave 6 & 8 — TypeScript-based Contract Resolver
|
|
3
|
+
*
|
|
4
|
+
* Resolves PROVEN network action contracts (Wave 6) and state action contracts (Wave 8)
|
|
5
|
+
* across files using TypeScript Program.
|
|
6
|
+
* Follows handler identifiers across modules and call chains (depth <= 3).
|
|
7
|
+
* Zero heuristics. Only static literal URLs and resolvable state mutations produce PROVEN contracts.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import ts from 'typescript';
|
|
11
|
+
import { resolve, relative, dirname, sep, join } from 'path';
|
|
12
|
+
import { readFileSync, existsSync, statSync } from 'fs';
|
|
13
|
+
import { glob } from 'glob';
|
|
14
|
+
|
|
15
|
+
const MAX_DEPTH = 3;
|
|
16
|
+
const SUPPORTED_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve action contracts across files using TypeScript compiler API.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} rootDir - Directory containing the source (fixture/project)
|
|
22
|
+
* @param {string} workspaceRoot - Workspace root for relative paths
|
|
23
|
+
* @returns {Promise<Array<Object>>} - PROVEN contracts (both NETWORK_ACTION and STATE_ACTION)
|
|
24
|
+
*/
|
|
25
|
+
export async function resolveActionContracts(rootDir, workspaceRoot) {
|
|
26
|
+
const files = await glob('**/*.{ts,tsx,js,jsx}', {
|
|
27
|
+
cwd: rootDir,
|
|
28
|
+
absolute: true,
|
|
29
|
+
ignore: ['node_modules/**', 'dist/**', 'build/**']
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const normalizedRoot = resolve(rootDir);
|
|
33
|
+
|
|
34
|
+
const program = ts.createProgram(files, {
|
|
35
|
+
allowJs: true,
|
|
36
|
+
checkJs: true,
|
|
37
|
+
jsx: ts.JsxEmit.ReactJSX,
|
|
38
|
+
target: ts.ScriptTarget.ES2020,
|
|
39
|
+
module: ts.ModuleKind.CommonJS,
|
|
40
|
+
moduleResolution: ts.ModuleResolutionKind.NodeJs,
|
|
41
|
+
skipLibCheck: true,
|
|
42
|
+
noResolve: false
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const checker = program.getTypeChecker();
|
|
46
|
+
const contracts = [];
|
|
47
|
+
|
|
48
|
+
for (const sourceFile of program.getSourceFiles()) {
|
|
49
|
+
const sourcePath = resolve(sourceFile.fileName);
|
|
50
|
+
if (!sourcePath.toLowerCase().startsWith(normalizedRoot.toLowerCase())) continue;
|
|
51
|
+
if (sourceFile.isDeclarationFile) continue;
|
|
52
|
+
|
|
53
|
+
const importMap = buildImportMap(sourceFile, rootDir, workspaceRoot);
|
|
54
|
+
|
|
55
|
+
function visit(node) {
|
|
56
|
+
if (ts.isJsxAttribute(node)) {
|
|
57
|
+
const name = node.name.getText();
|
|
58
|
+
if (name !== 'onClick' && name !== 'onSubmit') return;
|
|
59
|
+
|
|
60
|
+
if (!node.initializer || !ts.isJsxExpression(node.initializer)) return;
|
|
61
|
+
const expr = node.initializer.expression;
|
|
62
|
+
if (!expr) return;
|
|
63
|
+
|
|
64
|
+
// We only handle identifier handlers (cross-file capable)
|
|
65
|
+
if (!ts.isIdentifier(expr)) return;
|
|
66
|
+
|
|
67
|
+
const handlerName = expr.text;
|
|
68
|
+
const handlerRef = deriveHandlerRef(expr, importMap, sourceFile, workspaceRoot);
|
|
69
|
+
if (!handlerRef) return;
|
|
70
|
+
|
|
71
|
+
const symbol = checker.getSymbolAtLocation(expr);
|
|
72
|
+
if (!symbol) return;
|
|
73
|
+
const targetSymbol = ts.SymbolFlags.Alias & symbol.flags ? checker.getAliasedSymbol(symbol) : symbol;
|
|
74
|
+
|
|
75
|
+
// Check for network action contracts (Wave 6)
|
|
76
|
+
const networkContract = followHandler(targetSymbol, checker, 0, 'network');
|
|
77
|
+
if (networkContract) {
|
|
78
|
+
const sourceRef = formatSourceRef(sourceFile.fileName, workspaceRoot, node.getStart(), sourceFile);
|
|
79
|
+
const openingElement = node.parent && node.parent.parent && ts.isJsxOpeningElement(node.parent.parent)
|
|
80
|
+
? node.parent.parent
|
|
81
|
+
: null;
|
|
82
|
+
const elementType = getElementName(openingElement);
|
|
83
|
+
|
|
84
|
+
contracts.push({
|
|
85
|
+
kind: 'NETWORK_ACTION',
|
|
86
|
+
method: networkContract.method,
|
|
87
|
+
urlPath: networkContract.url,
|
|
88
|
+
source: sourceRef,
|
|
89
|
+
handlerRef,
|
|
90
|
+
sourceChain: networkContract.chain,
|
|
91
|
+
elementType
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Check for state action contracts (Wave 8)
|
|
96
|
+
const stateContract = followHandler(targetSymbol, checker, 0, 'state');
|
|
97
|
+
if (stateContract) {
|
|
98
|
+
const sourceRef = formatSourceRef(sourceFile.fileName, workspaceRoot, node.getStart(), sourceFile);
|
|
99
|
+
const openingElement = node.parent && node.parent.parent && ts.isJsxOpeningElement(node.parent.parent)
|
|
100
|
+
? node.parent.parent
|
|
101
|
+
: null;
|
|
102
|
+
const elementType = getElementName(openingElement);
|
|
103
|
+
|
|
104
|
+
contracts.push({
|
|
105
|
+
kind: 'STATE_ACTION',
|
|
106
|
+
stateKind: stateContract.stateKind,
|
|
107
|
+
source: sourceRef,
|
|
108
|
+
handlerRef,
|
|
109
|
+
sourceChain: stateContract.chain,
|
|
110
|
+
elementType
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
ts.forEachChild(node, visit);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
visit(sourceFile);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return dedupeContracts(contracts);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function dedupeContracts(list) {
|
|
124
|
+
const seen = new Set();
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const c of list) {
|
|
127
|
+
const key = `${c.handlerRef}|${c.urlPath}|${c.method}|${c.source}`;
|
|
128
|
+
if (seen.has(key)) continue;
|
|
129
|
+
seen.add(key);
|
|
130
|
+
out.push(c);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function buildImportMap(sourceFile, rootDir, workspaceRoot) {
|
|
136
|
+
const map = new Map(); // localName -> { modulePath, exportName }
|
|
137
|
+
const fileDir = dirname(sourceFile.fileName);
|
|
138
|
+
|
|
139
|
+
sourceFile.forEachChild((node) => {
|
|
140
|
+
if (!ts.isImportDeclaration(node) || !node.importClause || !node.moduleSpecifier) return;
|
|
141
|
+
const moduleText = node.moduleSpecifier.getText().slice(1, -1);
|
|
142
|
+
const resolved = resolveModulePath(moduleText, fileDir, rootDir, workspaceRoot);
|
|
143
|
+
if (!resolved) return;
|
|
144
|
+
|
|
145
|
+
const { importClause } = node;
|
|
146
|
+
if (importClause.name) {
|
|
147
|
+
// default import: import foo from './x'
|
|
148
|
+
map.set(importClause.name.text, { modulePath: resolved, exportName: 'default' });
|
|
149
|
+
}
|
|
150
|
+
if (importClause.namedBindings && ts.isNamedImports(importClause.namedBindings)) {
|
|
151
|
+
for (const spec of importClause.namedBindings.elements) {
|
|
152
|
+
const importedName = spec.propertyName ? spec.propertyName.text : spec.name.text;
|
|
153
|
+
map.set(spec.name.text, { modulePath: resolved, exportName: importedName });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return map;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function resolveModulePath(specifier, fromDir, rootDir, workspaceRoot) {
|
|
162
|
+
if (specifier.startsWith('.') || specifier.startsWith('/')) {
|
|
163
|
+
const base = specifier.startsWith('.') ? resolve(fromDir, specifier) : resolve(rootDir, specifier);
|
|
164
|
+
const candidateFiles = [base, ...SUPPORTED_EXTENSIONS.map(ext => base + ext), ...SUPPORTED_EXTENSIONS.map(ext => join(base, 'index' + ext))];
|
|
165
|
+
for (const file of candidateFiles) {
|
|
166
|
+
if (existsSync(file) && statSync(file).isFile()) return normalizePath(file, workspaceRoot);
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
// External module -> treat as unresolved (UNKNOWN)
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function deriveHandlerRef(identifier, importMap, sourceFile, workspaceRoot) {
|
|
175
|
+
const localName = identifier.text;
|
|
176
|
+
const entry = importMap.get(localName);
|
|
177
|
+
if (entry) {
|
|
178
|
+
return `${entry.modulePath}#${entry.exportName}`;
|
|
179
|
+
}
|
|
180
|
+
// Local declaration in same file
|
|
181
|
+
const modulePath = normalizePath(sourceFile.fileName, workspaceRoot);
|
|
182
|
+
return `${modulePath}#${localName}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function followHandler(symbol, checker, depth, contractType = 'network') {
|
|
186
|
+
if (!symbol || depth > MAX_DEPTH) return null;
|
|
187
|
+
|
|
188
|
+
const declarations = symbol.getDeclarations() || [];
|
|
189
|
+
for (const decl of declarations) {
|
|
190
|
+
if (ts.isFunctionDeclaration(decl) && decl.body) {
|
|
191
|
+
if (contractType === 'network') {
|
|
192
|
+
const res = findNetworkCallInBody(decl.body, checker, depth);
|
|
193
|
+
if (res) return res;
|
|
194
|
+
} else {
|
|
195
|
+
const res = findStateCallInBody(decl.body, checker, depth);
|
|
196
|
+
if (res) return res;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (ts.isVariableDeclaration(decl) && decl.initializer) {
|
|
200
|
+
if (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer)) {
|
|
201
|
+
if (contractType === 'network') {
|
|
202
|
+
const res = findNetworkCallInBody(decl.initializer.body, checker, depth);
|
|
203
|
+
if (res) return res;
|
|
204
|
+
} else {
|
|
205
|
+
const res = findStateCallInBody(decl.initializer.body, checker, depth);
|
|
206
|
+
if (res) return res;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function findNetworkCallInBody(body, checker, depth) {
|
|
215
|
+
let found = null;
|
|
216
|
+
|
|
217
|
+
function walk(node) {
|
|
218
|
+
if (found) return;
|
|
219
|
+
|
|
220
|
+
// Direct network calls
|
|
221
|
+
if (ts.isCallExpression(node)) {
|
|
222
|
+
const call = analyzeCall(node);
|
|
223
|
+
if (call) {
|
|
224
|
+
found = { method: call.method, url: call.url, chain: call.chain }; return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Follow inner call chain: handler -> wrapper -> fetch
|
|
228
|
+
if (depth < MAX_DEPTH) {
|
|
229
|
+
const callee = node.expression;
|
|
230
|
+
if (ts.isIdentifier(callee)) {
|
|
231
|
+
const sym = checker.getSymbolAtLocation(callee);
|
|
232
|
+
const target = sym && (ts.SymbolFlags.Alias & sym.flags ? checker.getAliasedSymbol(sym) : sym);
|
|
233
|
+
const inner = followHandler(target, checker, depth + 1, 'network');
|
|
234
|
+
if (inner) {
|
|
235
|
+
const name = callee.text;
|
|
236
|
+
const chain = [{ name }].concat(inner.chain || []);
|
|
237
|
+
found = { method: inner.method, url: inner.url, chain };
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
ts.forEachChild(node, walk);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
walk(body);
|
|
248
|
+
return found;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Find state mutations (useState setter, Redux dispatch, Zustand set) in function body.
|
|
253
|
+
* Returns { stateKind, chain } if found, null otherwise.
|
|
254
|
+
*/
|
|
255
|
+
function findStateCallInBody(body, checker, depth) {
|
|
256
|
+
let found = null;
|
|
257
|
+
|
|
258
|
+
function walk(node) {
|
|
259
|
+
if (found) return;
|
|
260
|
+
|
|
261
|
+
// Direct state setter calls: setX(...), dispatch(...), set(...)
|
|
262
|
+
if (ts.isCallExpression(node)) {
|
|
263
|
+
const stateCall = analyzeStateCall(node);
|
|
264
|
+
if (stateCall) {
|
|
265
|
+
found = { stateKind: stateCall.stateKind, chain: stateCall.chain };
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Follow inner call chain for state mutations
|
|
270
|
+
if (depth < MAX_DEPTH) {
|
|
271
|
+
const callee = node.expression;
|
|
272
|
+
if (ts.isIdentifier(callee)) {
|
|
273
|
+
const sym = checker.getSymbolAtLocation(callee);
|
|
274
|
+
const target = sym && (ts.SymbolFlags.Alias & sym.flags ? checker.getAliasedSymbol(sym) : sym);
|
|
275
|
+
const inner = followHandler(target, checker, depth + 1, 'state');
|
|
276
|
+
if (inner) {
|
|
277
|
+
const name = callee.text;
|
|
278
|
+
const chain = [{ name }].concat(inner.chain || []);
|
|
279
|
+
found = { stateKind: inner.stateKind, chain };
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
ts.forEachChild(node, walk);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
walk(body);
|
|
290
|
+
return found;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Analyze a call expression to determine if it's a state mutation.
|
|
295
|
+
* Returns { stateKind, chain } if it's a state call, null otherwise.
|
|
296
|
+
*/
|
|
297
|
+
function analyzeStateCall(node) {
|
|
298
|
+
if (!ts.isCallExpression(node)) return null;
|
|
299
|
+
|
|
300
|
+
const callee = node.expression;
|
|
301
|
+
|
|
302
|
+
// React setter: setX(...) — any identifier starting with 'set' followed by capital letter
|
|
303
|
+
if (ts.isIdentifier(callee)) {
|
|
304
|
+
const name = callee.text;
|
|
305
|
+
if (name.startsWith('set') && name.length > 3 && /^set[A-Z]/.test(name)) {
|
|
306
|
+
return { stateKind: 'react_setter', chain: [] };
|
|
307
|
+
}
|
|
308
|
+
// dispatch(...) — Redux or similar
|
|
309
|
+
if (name === 'dispatch') {
|
|
310
|
+
return { stateKind: 'redux_dispatch', chain: [] };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Zustand: store.set(...) or setState(...)
|
|
315
|
+
if (ts.isPropertyAccessExpression(callee)) {
|
|
316
|
+
const obj = callee.expression;
|
|
317
|
+
const prop = callee.name;
|
|
318
|
+
// Common pattern: storeObj.set(...)
|
|
319
|
+
if (prop.text === 'set') {
|
|
320
|
+
return { stateKind: 'zustand_set', chain: [] };
|
|
321
|
+
}
|
|
322
|
+
// setState(...) called on object
|
|
323
|
+
if (prop.text === 'setState') {
|
|
324
|
+
return { stateKind: 'react_setter', chain: [] };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function analyzeCall(node) {
|
|
332
|
+
// fetch("/api")
|
|
333
|
+
if (ts.isIdentifier(node.expression) && node.expression.text === 'fetch') {
|
|
334
|
+
const urlArg = node.arguments[0];
|
|
335
|
+
if (urlArg && ts.isStringLiteral(urlArg)) {
|
|
336
|
+
let method = 'GET';
|
|
337
|
+
const options = node.arguments[1];
|
|
338
|
+
if (options && ts.isObjectLiteralExpression(options)) {
|
|
339
|
+
const methodProp = options.properties.find(p => ts.isPropertyAssignment(p) && p.name && p.name.getText() === 'method');
|
|
340
|
+
if (methodProp && ts.isPropertyAssignment(methodProp) && ts.isStringLiteral(methodProp.initializer)) {
|
|
341
|
+
method = methodProp.initializer.text.toUpperCase();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return { method, url: urlArg.text, chain: [] };
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// axios.post("/api")
|
|
349
|
+
if (ts.isPropertyAccessExpression(node.expression)) {
|
|
350
|
+
const obj = node.expression.expression;
|
|
351
|
+
const prop = node.expression.name;
|
|
352
|
+
if (ts.isIdentifier(obj) && obj.text === 'axios') {
|
|
353
|
+
const method = prop.text.toUpperCase();
|
|
354
|
+
const urlArg = node.arguments[0];
|
|
355
|
+
if (urlArg && ts.isStringLiteral(urlArg)) {
|
|
356
|
+
return { method, url: urlArg.text, chain: [] };
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// new XMLHttpRequest().open("METHOD", "URL")
|
|
362
|
+
if (ts.isCallExpression(node.expression) && ts.isPropertyAccessExpression(node.expression)) {
|
|
363
|
+
const inner = node.expression;
|
|
364
|
+
if (inner.name.text === 'open') {
|
|
365
|
+
const obj = inner.expression;
|
|
366
|
+
if (ts.isNewExpression(obj) && ts.isIdentifier(obj.expression) && obj.expression.text === 'XMLHttpRequest') {
|
|
367
|
+
const methodArg = node.arguments[0];
|
|
368
|
+
const urlArg = node.arguments[1];
|
|
369
|
+
if (methodArg && ts.isStringLiteral(methodArg) && urlArg && ts.isStringLiteral(urlArg)) {
|
|
370
|
+
return { method: methodArg.text.toUpperCase(), url: urlArg.text, chain: [] };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function getElementName(openingElement) {
|
|
380
|
+
if (!openingElement || !openingElement.name) return 'element';
|
|
381
|
+
const nameNode = openingElement.name;
|
|
382
|
+
if (ts.isIdentifier(nameNode)) return nameNode.text;
|
|
383
|
+
return 'element';
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function formatSourceRef(filePath, workspaceRoot, pos, sourceFile) {
|
|
387
|
+
const rel = normalizePath(filePath, workspaceRoot);
|
|
388
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(pos);
|
|
389
|
+
return `${rel}:${line + 1}:${character}`; // keep column 0-based for consistency with Babel loc.column
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function normalizePath(filePath, workspaceRoot) {
|
|
393
|
+
const rel = relative(workspaceRoot, filePath);
|
|
394
|
+
return rel.split(sep).join('/');
|
|
395
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
|
|
3
|
+
const STABLE_WAIT_MS = 2000;
|
|
4
|
+
|
|
5
|
+
export async function createBrowser() {
|
|
6
|
+
const browser = await chromium.launch({ headless: true });
|
|
7
|
+
const context = await browser.newContext({
|
|
8
|
+
viewport: { width: 1280, height: 720 }
|
|
9
|
+
});
|
|
10
|
+
const page = await context.newPage();
|
|
11
|
+
return { browser, page };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function navigateToUrl(page, url) {
|
|
15
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
|
|
16
|
+
await page.waitForTimeout(STABLE_WAIT_MS);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function closeBrowser(browser) {
|
|
20
|
+
await browser.close();
|
|
21
|
+
}
|
|
22
|
+
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAVE 3: Console Truth Sensor
|
|
3
|
+
* Captures console errors, warnings, page errors, unhandled rejections
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class ConsoleSensor {
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.maxErrorsToKeep = options.maxErrorsToKeep || 10;
|
|
9
|
+
this.windows = new Map(); // windowId -> window state
|
|
10
|
+
this.nextWindowId = 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Start monitoring console and page errors, return window ID.
|
|
15
|
+
*/
|
|
16
|
+
startWindow(page) {
|
|
17
|
+
const windowId = this.nextWindowId++;
|
|
18
|
+
|
|
19
|
+
const state = {
|
|
20
|
+
id: windowId,
|
|
21
|
+
startTime: Date.now(),
|
|
22
|
+
consoleErrors: [],
|
|
23
|
+
consoleWarnings: [],
|
|
24
|
+
pageErrors: [],
|
|
25
|
+
unhandledRejections: []
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Capture console.error and console.warn
|
|
29
|
+
const onConsoleMessage = (msg) => {
|
|
30
|
+
const type = msg.type();
|
|
31
|
+
const text = msg.text().slice(0, 200); // Limit message length
|
|
32
|
+
|
|
33
|
+
if (type === 'error') {
|
|
34
|
+
state.consoleErrors.push(text);
|
|
35
|
+
if (state.consoleErrors.length > this.maxErrorsToKeep) {
|
|
36
|
+
state.consoleErrors.shift();
|
|
37
|
+
}
|
|
38
|
+
} else if (type === 'warning') {
|
|
39
|
+
state.consoleWarnings.push(text);
|
|
40
|
+
if (state.consoleWarnings.length > this.maxErrorsToKeep) {
|
|
41
|
+
state.consoleWarnings.shift();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Capture uncaught page errors
|
|
47
|
+
const onPageError = (error) => {
|
|
48
|
+
const message = error?.toString().slice(0, 200) || 'Unknown error';
|
|
49
|
+
state.pageErrors.push({
|
|
50
|
+
message: message,
|
|
51
|
+
name: error?.name || 'Error'
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (state.pageErrors.length > this.maxErrorsToKeep) {
|
|
55
|
+
state.pageErrors.shift();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Capture unhandled promise rejections
|
|
60
|
+
const onUnhandledRejection = (promise, reason) => {
|
|
61
|
+
const message = (reason?.toString?.() || String(reason)).slice(0, 200);
|
|
62
|
+
state.unhandledRejections.push({
|
|
63
|
+
message: message,
|
|
64
|
+
type: typeof reason
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (state.unhandledRejections.length > this.maxErrorsToKeep) {
|
|
68
|
+
state.unhandledRejections.shift();
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
page.on('console', onConsoleMessage);
|
|
73
|
+
page.on('pageerror', onPageError);
|
|
74
|
+
|
|
75
|
+
// Inject listener for unhandledrejection (Playwright doesn't have direct event)
|
|
76
|
+
try {
|
|
77
|
+
page.evaluate(() => {
|
|
78
|
+
if (typeof window !== 'undefined') {
|
|
79
|
+
window.__unhandledRejections = [];
|
|
80
|
+
window.addEventListener('unhandledrejection', (event) => {
|
|
81
|
+
const reason = event.reason || {};
|
|
82
|
+
window.__unhandledRejections.push({
|
|
83
|
+
message: (reason?.toString?.() || String(reason)).slice(0, 200),
|
|
84
|
+
type: typeof reason
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}).catch(() => {
|
|
89
|
+
// Page may not support this
|
|
90
|
+
});
|
|
91
|
+
} catch {
|
|
92
|
+
// Ignore
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
state.cleanup = () => {
|
|
96
|
+
page.removeListener('console', onConsoleMessage);
|
|
97
|
+
page.removeListener('pageerror', onPageError);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
this.windows.set(windowId, state);
|
|
101
|
+
return windowId;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Stop monitoring and return a summary for the window.
|
|
106
|
+
*/
|
|
107
|
+
async stopWindow(windowId, page) {
|
|
108
|
+
const state = this.windows.get(windowId);
|
|
109
|
+
if (!state) {
|
|
110
|
+
return this.getEmptySummary();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
state.cleanup();
|
|
114
|
+
|
|
115
|
+
// Collect any unhandled rejections that were captured
|
|
116
|
+
let capturedRejections = [];
|
|
117
|
+
try {
|
|
118
|
+
capturedRejections = await page.evaluate(() => {
|
|
119
|
+
return window.__unhandledRejections || [];
|
|
120
|
+
});
|
|
121
|
+
} catch {
|
|
122
|
+
// Page may not have this
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Merge captured rejections with ones we heard about
|
|
126
|
+
state.unhandledRejections = [
|
|
127
|
+
...state.unhandledRejections,
|
|
128
|
+
...capturedRejections
|
|
129
|
+
].slice(0, this.maxErrorsToKeep);
|
|
130
|
+
|
|
131
|
+
const summary = {
|
|
132
|
+
windowId,
|
|
133
|
+
errorCount: state.consoleErrors.length + state.pageErrors.length,
|
|
134
|
+
consoleErrorCount: state.consoleErrors.length,
|
|
135
|
+
pageErrorCount: state.pageErrors.length,
|
|
136
|
+
unhandledRejectionCount: state.unhandledRejections.length,
|
|
137
|
+
lastErrors: [
|
|
138
|
+
...state.consoleErrors.map((msg) => ({ type: 'console.error', message: msg })),
|
|
139
|
+
...state.pageErrors.map((err) => ({ type: 'pageerror', message: err.message })),
|
|
140
|
+
...state.unhandledRejections.map((rej) => ({
|
|
141
|
+
type: 'unhandledrejection',
|
|
142
|
+
message: rej.message
|
|
143
|
+
}))
|
|
144
|
+
].slice(0, this.maxErrorsToKeep),
|
|
145
|
+
hasErrors:
|
|
146
|
+
state.consoleErrors.length > 0 ||
|
|
147
|
+
state.pageErrors.length > 0 ||
|
|
148
|
+
state.unhandledRejections.length > 0
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
this.windows.delete(windowId);
|
|
152
|
+
return summary;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
getEmptySummary() {
|
|
156
|
+
return {
|
|
157
|
+
windowId: -1,
|
|
158
|
+
errorCount: 0,
|
|
159
|
+
consoleErrorCount: 0,
|
|
160
|
+
pageErrorCount: 0,
|
|
161
|
+
unhandledRejectionCount: 0,
|
|
162
|
+
lastErrors: [],
|
|
163
|
+
hasErrors: false
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
|
|
3
|
+
const MAX_TEXT_LENGTH = 5000;
|
|
4
|
+
|
|
5
|
+
export async function captureDomSignature(page) {
|
|
6
|
+
try {
|
|
7
|
+
const text = await page.evaluate(() => {
|
|
8
|
+
return document.body?.innerText?.trim() || '';
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const normalized = text
|
|
12
|
+
.replace(/\s+/g, ' ')
|
|
13
|
+
.trim()
|
|
14
|
+
.substring(0, MAX_TEXT_LENGTH);
|
|
15
|
+
|
|
16
|
+
const hash = createHash('sha256').update(normalized).digest('hex');
|
|
17
|
+
|
|
18
|
+
return hash;
|
|
19
|
+
} catch (error) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function getBaseOrigin(url) {
|
|
2
|
+
try {
|
|
3
|
+
const urlObj = new URL(url);
|
|
4
|
+
return `${urlObj.protocol}//${urlObj.host}${urlObj.port ? `:${urlObj.port}` : ''}`;
|
|
5
|
+
} catch (error) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isExternalUrl(url, baseOrigin) {
|
|
11
|
+
if (!baseOrigin) return false;
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const urlObj = new URL(url);
|
|
15
|
+
const urlOrigin = urlObj.origin;
|
|
16
|
+
return urlOrigin !== baseOrigin;
|
|
17
|
+
} catch (error) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isExternalHref(href, baseOrigin, currentUrl) {
|
|
23
|
+
if (!href || href.startsWith('#') || href.startsWith('javascript:')) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (href.startsWith('http://') || href.startsWith('https://')) {
|
|
28
|
+
return isExternalUrl(href, baseOrigin);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const resolvedUrl = new URL(href, currentUrl);
|
|
33
|
+
return isExternalUrl(resolvedUrl.href, baseOrigin);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|