@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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +237 -0
  3. package/bin/verax.js +452 -0
  4. package/package.json +57 -0
  5. package/src/verax/detect/comparison.js +69 -0
  6. package/src/verax/detect/confidence-engine.js +498 -0
  7. package/src/verax/detect/evidence-validator.js +33 -0
  8. package/src/verax/detect/expectation-model.js +204 -0
  9. package/src/verax/detect/findings-writer.js +31 -0
  10. package/src/verax/detect/index.js +397 -0
  11. package/src/verax/detect/skip-classifier.js +202 -0
  12. package/src/verax/flow/flow-engine.js +265 -0
  13. package/src/verax/flow/flow-spec.js +145 -0
  14. package/src/verax/flow/redaction.js +74 -0
  15. package/src/verax/index.js +97 -0
  16. package/src/verax/learn/action-contract-extractor.js +281 -0
  17. package/src/verax/learn/ast-contract-extractor.js +255 -0
  18. package/src/verax/learn/index.js +18 -0
  19. package/src/verax/learn/manifest-writer.js +97 -0
  20. package/src/verax/learn/project-detector.js +87 -0
  21. package/src/verax/learn/react-router-extractor.js +73 -0
  22. package/src/verax/learn/route-extractor.js +122 -0
  23. package/src/verax/learn/route-validator.js +215 -0
  24. package/src/verax/learn/source-instrumenter.js +214 -0
  25. package/src/verax/learn/static-extractor.js +222 -0
  26. package/src/verax/learn/truth-assessor.js +96 -0
  27. package/src/verax/learn/ts-contract-resolver.js +395 -0
  28. package/src/verax/observe/browser.js +22 -0
  29. package/src/verax/observe/console-sensor.js +166 -0
  30. package/src/verax/observe/dom-signature.js +23 -0
  31. package/src/verax/observe/domain-boundary.js +38 -0
  32. package/src/verax/observe/evidence-capture.js +5 -0
  33. package/src/verax/observe/human-driver.js +376 -0
  34. package/src/verax/observe/index.js +67 -0
  35. package/src/verax/observe/interaction-discovery.js +269 -0
  36. package/src/verax/observe/interaction-runner.js +410 -0
  37. package/src/verax/observe/network-sensor.js +173 -0
  38. package/src/verax/observe/selector-generator.js +74 -0
  39. package/src/verax/observe/settle.js +155 -0
  40. package/src/verax/observe/state-ui-sensor.js +200 -0
  41. package/src/verax/observe/traces-writer.js +82 -0
  42. package/src/verax/observe/ui-signal-sensor.js +197 -0
  43. package/src/verax/resolve-workspace-root.js +173 -0
  44. package/src/verax/scan-summary-writer.js +41 -0
  45. package/src/verax/shared/artifact-manager.js +139 -0
  46. package/src/verax/shared/caching.js +104 -0
  47. package/src/verax/shared/expectation-proof.js +4 -0
  48. package/src/verax/shared/redaction.js +227 -0
  49. package/src/verax/shared/retry-policy.js +89 -0
  50. package/src/verax/shared/timing-metrics.js +44 -0
@@ -0,0 +1,97 @@
1
+ import { resolve } from 'path';
2
+ import { writeFileSync, mkdirSync } from 'fs';
3
+ import { extractStaticExpectations } from './static-extractor.js';
4
+ import { assessLearnTruth } from './truth-assessor.js';
5
+ import { ExpectationProof } from '../shared/expectation-proof.js';
6
+ import { extractASTContracts, contractsToExpectations } from './ast-contract-extractor.js';
7
+ import { scanForContracts } from './action-contract-extractor.js';
8
+ import { resolveActionContracts } from './ts-contract-resolver.js';
9
+
10
+ export async function writeManifest(projectDir, projectType, routes) {
11
+ const publicRoutes = routes.filter(r => r.public).map(r => r.path);
12
+ const internalRoutes = routes.filter(r => !r.public).map(r => r.path);
13
+
14
+ const manifest = {
15
+ version: 1,
16
+ learnedAt: new Date().toISOString(),
17
+ projectDir: projectDir,
18
+ projectType: projectType,
19
+ routes: routes.map(r => ({
20
+ path: r.path,
21
+ source: r.source,
22
+ public: r.public
23
+ })),
24
+ publicRoutes: publicRoutes,
25
+ internalRoutes: internalRoutes,
26
+ notes: []
27
+ };
28
+
29
+ let staticExpectations = null;
30
+ let spaExpectations = null;
31
+ let actionContracts = null;
32
+
33
+ if (projectType === 'static' && routes.length > 0) {
34
+ staticExpectations = await extractStaticExpectations(projectDir, routes);
35
+ manifest.staticExpectations = staticExpectations;
36
+ manifest.expectationProof = ExpectationProof.PROVEN_EXPECTATION;
37
+ } else if (projectType === 'react_spa' || projectType.startsWith('nextjs_')) {
38
+ // Wave 1 - CODE TRUTH ENGINE: Extract PROVEN contracts from AST
39
+ const contracts = await extractASTContracts(projectDir);
40
+ spaExpectations = contractsToExpectations(contracts, projectType);
41
+
42
+ if (spaExpectations.length > 0) {
43
+ manifest.spaExpectations = spaExpectations;
44
+ manifest.expectationProof = ExpectationProof.PROVEN_EXPECTATION;
45
+ } else {
46
+ manifest.expectationProof = ExpectationProof.UNKNOWN_EXPECTATION;
47
+ }
48
+ } else {
49
+ manifest.expectationProof = ExpectationProof.UNKNOWN_EXPECTATION;
50
+ }
51
+
52
+ // Wave 5/6 - ACTION CONTRACTS: Extract network action contracts (Babel inline + TS cross-file)
53
+ const shouldExtractActions = projectType === 'react_spa' || projectType.startsWith('nextjs_');
54
+ if (shouldExtractActions) {
55
+ const [babelContracts, tsContracts] = await Promise.all([
56
+ scanForContracts(projectDir, projectDir),
57
+ resolveActionContracts(projectDir, projectDir).catch(() => [])
58
+ ]);
59
+ const merged = dedupeActionContracts([...(babelContracts || []), ...(tsContracts || [])]);
60
+ if (merged.length > 0) {
61
+ actionContracts = merged;
62
+ manifest.actionContracts = actionContracts;
63
+ }
64
+ }
65
+
66
+ const learnTruth = await assessLearnTruth(projectDir, projectType, routes, staticExpectations, spaExpectations);
67
+ manifest.notes.push({
68
+ type: 'truth',
69
+ learn: learnTruth
70
+ });
71
+
72
+ const manifestDir = resolve(projectDir, '.verax', 'learn');
73
+ mkdirSync(manifestDir, { recursive: true });
74
+
75
+ const manifestPath = resolve(manifestDir, 'site-manifest.json');
76
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
77
+
78
+ return {
79
+ ...manifest,
80
+ manifestPath: manifestPath,
81
+ learnTruth: learnTruth,
82
+ actionContracts
83
+ };
84
+ }
85
+
86
+ function dedupeActionContracts(contracts) {
87
+ const seen = new Set();
88
+ const out = [];
89
+ for (const c of contracts) {
90
+ const key = `${c.kind}|${c.method}|${c.urlPath}|${c.source || ''}|${c.handlerRef || ''}`;
91
+ if (seen.has(key)) continue;
92
+ seen.add(key);
93
+ out.push(c);
94
+ }
95
+ return out;
96
+ }
97
+
@@ -0,0 +1,87 @@
1
+ import { glob } from 'glob';
2
+ import { resolve } from 'path';
3
+ import { existsSync, readFileSync } from 'fs';
4
+
5
+ async function hasReactDependency(projectDir) {
6
+ try {
7
+ const packageJsonPath = resolve(projectDir, 'package.json');
8
+ if (!existsSync(packageJsonPath)) return false;
9
+
10
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
11
+ const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
12
+
13
+ return !!deps.react;
14
+ } catch (error) {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ async function hasNextJs(projectDir) {
20
+ try {
21
+ const packageJsonPath = resolve(projectDir, 'package.json');
22
+ if (!existsSync(packageJsonPath)) return false;
23
+
24
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
25
+ const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
26
+
27
+ return !!deps.next;
28
+ } catch (error) {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ async function hasReactRouter(projectDir) {
34
+ try {
35
+ const packageJsonPath = resolve(projectDir, 'package.json');
36
+ if (!existsSync(packageJsonPath)) return false;
37
+
38
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
39
+ const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
40
+
41
+ return !!deps['react-router-dom'] || !!deps['react-router'];
42
+ } catch (error) {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ export async function detectProjectType(projectDir) {
48
+ const appDir = resolve(projectDir, 'app');
49
+ const pagesDir = resolve(projectDir, 'pages');
50
+
51
+ try {
52
+ if (existsSync(appDir)) {
53
+ const appFiles = await glob('**/page.{js,jsx,ts,tsx}', { cwd: appDir, absolute: false });
54
+ if (appFiles.length > 0) {
55
+ return 'nextjs_app_router';
56
+ }
57
+ }
58
+
59
+ if (existsSync(pagesDir)) {
60
+ const pagesFiles = await glob('**/*.{js,jsx,ts,tsx}', { cwd: pagesDir, absolute: false });
61
+ if (pagesFiles.length > 0) {
62
+ return 'nextjs_pages_router';
63
+ }
64
+ }
65
+
66
+ const hasReact = await hasReactDependency(projectDir);
67
+ const hasNext = await hasNextJs(projectDir);
68
+
69
+ if (hasReact && !hasNext) {
70
+ return 'react_spa';
71
+ }
72
+
73
+ const htmlFiles = await glob('**/*.html', { cwd: projectDir, absolute: false, ignore: ['node_modules/**'] });
74
+ if (htmlFiles.length > 0) {
75
+ return 'static';
76
+ }
77
+ } catch (error) {
78
+ // Fall through to unknown
79
+ }
80
+
81
+ return 'unknown';
82
+ }
83
+
84
+ export async function hasReactRouterDom(projectDir) {
85
+ return await hasReactRouter(projectDir);
86
+ }
87
+
@@ -0,0 +1,73 @@
1
+ import { glob } from 'glob';
2
+ import { readFileSync } from 'fs';
3
+ import { resolve } from 'path';
4
+
5
+ export async function extractReactRouterRoutes(projectDir) {
6
+ const routes = [];
7
+ const routeSet = new Set();
8
+
9
+ const jsFiles = await glob('**/*.{js,jsx,ts,tsx}', {
10
+ cwd: projectDir,
11
+ absolute: false,
12
+ ignore: ['node_modules/**', 'dist/**', 'build/**', '.next/**']
13
+ });
14
+
15
+ for (const file of jsFiles.slice(0, 200)) {
16
+ try {
17
+ const content = readFileSync(resolve(projectDir, file), 'utf-8');
18
+
19
+ const routePatterns = [
20
+ /<Route\s+path=["']([^"']+)["']/g,
21
+ /path:\s*["']([^"']+)["']/g,
22
+ /path\s*:\s*["']([^"']+)["']/g,
23
+ /createBrowserRouter\s*\(\s*\[([^\]]+)\]/gs,
24
+ /createRoutesFromElements\s*\([^)]*\)/gs
25
+ ];
26
+
27
+ for (const pattern of routePatterns) {
28
+ let match;
29
+ while ((match = pattern.exec(content)) !== null) {
30
+ let path = match[1];
31
+
32
+ if (!path) {
33
+ if (match[0].includes('createBrowserRouter')) {
34
+ const routesMatch = content.match(/createBrowserRouter\s*\(\s*\[([^\]]+)\]/s);
35
+ if (routesMatch) {
36
+ const routesContent = routesMatch[1];
37
+ const pathMatches = routesContent.matchAll(/path\s*:\s*["']([^"']+)["']/g);
38
+ for (const pathMatch of pathMatches) {
39
+ path = pathMatch[1];
40
+ if (path && !routeSet.has(path)) {
41
+ routeSet.add(path);
42
+ routes.push({
43
+ path: path,
44
+ source: file,
45
+ public: true
46
+ });
47
+ }
48
+ }
49
+ }
50
+ }
51
+ continue;
52
+ }
53
+
54
+ if (path && !routeSet.has(path)) {
55
+ routeSet.add(path);
56
+ routes.push({
57
+ path: path,
58
+ source: file,
59
+ public: true
60
+ });
61
+ }
62
+ }
63
+ }
64
+ } catch (error) {
65
+ continue;
66
+ }
67
+ }
68
+
69
+ routes.sort((a, b) => a.path.localeCompare(b.path));
70
+
71
+ return routes;
72
+ }
73
+
@@ -0,0 +1,122 @@
1
+ import { glob } from 'glob';
2
+ import { resolve } from 'path';
3
+
4
+ const INTERNAL_PATH_PATTERNS = [
5
+ /^\/admin/,
6
+ /^\/dashboard/,
7
+ /^\/account/,
8
+ /^\/settings/,
9
+ /\/internal/,
10
+ /\/private/
11
+ ];
12
+
13
+ function isInternalRoute(path) {
14
+ return INTERNAL_PATH_PATTERNS.some(pattern => pattern.test(path));
15
+ }
16
+
17
+ function fileToAppRouterPath(file) {
18
+ let path = file.replace(/[\\\/]/g, '/');
19
+ path = path.replace(/(^|\/)page\.(js|jsx|ts|tsx)$/, '');
20
+ path = path.replace(/^\./, '');
21
+
22
+ if (path === '' || path === '/') {
23
+ return '/';
24
+ }
25
+
26
+ if (!path.startsWith('/')) {
27
+ path = '/' + path;
28
+ }
29
+
30
+ path = path.replace(/\[([^\]]+)\]/g, ':$1');
31
+ path = path.replace(/\([^)]+\)/g, '');
32
+
33
+ return path || '/';
34
+ }
35
+
36
+ function fileToPagesRouterPath(file) {
37
+ let path = file.replace(/[\\\/]/g, '/');
38
+ path = path.replace(/\.(js|jsx|ts|tsx)$/, '');
39
+ path = path.replace(/^index$/, '');
40
+ path = path.replace(/^\./, '');
41
+
42
+ if (path === '' || path === '/') {
43
+ return '/';
44
+ }
45
+ if (!path.startsWith('/')) {
46
+ path = '/' + path;
47
+ }
48
+
49
+ path = path.replace(/\[([^\]]+)\]/g, ':$1');
50
+
51
+ return path || '/';
52
+ }
53
+
54
+ import { extractStaticRoutes } from './static-extractor.js';
55
+ import { extractReactRouterRoutes } from './react-router-extractor.js';
56
+ import { hasReactRouterDom } from './project-detector.js';
57
+
58
+ export async function extractRoutes(projectDir, projectType) {
59
+ const routes = [];
60
+ const routeSet = new Set();
61
+
62
+ if (projectType === 'static') {
63
+ return await extractStaticRoutes(projectDir);
64
+ }
65
+
66
+ if (projectType === 'react_spa') {
67
+ const hasRouter = await hasReactRouterDom(projectDir);
68
+ if (hasRouter) {
69
+ return await extractReactRouterRoutes(projectDir);
70
+ }
71
+ return [];
72
+ }
73
+
74
+ if (projectType === 'nextjs_app_router') {
75
+ const appDir = resolve(projectDir, 'app');
76
+ const pageFiles = await glob('**/page.{js,jsx,ts,tsx}', {
77
+ cwd: appDir,
78
+ absolute: false,
79
+ ignore: ['node_modules/**']
80
+ });
81
+
82
+ for (const file of pageFiles) {
83
+ const routePath = fileToAppRouterPath(file);
84
+ const routeKey = routePath;
85
+
86
+ if (!routeSet.has(routeKey)) {
87
+ routeSet.add(routeKey);
88
+ routes.push({
89
+ path: routePath,
90
+ source: `app/${file}`,
91
+ public: !isInternalRoute(routePath)
92
+ });
93
+ }
94
+ }
95
+ } else if (projectType === 'nextjs_pages_router') {
96
+ const pagesDir = resolve(projectDir, 'pages');
97
+ const pageFiles = await glob('**/*.{js,jsx,ts,tsx}', {
98
+ cwd: pagesDir,
99
+ absolute: false,
100
+ ignore: ['node_modules/**', '_app.*', '_document.*', '_error.*']
101
+ });
102
+
103
+ for (const file of pageFiles) {
104
+ const routePath = fileToPagesRouterPath(file);
105
+ const routeKey = routePath;
106
+
107
+ if (!routeSet.has(routeKey)) {
108
+ routeSet.add(routeKey);
109
+ routes.push({
110
+ path: routePath,
111
+ source: `pages/${file}`,
112
+ public: !isInternalRoute(routePath)
113
+ });
114
+ }
115
+ }
116
+ }
117
+
118
+ routes.sort((a, b) => a.path.localeCompare(b.path));
119
+
120
+ return routes;
121
+ }
122
+
@@ -0,0 +1,215 @@
1
+ import { chromium } from 'playwright';
2
+ import { isExternalUrl } from '../observe/domain-boundary.js';
3
+
4
+ const VALIDATION_TIMEOUT_MS = 8000;
5
+ const MAX_ROUTES_TO_VALIDATE = 50;
6
+
7
+ export async function validateRoutes(manifest, baseUrl) {
8
+ let baseOrigin;
9
+ try {
10
+ const urlObj = new URL(baseUrl);
11
+ baseOrigin = urlObj.origin;
12
+ } catch (error) {
13
+ return {
14
+ routesValidated: 0,
15
+ routesReachable: 0,
16
+ routesUnreachable: 0,
17
+ details: [],
18
+ warnings: []
19
+ };
20
+ }
21
+
22
+ const projectType = manifest.projectType;
23
+ const shouldValidate = projectType === 'nextjs_app_router' ||
24
+ projectType === 'nextjs_pages_router' ||
25
+ projectType === 'react_spa' ||
26
+ projectType === 'static';
27
+
28
+ if (!shouldValidate) {
29
+ return {
30
+ routesValidated: 0,
31
+ routesReachable: 0,
32
+ routesUnreachable: 0,
33
+ details: [],
34
+ warnings: []
35
+ };
36
+ }
37
+
38
+ const publicRoutes = manifest.publicRoutes || [];
39
+ if (publicRoutes.length === 0) {
40
+ return {
41
+ routesValidated: 0,
42
+ routesReachable: 0,
43
+ routesUnreachable: 0,
44
+ details: [],
45
+ warnings: []
46
+ };
47
+ }
48
+
49
+ const routesToValidate = publicRoutes.slice(0, MAX_ROUTES_TO_VALIDATE);
50
+ const wasCapped = publicRoutes.length > MAX_ROUTES_TO_VALIDATE;
51
+
52
+ const browser = await chromium.launch({ headless: true });
53
+ const context = await browser.newContext({
54
+ viewport: { width: 1280, height: 720 }
55
+ });
56
+ const page = await context.newPage();
57
+
58
+ const details = [];
59
+ let reachableCount = 0;
60
+ let unreachableCount = 0;
61
+
62
+ try {
63
+ for (const routePath of routesToValidate) {
64
+ page.removeAllListeners('response');
65
+ let routeUrl;
66
+ let normalizedPath;
67
+
68
+ if (routePath.startsWith('http://') || routePath.startsWith('https://')) {
69
+ routeUrl = routePath;
70
+ } else {
71
+ normalizedPath = routePath.startsWith('/') ? routePath : '/' + routePath;
72
+ if (normalizedPath === '') {
73
+ normalizedPath = '/';
74
+ }
75
+ routeUrl = baseOrigin + normalizedPath;
76
+ }
77
+
78
+ let status = 'UNREACHABLE';
79
+ let httpStatus = null;
80
+ let finalUrl = null;
81
+ let reason = null;
82
+
83
+ let redirectTargetUrl = null;
84
+ let hasExternalRedirect = false;
85
+
86
+ try {
87
+ const response = await page.goto(routeUrl, {
88
+ waitUntil: 'domcontentloaded',
89
+ timeout: VALIDATION_TIMEOUT_MS
90
+ });
91
+
92
+ await page.waitForTimeout(300);
93
+
94
+ finalUrl = page.url();
95
+
96
+ // Check redirect chain in response object - PRIMARY detection method
97
+ if (response) {
98
+ httpStatus = response.status();
99
+
100
+ const request = response.request();
101
+
102
+ // Use redirectChain() if available (preferred), otherwise use redirectedFrom()
103
+ let redirectChain = [];
104
+ if (typeof request.redirectChain === 'function') {
105
+ try {
106
+ redirectChain = request.redirectChain();
107
+ } catch (e) {
108
+ // redirectChain may not be available, fall back to redirectedFrom
109
+ }
110
+ }
111
+
112
+ // If redirectChain is empty or not available, build chain from redirectedFrom
113
+ if (redirectChain.length === 0) {
114
+ let currentRequest = request.redirectedFrom();
115
+ while (currentRequest) {
116
+ redirectChain.unshift(currentRequest);
117
+ currentRequest = currentRequest.redirectedFrom();
118
+ }
119
+ }
120
+
121
+ // Check each request in the redirect chain for external origin
122
+ for (const chainRequest of redirectChain) {
123
+ const chainRequestUrl = chainRequest.url();
124
+ try {
125
+ const chainUrlObj = new URL(chainRequestUrl);
126
+ if (chainUrlObj.origin !== baseOrigin) {
127
+ hasExternalRedirect = true;
128
+ redirectTargetUrl = chainRequestUrl;
129
+ break;
130
+ }
131
+ } catch (e) {
132
+ // URL parsing failed, try with isExternalUrl
133
+ if (isExternalUrl(chainRequestUrl, baseOrigin)) {
134
+ hasExternalRedirect = true;
135
+ redirectTargetUrl = chainRequestUrl;
136
+ break;
137
+ }
138
+ }
139
+ }
140
+ }
141
+
142
+ if (hasExternalRedirect) {
143
+ status = 'UNREACHABLE';
144
+ reason = 'external_redirect_blocked';
145
+ finalUrl = redirectTargetUrl || finalUrl;
146
+ unreachableCount++;
147
+ } else if (isExternalUrl(finalUrl, baseOrigin)) {
148
+ status = 'UNREACHABLE';
149
+ reason = 'external_redirect_blocked';
150
+ unreachableCount++;
151
+ } else if (response) {
152
+ if (httpStatus >= 200 && httpStatus < 300) {
153
+ status = 'REACHABLE';
154
+ reachableCount++;
155
+ } else {
156
+ status = 'UNREACHABLE';
157
+ reason = `http_${httpStatus}`;
158
+ unreachableCount++;
159
+ }
160
+ } else {
161
+ status = 'REACHABLE';
162
+ reachableCount++;
163
+ }
164
+ } catch (error) {
165
+ if (error.name === 'TimeoutError' || error.message.includes('timeout')) {
166
+ reason = 'timeout';
167
+ } else if (error.message.includes('net::ERR')) {
168
+ reason = 'network_error';
169
+ } else {
170
+ reason = 'navigation_failed';
171
+ }
172
+ unreachableCount++;
173
+ }
174
+
175
+ details.push({
176
+ path: routePath,
177
+ status: status,
178
+ httpStatus: httpStatus,
179
+ finalUrl: finalUrl,
180
+ reason: reason
181
+ });
182
+ }
183
+ } finally {
184
+ await browser.close();
185
+ }
186
+
187
+ const warnings = [];
188
+
189
+ if (wasCapped) {
190
+ warnings.push({
191
+ code: 'ROUTE_VALIDATION_CAPPED',
192
+ message: `Route validation capped at ${MAX_ROUTES_TO_VALIDATE} routes. ${publicRoutes.length - MAX_ROUTES_TO_VALIDATE} routes were not validated.`
193
+ });
194
+ }
195
+
196
+ const totalValidated = details.length;
197
+ if (totalValidated > 0) {
198
+ const reachabilityRatio = reachableCount / totalValidated;
199
+ if (reachabilityRatio < 0.5) {
200
+ warnings.push({
201
+ code: 'LOW_ROUTE_REACHABILITY',
202
+ message: `Only ${reachableCount} of ${totalValidated} validated routes are reachable (${Math.round(reachabilityRatio * 100)}%). This may indicate incorrect route extraction or server configuration issues.`
203
+ });
204
+ }
205
+ }
206
+
207
+ return {
208
+ routesValidated: totalValidated,
209
+ routesReachable: reachableCount,
210
+ routesUnreachable: unreachableCount,
211
+ details: details,
212
+ warnings: warnings
213
+ };
214
+ }
215
+