@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
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { glob } from 'glob';
|
|
2
|
-
import { resolve, dirname,
|
|
2
|
+
import { resolve, dirname, relative } from 'path';
|
|
3
3
|
import { readFileSync, existsSync } from 'fs';
|
|
4
4
|
import { parse } from 'node-html-parser';
|
|
5
|
-
import { ExpectationProof } from '../shared/expectation-proof.js';
|
|
6
5
|
|
|
7
6
|
const MAX_HTML_FILES = 200;
|
|
8
7
|
|
|
9
8
|
function htmlFileToRoute(file) {
|
|
10
|
-
let path = file.replace(/[
|
|
11
|
-
path = path.replace(/\/index
|
|
9
|
+
let path = file.replace(/[\\/]/g, '/');
|
|
10
|
+
path = path.replace(/\/index.html$/, '');
|
|
12
11
|
path = path.replace(/\.html$/, '');
|
|
13
12
|
path = path.replace(/^index$/, '');
|
|
14
13
|
|
|
@@ -121,7 +120,6 @@ function extractButtonNavigationExpectations(root, fromPath, file, routeMap, pro
|
|
|
121
120
|
fromPath: fromPath,
|
|
122
121
|
type: 'navigation',
|
|
123
122
|
targetPath: targetPath,
|
|
124
|
-
proof: ExpectationProof.PROVEN_EXPECTATION,
|
|
125
123
|
evidence: {
|
|
126
124
|
source: file,
|
|
127
125
|
selectorHint: selectorHint
|
|
@@ -151,7 +149,6 @@ function extractFormSubmissionExpectations(root, fromPath, file, routeMap, proje
|
|
|
151
149
|
fromPath: fromPath,
|
|
152
150
|
type: 'form_submission',
|
|
153
151
|
targetPath: targetPath,
|
|
154
|
-
proof: ExpectationProof.PROVEN_EXPECTATION,
|
|
155
152
|
evidence: {
|
|
156
153
|
source: file,
|
|
157
154
|
selectorHint: selectorHint
|
|
@@ -162,6 +159,105 @@ function extractFormSubmissionExpectations(root, fromPath, file, routeMap, proje
|
|
|
162
159
|
return expectations;
|
|
163
160
|
}
|
|
164
161
|
|
|
162
|
+
function extractNetworkExpectations(root, fromPath, file, _projectDir) {
|
|
163
|
+
const expectations = [];
|
|
164
|
+
|
|
165
|
+
// Extract from inline scripts
|
|
166
|
+
const scripts = root.querySelectorAll('script');
|
|
167
|
+
for (const script of scripts) {
|
|
168
|
+
const scriptContent = script.textContent || '';
|
|
169
|
+
if (!scriptContent) continue;
|
|
170
|
+
|
|
171
|
+
// Find fetch() calls with string literals
|
|
172
|
+
const fetchMatches = scriptContent.matchAll(/fetch\s*\(\s*['"]([^'"]+)['"]/g);
|
|
173
|
+
for (const match of fetchMatches) {
|
|
174
|
+
const endpoint = match[1];
|
|
175
|
+
if (!endpoint) continue;
|
|
176
|
+
|
|
177
|
+
// Only extract if it's an API endpoint (starts with /api/)
|
|
178
|
+
if (!endpoint.startsWith('/api/')) continue;
|
|
179
|
+
|
|
180
|
+
// Find the button/function that triggers this
|
|
181
|
+
// Look for onclick handlers or function definitions
|
|
182
|
+
const buttonId = scriptContent.match(/getElementById\s*\(\s*['"]([^'"]+)['"]/)?.[1];
|
|
183
|
+
const functionName = scriptContent.match(/function\s+(\w+)\s*\(/)?.[1];
|
|
184
|
+
|
|
185
|
+
let selectorHint = null;
|
|
186
|
+
if (buttonId) {
|
|
187
|
+
selectorHint = `#${buttonId}`;
|
|
188
|
+
} else if (functionName) {
|
|
189
|
+
// Try to find button with onclick that calls this function
|
|
190
|
+
const buttons = root.querySelectorAll(`button[onclick*="${functionName}"]`);
|
|
191
|
+
if (buttons.length > 0) {
|
|
192
|
+
const btn = buttons[0];
|
|
193
|
+
selectorHint = btn.id ? `#${btn.id}` : `button[onclick*="${functionName}"]`;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (selectorHint) {
|
|
198
|
+
// Determine method from fetch options if present
|
|
199
|
+
let method = 'GET';
|
|
200
|
+
const methodMatch = scriptContent.match(/method\s*:\s*['"]([^'"]+)['"]/i);
|
|
201
|
+
if (methodMatch) {
|
|
202
|
+
method = methodMatch[1].toUpperCase();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
expectations.push({
|
|
206
|
+
fromPath: fromPath,
|
|
207
|
+
type: 'network_action',
|
|
208
|
+
expectedTarget: endpoint,
|
|
209
|
+
urlPath: endpoint,
|
|
210
|
+
method: method,
|
|
211
|
+
proof: 'PROVEN_EXPECTATION',
|
|
212
|
+
sourceRef: `${file}:${scriptContent.indexOf(match[0])}`,
|
|
213
|
+
selectorHint: selectorHint,
|
|
214
|
+
evidence: {
|
|
215
|
+
source: file,
|
|
216
|
+
selectorHint: selectorHint
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Also check onclick attributes directly
|
|
224
|
+
const buttons = root.querySelectorAll('button[onclick]');
|
|
225
|
+
for (const button of buttons) {
|
|
226
|
+
const onclick = button.getAttribute('onclick') || '';
|
|
227
|
+
const fetchMatch = onclick.match(/fetch\s*\(\s*['"]([^'"]+)['"]/);
|
|
228
|
+
if (fetchMatch) {
|
|
229
|
+
const endpoint = fetchMatch[1];
|
|
230
|
+
if (endpoint.startsWith('/api/')) {
|
|
231
|
+
const buttonId = button.getAttribute('id');
|
|
232
|
+
const selectorHint = buttonId ? `#${buttonId}` : `button[onclick*="${endpoint}"]`;
|
|
233
|
+
|
|
234
|
+
let method = 'GET';
|
|
235
|
+
const methodMatch = onclick.match(/method\s*:\s*['"]([^'"]+)['"]/i);
|
|
236
|
+
if (methodMatch) {
|
|
237
|
+
method = methodMatch[1].toUpperCase();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
expectations.push({
|
|
241
|
+
fromPath: fromPath,
|
|
242
|
+
type: 'network_action',
|
|
243
|
+
expectedTarget: endpoint,
|
|
244
|
+
urlPath: endpoint,
|
|
245
|
+
method: method,
|
|
246
|
+
proof: 'PROVEN_EXPECTATION',
|
|
247
|
+
sourceRef: `${file}:onclick`,
|
|
248
|
+
selectorHint: selectorHint,
|
|
249
|
+
evidence: {
|
|
250
|
+
source: file,
|
|
251
|
+
selectorHint: selectorHint
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return expectations;
|
|
259
|
+
}
|
|
260
|
+
|
|
165
261
|
export async function extractStaticExpectations(projectDir, routes) {
|
|
166
262
|
const expectations = [];
|
|
167
263
|
const routeMap = new Map(routes.map(r => [r.path, r]));
|
|
@@ -190,16 +286,16 @@ export async function extractStaticExpectations(projectDir, routes) {
|
|
|
190
286
|
const targetPath = resolveLinkPath(href, file, projectDir);
|
|
191
287
|
if (!targetPath) continue;
|
|
192
288
|
|
|
193
|
-
if
|
|
194
|
-
|
|
195
|
-
|
|
289
|
+
// Extract expectation even if target route doesn't exist yet
|
|
290
|
+
// This allows detection of broken links or prevented navigation
|
|
291
|
+
// (The route may not exist, but the link promises navigation)
|
|
292
|
+
const _linkText = link.textContent?.trim() || '';
|
|
196
293
|
const selectorHint = link.id ? `#${link.id}` : `a[href="${href}"]`;
|
|
197
294
|
|
|
198
295
|
expectations.push({
|
|
199
296
|
fromPath: fromPath,
|
|
200
297
|
type: 'navigation',
|
|
201
298
|
targetPath: targetPath,
|
|
202
|
-
proof: ExpectationProof.PROVEN_EXPECTATION,
|
|
203
299
|
evidence: {
|
|
204
300
|
source: file,
|
|
205
301
|
selectorHint: selectorHint
|
|
@@ -212,6 +308,19 @@ export async function extractStaticExpectations(projectDir, routes) {
|
|
|
212
308
|
|
|
213
309
|
const formExpectations = extractFormSubmissionExpectations(root, fromPath, file, routeMap, projectDir);
|
|
214
310
|
expectations.push(...formExpectations);
|
|
311
|
+
|
|
312
|
+
const networkExpectations = extractNetworkExpectations(root, fromPath, file, projectDir);
|
|
313
|
+
expectations.push(...networkExpectations);
|
|
314
|
+
|
|
315
|
+
// NAVIGATION INTELLIGENCE v2: Extract navigation expectations from inline scripts
|
|
316
|
+
const { extractNavigationExpectations } = await import('./static-extractor-navigation.js');
|
|
317
|
+
const navigationExpectations = extractNavigationExpectations(root, fromPath, file, projectDir);
|
|
318
|
+
expectations.push(...navigationExpectations);
|
|
319
|
+
|
|
320
|
+
// VALIDATION INTELLIGENCE v1: Extract validation_block expectations from inline scripts
|
|
321
|
+
const { extractValidationExpectations } = await import('./static-extractor-validation.js');
|
|
322
|
+
const validationExpectations = extractValidationExpectations(root, fromPath, file, projectDir);
|
|
323
|
+
expectations.push(...validationExpectations);
|
|
215
324
|
} catch (error) {
|
|
216
325
|
continue;
|
|
217
326
|
}
|
|
@@ -3,7 +3,7 @@ import { hasReactRouterDom } from './project-detector.js';
|
|
|
3
3
|
|
|
4
4
|
const MAX_HTML_FILES = 200;
|
|
5
5
|
|
|
6
|
-
export async function assessLearnTruth(projectDir, projectType, routes, staticExpectations
|
|
6
|
+
export async function assessLearnTruth(projectDir, projectType, routes, staticExpectations) {
|
|
7
7
|
const truth = {
|
|
8
8
|
routesDiscovered: routes.length,
|
|
9
9
|
routesSource: 'none',
|
|
@@ -18,14 +18,24 @@ export async function assessLearnTruth(projectDir, projectType, routes, staticEx
|
|
|
18
18
|
if (projectType === 'nextjs_app_router' || projectType === 'nextjs_pages_router') {
|
|
19
19
|
truth.routesSource = 'nextjs_fs';
|
|
20
20
|
truth.routesConfidence = 'HIGH';
|
|
21
|
+
} else if (projectType === 'vue_router') {
|
|
22
|
+
truth.routesSource = 'vue_router_ast';
|
|
23
|
+
truth.routesConfidence = 'HIGH';
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
truth.
|
|
25
|
-
|
|
25
|
+
if (staticExpectations && staticExpectations.length > 0) {
|
|
26
|
+
truth.expectationsDiscovered = staticExpectations.length;
|
|
27
|
+
truth.expectationsStrong = staticExpectations.filter(e =>
|
|
28
|
+
e.type === 'spa_navigation'
|
|
29
|
+
).length;
|
|
26
30
|
truth.expectationsWeak = 0;
|
|
27
|
-
truth.expectationsSource = 'ast_contracts';
|
|
28
31
|
}
|
|
32
|
+
} else if (projectType === 'vue_spa') {
|
|
33
|
+
truth.routesSource = 'vue_no_router';
|
|
34
|
+
truth.routesConfidence = 'LOW';
|
|
35
|
+
truth.limitations.push({
|
|
36
|
+
code: 'VUE_ROUTER_NOT_INSTALLED',
|
|
37
|
+
message: 'Vue detected but vue-router not installed. Routes cannot be extracted from router configuration.'
|
|
38
|
+
});
|
|
29
39
|
} else if (projectType === 'static') {
|
|
30
40
|
truth.routesSource = 'static_html';
|
|
31
41
|
|
|
@@ -67,21 +77,14 @@ export async function assessLearnTruth(projectDir, projectType, routes, staticEx
|
|
|
67
77
|
});
|
|
68
78
|
}
|
|
69
79
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
truth.expectationsStrong = 0;
|
|
79
|
-
truth.expectationsWeak = 0;
|
|
80
|
-
truth.warnings.push({
|
|
81
|
-
code: 'NO_AST_CONTRACTS_FOUND',
|
|
82
|
-
message: 'No JSX Link/NavLink elements with static href/to found. No PROVEN expectations available.'
|
|
83
|
-
});
|
|
84
|
-
}
|
|
80
|
+
truth.warnings.push({
|
|
81
|
+
code: 'REACT_ROUTE_EXTRACTION_FRAGILE',
|
|
82
|
+
message: 'Route extraction uses regex parsing of source files. Dynamic routes, nested routers, and code-split route definitions may be missed.'
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
truth.expectationsDiscovered = routes.length;
|
|
86
|
+
truth.expectationsStrong = routes.length;
|
|
87
|
+
truth.expectationsWeak = 0;
|
|
85
88
|
} else if (projectType === 'unknown') {
|
|
86
89
|
truth.routesSource = 'none';
|
|
87
90
|
truth.routesConfidence = 'LOW';
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import ts from 'typescript';
|
|
11
11
|
import { resolve, relative, dirname, sep, join } from 'path';
|
|
12
|
-
import {
|
|
12
|
+
import { existsSync, statSync } from 'fs';
|
|
13
13
|
import { glob } from 'glob';
|
|
14
14
|
|
|
15
15
|
const MAX_DEPTH = 3;
|
|
@@ -45,14 +45,8 @@ export async function resolveActionContracts(rootDir, workspaceRoot) {
|
|
|
45
45
|
const checker = program.getTypeChecker();
|
|
46
46
|
const contracts = [];
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
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) {
|
|
48
|
+
function createVisitFunction(importMap, sourceFile) {
|
|
49
|
+
return function visit(node) {
|
|
56
50
|
if (ts.isJsxAttribute(node)) {
|
|
57
51
|
const name = node.name.getText();
|
|
58
52
|
if (name !== 'onClick' && name !== 'onSubmit') return;
|
|
@@ -64,7 +58,7 @@ export async function resolveActionContracts(rootDir, workspaceRoot) {
|
|
|
64
58
|
// We only handle identifier handlers (cross-file capable)
|
|
65
59
|
if (!ts.isIdentifier(expr)) return;
|
|
66
60
|
|
|
67
|
-
const
|
|
61
|
+
const _handlerName = expr.text;
|
|
68
62
|
const handlerRef = deriveHandlerRef(expr, importMap, sourceFile, workspaceRoot);
|
|
69
63
|
if (!handlerRef) return;
|
|
70
64
|
|
|
@@ -112,8 +106,16 @@ export async function resolveActionContracts(rootDir, workspaceRoot) {
|
|
|
112
106
|
}
|
|
113
107
|
}
|
|
114
108
|
ts.forEachChild(node, visit);
|
|
115
|
-
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
116
111
|
|
|
112
|
+
for (const sourceFile of program.getSourceFiles()) {
|
|
113
|
+
const sourcePath = resolve(sourceFile.fileName);
|
|
114
|
+
if (!sourcePath.toLowerCase().startsWith(normalizedRoot.toLowerCase())) continue;
|
|
115
|
+
if (sourceFile.isDeclarationFile) continue;
|
|
116
|
+
|
|
117
|
+
const importMap = buildImportMap(sourceFile, rootDir, workspaceRoot);
|
|
118
|
+
const visit = createVisitFunction(importMap, sourceFile);
|
|
117
119
|
visit(sourceFile);
|
|
118
120
|
}
|
|
119
121
|
|
|
@@ -313,7 +315,7 @@ function analyzeStateCall(node) {
|
|
|
313
315
|
|
|
314
316
|
// Zustand: store.set(...) or setState(...)
|
|
315
317
|
if (ts.isPropertyAccessExpression(callee)) {
|
|
316
|
-
const
|
|
318
|
+
const _obj = callee.expression;
|
|
317
319
|
const prop = callee.name;
|
|
318
320
|
// Common pattern: storeObj.set(...)
|
|
319
321
|
if (prop.text === 'set') {
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARIA Announcement Sensor
|
|
3
|
+
* Tracks ARIA live regions, status/alert roles, and announcements
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class AriaSensor {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.ariaStateBefore = null;
|
|
9
|
+
this.ariaStateAfter = null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Capture ARIA state before interaction
|
|
14
|
+
*/
|
|
15
|
+
async captureBefore(page) {
|
|
16
|
+
const ariaData = await page.evaluate(() => {
|
|
17
|
+
const result = {
|
|
18
|
+
liveRegions: [],
|
|
19
|
+
statusRoles: [],
|
|
20
|
+
alerts: [],
|
|
21
|
+
ariaBusyElements: [],
|
|
22
|
+
ariaLive: [],
|
|
23
|
+
announcements: []
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Find all live regions
|
|
27
|
+
const liveRegions = document.querySelectorAll('[aria-live]');
|
|
28
|
+
liveRegions.forEach(el => {
|
|
29
|
+
result.liveRegions.push({
|
|
30
|
+
selector: generateSelector(el),
|
|
31
|
+
ariaLive: el.getAttribute('aria-live'),
|
|
32
|
+
text: el.textContent?.slice(0, 100) || '',
|
|
33
|
+
ariaAtomic: el.getAttribute('aria-atomic'),
|
|
34
|
+
ariaRelevant: el.getAttribute('aria-relevant')
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Find status and alert roles
|
|
39
|
+
const statusAlerts = document.querySelectorAll('[role="status"], [role="alert"]');
|
|
40
|
+
statusAlerts.forEach(el => {
|
|
41
|
+
result.statusRoles.push({
|
|
42
|
+
selector: generateSelector(el),
|
|
43
|
+
role: el.getAttribute('role'),
|
|
44
|
+
text: el.textContent?.slice(0, 100) || '',
|
|
45
|
+
ariaLive: el.getAttribute('aria-live')
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Find aria-busy elements
|
|
50
|
+
const busyElements = document.querySelectorAll('[aria-busy="true"]');
|
|
51
|
+
busyElements.forEach(el => {
|
|
52
|
+
result.ariaBusyElements.push({
|
|
53
|
+
selector: generateSelector(el),
|
|
54
|
+
ariaBusy: el.getAttribute('aria-busy')
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return result;
|
|
59
|
+
|
|
60
|
+
function generateSelector(el) {
|
|
61
|
+
if (el.id) return `#${el.id}`;
|
|
62
|
+
if (el.className) {
|
|
63
|
+
const classes = Array.from(el.classList || []).slice(0, 2).join('.');
|
|
64
|
+
return el.tagName.toLowerCase() + (classes ? `.${classes}` : '');
|
|
65
|
+
}
|
|
66
|
+
return el.tagName.toLowerCase();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
this.ariaStateBefore = ariaData;
|
|
71
|
+
return ariaData;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Capture ARIA state after interaction
|
|
76
|
+
*/
|
|
77
|
+
async captureAfter(page) {
|
|
78
|
+
const ariaData = await page.evaluate(() => {
|
|
79
|
+
const result = {
|
|
80
|
+
liveRegions: [],
|
|
81
|
+
statusRoles: [],
|
|
82
|
+
alerts: [],
|
|
83
|
+
ariaBusyElements: [],
|
|
84
|
+
ariaLive: [],
|
|
85
|
+
announcements: []
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Find all live regions
|
|
89
|
+
const liveRegions = document.querySelectorAll('[aria-live]');
|
|
90
|
+
liveRegions.forEach(el => {
|
|
91
|
+
result.liveRegions.push({
|
|
92
|
+
selector: generateSelector(el),
|
|
93
|
+
ariaLive: el.getAttribute('aria-live'),
|
|
94
|
+
text: el.textContent?.slice(0, 100) || '',
|
|
95
|
+
ariaAtomic: el.getAttribute('aria-atomic'),
|
|
96
|
+
ariaRelevant: el.getAttribute('aria-relevant')
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Find status and alert roles
|
|
101
|
+
const statusAlerts = document.querySelectorAll('[role="status"], [role="alert"]');
|
|
102
|
+
statusAlerts.forEach(el => {
|
|
103
|
+
result.statusRoles.push({
|
|
104
|
+
selector: generateSelector(el),
|
|
105
|
+
role: el.getAttribute('role'),
|
|
106
|
+
text: el.textContent?.slice(0, 100) || '',
|
|
107
|
+
ariaLive: el.getAttribute('aria-live')
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Find aria-busy elements
|
|
112
|
+
const busyElements = document.querySelectorAll('[aria-busy="true"]');
|
|
113
|
+
busyElements.forEach(el => {
|
|
114
|
+
result.ariaBusyElements.push({
|
|
115
|
+
selector: generateSelector(el),
|
|
116
|
+
ariaBusy: el.getAttribute('aria-busy')
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return result;
|
|
121
|
+
|
|
122
|
+
function generateSelector(el) {
|
|
123
|
+
if (el.id) return `#${el.id}`;
|
|
124
|
+
if (el.className) {
|
|
125
|
+
const classes = Array.from(el.classList || []).slice(0, 2).join('.');
|
|
126
|
+
return el.tagName.toLowerCase() + (classes ? `.${classes}` : '');
|
|
127
|
+
}
|
|
128
|
+
return el.tagName.toLowerCase();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
this.ariaStateAfter = ariaData;
|
|
133
|
+
return ariaData;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Detect if ARIA state changed (live region updates, role changes, etc)
|
|
138
|
+
*/
|
|
139
|
+
detectAriaChange() {
|
|
140
|
+
if (!this.ariaStateBefore || !this.ariaStateAfter) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Check if live region text changed
|
|
145
|
+
const beforeLiveText = this.ariaStateBefore.liveRegions.map(r => r.text).join('|');
|
|
146
|
+
const afterLiveText = this.ariaStateAfter.liveRegions.map(r => r.text).join('|');
|
|
147
|
+
|
|
148
|
+
if (beforeLiveText !== afterLiveText) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Check if status/alert role text changed
|
|
153
|
+
const beforeStatusText = this.ariaStateBefore.statusRoles.map(r => r.text).join('|');
|
|
154
|
+
const afterStatusText = this.ariaStateAfter.statusRoles.map(r => r.text).join('|');
|
|
155
|
+
|
|
156
|
+
if (beforeStatusText !== afterStatusText) {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Check if aria-busy state changed
|
|
161
|
+
const beforeBusy = this.ariaStateBefore.ariaBusyElements.length;
|
|
162
|
+
const afterBusy = this.ariaStateAfter.ariaBusyElements.length;
|
|
163
|
+
|
|
164
|
+
if (beforeBusy !== afterBusy) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Check if ARIA announcement should have occurred but didn't
|
|
173
|
+
*/
|
|
174
|
+
detectMissingAnnouncement(eventType) {
|
|
175
|
+
// eventType: 'submit', 'network_success', 'network_error', 'validation_error', etc
|
|
176
|
+
// These meaningful events should typically trigger ARIA announcements
|
|
177
|
+
|
|
178
|
+
if (!this.ariaStateBefore || !this.ariaStateAfter) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Check if any live region exists (at least one should for accessibility)
|
|
183
|
+
const hasLiveRegion = this.ariaStateAfter.liveRegions.length > 0;
|
|
184
|
+
const hasStatus = this.ariaStateAfter.statusRoles.length > 0;
|
|
185
|
+
|
|
186
|
+
if (!hasLiveRegion && !hasStatus) {
|
|
187
|
+
return true; // No ARIA announcement mechanism present
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Check if announcement actually changed
|
|
191
|
+
const ariaChanged = this.detectAriaChange();
|
|
192
|
+
|
|
193
|
+
// For meaningful events, ARIA should have changed
|
|
194
|
+
if (!ariaChanged && (eventType === 'submit' || eventType === 'network_success' || eventType === 'network_error')) {
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Get ARIA diff for evidence
|
|
203
|
+
*/
|
|
204
|
+
getAriaDiff() {
|
|
205
|
+
return {
|
|
206
|
+
before: this.ariaStateBefore,
|
|
207
|
+
after: this.ariaStateAfter,
|
|
208
|
+
changed: this.detectAriaChange()
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { chromium } from 'playwright';
|
|
2
|
-
|
|
3
|
-
const STABLE_WAIT_MS = 2000;
|
|
2
|
+
import { DEFAULT_SCAN_BUDGET } from '../shared/scan-budget.js';
|
|
4
3
|
|
|
5
4
|
export async function createBrowser() {
|
|
6
5
|
const browser = await chromium.launch({ headless: true });
|
|
@@ -11,12 +10,37 @@ export async function createBrowser() {
|
|
|
11
10
|
return { browser, page };
|
|
12
11
|
}
|
|
13
12
|
|
|
14
|
-
export async function navigateToUrl(page, url) {
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
export async function navigateToUrl(page, url, scanBudget = DEFAULT_SCAN_BUDGET) {
|
|
14
|
+
let stableWait = scanBudget.navigationStableWaitMs;
|
|
15
|
+
try {
|
|
16
|
+
if (url.startsWith('file:') || url.includes('localhost:') || url.includes('127.0.0.1')) {
|
|
17
|
+
stableWait = 200; // Short wait for local fixtures
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
// Ignore config errors
|
|
21
|
+
}
|
|
22
|
+
// Use domcontentloaded first for faster timeout, then wait for networkidle separately
|
|
23
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: scanBudget.initialNavigationTimeoutMs });
|
|
24
|
+
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {
|
|
25
|
+
// Network idle timeout is acceptable, continue
|
|
26
|
+
});
|
|
27
|
+
await page.waitForTimeout(stableWait);
|
|
17
28
|
}
|
|
18
29
|
|
|
19
30
|
export async function closeBrowser(browser) {
|
|
20
|
-
|
|
31
|
+
try {
|
|
32
|
+
// Close all contexts first
|
|
33
|
+
const contexts = browser.contexts();
|
|
34
|
+
for (const context of contexts) {
|
|
35
|
+
try {
|
|
36
|
+
await context.close({ timeout: 5000 }).catch(() => {});
|
|
37
|
+
} catch (e) {
|
|
38
|
+
// Ignore context close errors
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
await browser.close({ timeout: 5000 }).catch(() => {});
|
|
42
|
+
} catch (e) {
|
|
43
|
+
// Ignore browser close errors - best effort cleanup
|
|
44
|
+
}
|
|
21
45
|
}
|
|
22
46
|
|
|
@@ -57,7 +57,7 @@ export class ConsoleSensor {
|
|
|
57
57
|
};
|
|
58
58
|
|
|
59
59
|
// Capture unhandled promise rejections
|
|
60
|
-
const
|
|
60
|
+
const _onUnhandledRejection = (promise, reason) => {
|
|
61
61
|
const message = (reason?.toString?.() || String(reason)).slice(0, 200);
|
|
62
62
|
state.unhandledRejections.push({
|
|
63
63
|
message: message,
|
|
@@ -104,7 +104,7 @@ export class ConsoleSensor {
|
|
|
104
104
|
/**
|
|
105
105
|
* Stop monitoring and return a summary for the window.
|
|
106
106
|
*/
|
|
107
|
-
|
|
107
|
+
stopWindow(windowId, _page) {
|
|
108
108
|
const state = this.windows.get(windowId);
|
|
109
109
|
if (!state) {
|
|
110
110
|
return this.getEmptySummary();
|
|
@@ -112,22 +112,6 @@ export class ConsoleSensor {
|
|
|
112
112
|
|
|
113
113
|
state.cleanup();
|
|
114
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
115
|
const summary = {
|
|
132
116
|
windowId,
|
|
133
117
|
errorCount: state.consoleErrors.length + state.pageErrors.length,
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
export function getBaseOrigin(url) {
|
|
2
2
|
try {
|
|
3
3
|
const urlObj = new URL(url);
|
|
4
|
-
|
|
4
|
+
if (urlObj.protocol === 'file:') {
|
|
5
|
+
return 'file://';
|
|
6
|
+
}
|
|
7
|
+
return urlObj.origin;
|
|
5
8
|
} catch (error) {
|
|
6
9
|
return null;
|
|
7
10
|
}
|
|
@@ -12,6 +15,12 @@ export function isExternalUrl(url, baseOrigin) {
|
|
|
12
15
|
|
|
13
16
|
try {
|
|
14
17
|
const urlObj = new URL(url);
|
|
18
|
+
// Special-case file protocol: treat all file:// URLs as same-origin
|
|
19
|
+
const isFileProtocol = urlObj.protocol === 'file:';
|
|
20
|
+
const baseIsFile = baseOrigin.startsWith('file:');
|
|
21
|
+
if (isFileProtocol && baseIsFile) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
15
24
|
const urlOrigin = urlObj.origin;
|
|
16
25
|
return urlOrigin !== baseOrigin;
|
|
17
26
|
} catch (error) {
|