@veraxhq/verax 0.1.0 → 0.2.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 (126) hide show
  1. package/README.md +123 -88
  2. package/bin/verax.js +11 -452
  3. package/package.json +14 -36
  4. package/src/cli/commands/default.js +523 -0
  5. package/src/cli/commands/doctor.js +165 -0
  6. package/src/cli/commands/inspect.js +109 -0
  7. package/src/cli/commands/run.js +402 -0
  8. package/src/cli/entry.js +196 -0
  9. package/src/cli/util/atomic-write.js +37 -0
  10. package/src/cli/util/detection-engine.js +296 -0
  11. package/src/cli/util/env-url.js +33 -0
  12. package/src/cli/util/errors.js +44 -0
  13. package/src/cli/util/events.js +34 -0
  14. package/src/cli/util/expectation-extractor.js +378 -0
  15. package/src/cli/util/findings-writer.js +31 -0
  16. package/src/cli/util/idgen.js +87 -0
  17. package/src/cli/util/learn-writer.js +39 -0
  18. package/src/cli/util/observation-engine.js +366 -0
  19. package/src/cli/util/observe-writer.js +25 -0
  20. package/src/cli/util/paths.js +29 -0
  21. package/src/cli/util/project-discovery.js +277 -0
  22. package/src/cli/util/project-writer.js +26 -0
  23. package/src/cli/util/redact.js +128 -0
  24. package/src/cli/util/run-id.js +30 -0
  25. package/src/cli/util/summary-writer.js +32 -0
  26. package/src/verax/cli/ci-summary.js +35 -0
  27. package/src/verax/cli/context-explanation.js +89 -0
  28. package/src/verax/cli/doctor.js +277 -0
  29. package/src/verax/cli/error-normalizer.js +154 -0
  30. package/src/verax/cli/explain-output.js +105 -0
  31. package/src/verax/cli/finding-explainer.js +130 -0
  32. package/src/verax/cli/init.js +237 -0
  33. package/src/verax/cli/run-overview.js +163 -0
  34. package/src/verax/cli/url-safety.js +101 -0
  35. package/src/verax/cli/wizard.js +98 -0
  36. package/src/verax/cli/zero-findings-explainer.js +57 -0
  37. package/src/verax/cli/zero-interaction-explainer.js +127 -0
  38. package/src/verax/core/action-classifier.js +86 -0
  39. package/src/verax/core/budget-engine.js +218 -0
  40. package/src/verax/core/canonical-outcomes.js +157 -0
  41. package/src/verax/core/decision-snapshot.js +335 -0
  42. package/src/verax/core/determinism-model.js +403 -0
  43. package/src/verax/core/incremental-store.js +237 -0
  44. package/src/verax/core/invariants.js +356 -0
  45. package/src/verax/core/promise-model.js +230 -0
  46. package/src/verax/core/replay-validator.js +350 -0
  47. package/src/verax/core/replay.js +222 -0
  48. package/src/verax/core/run-id.js +175 -0
  49. package/src/verax/core/run-manifest.js +99 -0
  50. package/src/verax/core/silence-impact.js +369 -0
  51. package/src/verax/core/silence-model.js +521 -0
  52. package/src/verax/detect/comparison.js +2 -34
  53. package/src/verax/detect/confidence-engine.js +764 -329
  54. package/src/verax/detect/detection-engine.js +293 -0
  55. package/src/verax/detect/evidence-index.js +177 -0
  56. package/src/verax/detect/expectation-model.js +194 -172
  57. package/src/verax/detect/explanation-helpers.js +187 -0
  58. package/src/verax/detect/finding-detector.js +450 -0
  59. package/src/verax/detect/findings-writer.js +44 -8
  60. package/src/verax/detect/flow-detector.js +366 -0
  61. package/src/verax/detect/index.js +172 -286
  62. package/src/verax/detect/interactive-findings.js +613 -0
  63. package/src/verax/detect/signal-mapper.js +308 -0
  64. package/src/verax/detect/verdict-engine.js +563 -0
  65. package/src/verax/evidence-index-writer.js +61 -0
  66. package/src/verax/index.js +90 -14
  67. package/src/verax/intel/effect-detector.js +368 -0
  68. package/src/verax/intel/handler-mapper.js +249 -0
  69. package/src/verax/intel/index.js +281 -0
  70. package/src/verax/intel/route-extractor.js +280 -0
  71. package/src/verax/intel/ts-program.js +256 -0
  72. package/src/verax/intel/vue-navigation-extractor.js +579 -0
  73. package/src/verax/intel/vue-router-extractor.js +323 -0
  74. package/src/verax/learn/action-contract-extractor.js +335 -101
  75. package/src/verax/learn/ast-contract-extractor.js +95 -5
  76. package/src/verax/learn/flow-extractor.js +172 -0
  77. package/src/verax/learn/manifest-writer.js +97 -47
  78. package/src/verax/learn/project-detector.js +40 -0
  79. package/src/verax/learn/route-extractor.js +27 -96
  80. package/src/verax/learn/state-extractor.js +212 -0
  81. package/src/verax/learn/static-extractor-navigation.js +114 -0
  82. package/src/verax/learn/static-extractor-validation.js +88 -0
  83. package/src/verax/learn/static-extractor.js +112 -4
  84. package/src/verax/learn/truth-assessor.js +24 -21
  85. package/src/verax/observe/aria-sensor.js +211 -0
  86. package/src/verax/observe/browser.js +10 -5
  87. package/src/verax/observe/console-sensor.js +1 -17
  88. package/src/verax/observe/domain-boundary.js +10 -1
  89. package/src/verax/observe/expectation-executor.js +512 -0
  90. package/src/verax/observe/flow-matcher.js +143 -0
  91. package/src/verax/observe/focus-sensor.js +196 -0
  92. package/src/verax/observe/human-driver.js +643 -275
  93. package/src/verax/observe/index.js +908 -27
  94. package/src/verax/observe/index.js.backup +1 -0
  95. package/src/verax/observe/interaction-discovery.js +365 -14
  96. package/src/verax/observe/interaction-runner.js +563 -198
  97. package/src/verax/observe/loading-sensor.js +139 -0
  98. package/src/verax/observe/navigation-sensor.js +255 -0
  99. package/src/verax/observe/network-sensor.js +55 -7
  100. package/src/verax/observe/observed-expectation-deriver.js +186 -0
  101. package/src/verax/observe/observed-expectation.js +305 -0
  102. package/src/verax/observe/page-frontier.js +234 -0
  103. package/src/verax/observe/settle.js +37 -17
  104. package/src/verax/observe/state-sensor.js +389 -0
  105. package/src/verax/observe/timing-sensor.js +228 -0
  106. package/src/verax/observe/traces-writer.js +61 -20
  107. package/src/verax/observe/ui-signal-sensor.js +136 -17
  108. package/src/verax/scan-summary-writer.js +77 -15
  109. package/src/verax/shared/artifact-manager.js +110 -8
  110. package/src/verax/shared/budget-profiles.js +136 -0
  111. package/src/verax/shared/ci-detection.js +39 -0
  112. package/src/verax/shared/config-loader.js +170 -0
  113. package/src/verax/shared/dynamic-route-utils.js +218 -0
  114. package/src/verax/shared/expectation-coverage.js +44 -0
  115. package/src/verax/shared/expectation-prover.js +81 -0
  116. package/src/verax/shared/expectation-tracker.js +201 -0
  117. package/src/verax/shared/expectations-writer.js +60 -0
  118. package/src/verax/shared/first-run.js +44 -0
  119. package/src/verax/shared/progress-reporter.js +171 -0
  120. package/src/verax/shared/retry-policy.js +14 -1
  121. package/src/verax/shared/root-artifacts.js +49 -0
  122. package/src/verax/shared/scan-budget.js +86 -0
  123. package/src/verax/shared/url-normalizer.js +162 -0
  124. package/src/verax/shared/zip-artifacts.js +65 -0
  125. package/src/verax/validate/context-validator.js +244 -0
  126. package/src/verax/validate/context-validator.js.bak +0 -0
@@ -0,0 +1,579 @@
1
+ /**
2
+ * CODE INTELLIGENCE v1 — Vue Navigation Promise Extraction (AST-based)
3
+ *
4
+ * Extracts navigation promises from Vue components:
5
+ * - <router-link to="/path">
6
+ * - <RouterLink :to="{ path: '/path' }">
7
+ * - router.push('/path'), router.replace('/path')
8
+ * - Dynamic targets: router.push(`/users/${id}`)
9
+ */
10
+
11
+ import ts from 'typescript';
12
+ import { parseFile, findNodes, getStringLiteral, getNodeLocation } from './ts-program.js';
13
+ import { readFileSync, existsSync } from 'fs';
14
+ import { resolve, extname } from 'path';
15
+ import { normalizeDynamicRoute, normalizeTemplateLiteral } from '../shared/dynamic-route-utils.js';
16
+
17
+ /**
18
+ * Extract navigation promises from Vue components.
19
+ *
20
+ * @param {Object} program - TypeScript program
21
+ * @param {string} projectRoot - Project root
22
+ * @returns {Promise<Array>} - Array of navigation expectation objects
23
+ */
24
+ export async function extractVueNavigationPromises(program, projectRoot) {
25
+ const expectations = [];
26
+
27
+ if (!program || !program.program) return expectations;
28
+
29
+ // Find Vue component files (.vue, .ts, .js, .tsx, .jsx)
30
+ const vueFiles = [];
31
+ const sourceFiles = program.getSourceFiles ? program.getSourceFiles() : (program.sourceFiles || []);
32
+
33
+ for (const sourceFile of sourceFiles) {
34
+ // sourceFiles can be either file paths (strings) or SourceFile objects
35
+ const filePath = typeof sourceFile === 'string' ? sourceFile : (sourceFile.fileName || sourceFile);
36
+ if (!filePath) continue;
37
+ const ext = extname(filePath);
38
+ if (['.vue', '.ts', '.js', '.tsx', '.jsx'].includes(ext)) {
39
+ vueFiles.push(filePath);
40
+ }
41
+ }
42
+
43
+ // Also explicitly glob for .vue files since TypeScript might not include them
44
+ try {
45
+ const { glob } = await import('glob');
46
+ const vueGlobPattern = resolve(projectRoot, '**/*.vue');
47
+ const vueFilesFromGlob = await glob(vueGlobPattern, { cwd: projectRoot });
48
+ for (const filePath of vueFilesFromGlob) {
49
+ const absPath = resolve(projectRoot, filePath);
50
+ if (!vueFiles.includes(absPath) && !vueFiles.find(f => f.endsWith(filePath))) {
51
+ vueFiles.push(absPath);
52
+ }
53
+ }
54
+ } catch (err) {
55
+ // glob not available, skip
56
+ }
57
+
58
+ for (const filePath of vueFiles) {
59
+ const fileExpectations = extractFromFile(filePath, projectRoot, program);
60
+ expectations.push(...fileExpectations);
61
+ }
62
+
63
+ return expectations;
64
+ }
65
+
66
+ /**
67
+ * Extract navigation promises from a single file.
68
+ *
69
+ * @param {string} filePath - File path
70
+ * @param {string} projectRoot - Project root
71
+ * @param {Object} program - TypeScript program
72
+ * @returns {Array} - Navigation expectations
73
+ */
74
+ function extractFromFile(filePath, projectRoot, program) {
75
+ const expectations = [];
76
+
77
+ // Check if it's a .vue file (SFC)
78
+ if (extname(filePath) === '.vue') {
79
+ const sfcExpectations = extractFromVueSFC(filePath, projectRoot);
80
+ expectations.push(...sfcExpectations);
81
+ }
82
+
83
+ // Extract from script section (TypeScript/JavaScript)
84
+ const ast = parseFile(filePath, true);
85
+ if (!ast) return expectations;
86
+
87
+ // Extract router.push/replace calls
88
+ const routerCalls = findRouterCalls(ast);
89
+ for (const call of routerCalls) {
90
+ const expectation = extractFromRouterCall(call, ast, projectRoot);
91
+ if (expectation) {
92
+ expectations.push(expectation);
93
+ }
94
+ }
95
+
96
+ // Extract RouterLink JSX/TSX components
97
+ const routerLinks = findRouterLinkElements(ast);
98
+ for (const element of routerLinks) {
99
+ const expectation = extractFromRouterLink(element, ast, projectRoot);
100
+ if (expectation) {
101
+ expectations.push(expectation);
102
+ }
103
+ }
104
+
105
+ return expectations;
106
+ }
107
+
108
+ /**
109
+ * Extract navigation promises from Vue SFC (.vue file).
110
+ *
111
+ * @param {string} filePath - File path
112
+ * @param {string} projectRoot - Project root
113
+ * @returns {Array} - Navigation expectations
114
+ */
115
+ function extractFromVueSFC(filePath, projectRoot) {
116
+ const expectations = [];
117
+ const { relative } = require('path');
118
+
119
+ if (!existsSync(filePath)) return expectations;
120
+
121
+ try {
122
+ const content = readFileSync(filePath, 'utf-8');
123
+
124
+ // Extract template section
125
+ const templateMatch = content.match(/<template[^>]*>([\s\S]*?)<\/template>/);
126
+ if (templateMatch) {
127
+ const templateContent = templateMatch[1];
128
+
129
+ // Pattern 1: <router-link to="/path">
130
+ const routerLinkRegex = /<router-link[^>]*\s+to=["']([^"']+)["'][^>]*>/g;
131
+ let match;
132
+ while ((match = routerLinkRegex.exec(templateContent)) !== null) {
133
+ const path = match[1];
134
+ if (path && path.startsWith('/')) {
135
+ const normalized = normalizeDynamicRoute(path);
136
+ const line = (templateContent.substring(0, match.index).match(/\n/g) || []).length + 1;
137
+
138
+ if (normalized) {
139
+ // Dynamic route
140
+ expectations.push({
141
+ type: 'spa_navigation',
142
+ targetPath: normalized.examplePath,
143
+ originalPattern: normalized.originalPattern,
144
+ isDynamic: true,
145
+ exampleExecution: true,
146
+ matchAttribute: 'to',
147
+ proof: 'PROVEN_EXPECTATION',
148
+ sourceRef: `${relative(projectRoot, filePath).replace(/\\/g, '/')}:${line}`,
149
+ selectorHint: 'router-link',
150
+ metadata: {
151
+ elementFile: relative(projectRoot, filePath).replace(/\\/g, '/'),
152
+ elementLine: line,
153
+ eventType: 'click'
154
+ }
155
+ });
156
+ } else {
157
+ // Static route
158
+ expectations.push({
159
+ type: 'spa_navigation',
160
+ targetPath: path,
161
+ matchAttribute: 'to',
162
+ proof: 'PROVEN_EXPECTATION',
163
+ sourceRef: `${relative(projectRoot, filePath).replace(/\\/g, '/')}:${line}`,
164
+ selectorHint: 'router-link',
165
+ metadata: {
166
+ elementFile: relative(projectRoot, filePath).replace(/\\/g, '/'),
167
+ elementLine: line,
168
+ eventType: 'click'
169
+ }
170
+ });
171
+ }
172
+ }
173
+ }
174
+
175
+ // Pattern 2: <RouterLink :to="{ path: '/path' }">
176
+ const routerLinkBindingRegex = /<RouterLink\s+[^>]*:to=["']\{[^}]*path:\s*["']([^"']+)["'][^}]*\}[^>]*>/g;
177
+ while ((match = routerLinkBindingRegex.exec(templateContent)) !== null) {
178
+ const path = match[1];
179
+ if (path && path.startsWith('/')) {
180
+ const normalized = normalizeDynamicRoute(path);
181
+ const line = (templateContent.substring(0, match.index).match(/\n/g) || []).length + 1;
182
+
183
+ if (normalized) {
184
+ // Dynamic route
185
+ expectations.push({
186
+ type: 'spa_navigation',
187
+ targetPath: normalized.examplePath,
188
+ originalPattern: normalized.originalPattern,
189
+ isDynamic: true,
190
+ exampleExecution: true,
191
+ matchAttribute: 'to',
192
+ proof: 'PROVEN_EXPECTATION',
193
+ sourceRef: `${relative(projectRoot, filePath).replace(/\\/g, '/')}:${line}`,
194
+ selectorHint: 'RouterLink',
195
+ metadata: {
196
+ elementFile: relative(projectRoot, filePath).replace(/\\/g, '/'),
197
+ elementLine: line,
198
+ eventType: 'click'
199
+ }
200
+ });
201
+ } else {
202
+ // Static route
203
+ expectations.push({
204
+ type: 'spa_navigation',
205
+ targetPath: path,
206
+ matchAttribute: 'to',
207
+ proof: 'PROVEN_EXPECTATION',
208
+ sourceRef: `${relative(projectRoot, filePath).replace(/\\/g, '/')}:${line}`,
209
+ selectorHint: 'RouterLink',
210
+ metadata: {
211
+ elementFile: relative(projectRoot, filePath).replace(/\\/g, '/'),
212
+ elementLine: line,
213
+ eventType: 'click'
214
+ }
215
+ });
216
+ }
217
+ }
218
+ }
219
+ }
220
+
221
+ // Extract script section for router.push/replace
222
+ const scriptMatch = content.match(/<script[^>]*>([\s\S]*?)<\/script>/);
223
+ if (scriptMatch) {
224
+ const scriptContent = scriptMatch[1];
225
+ const routerPushRegex = /router\.(push|replace)\s*\(\s*["']([^"']+)["']\s*\)/g;
226
+ while ((match = routerPushRegex.exec(scriptContent)) !== null) {
227
+ const method = match[1];
228
+ const path = match[2];
229
+ if (path && path.startsWith('/')) {
230
+ const normalized = normalizeDynamicRoute(path);
231
+ const line = (scriptContent.substring(0, match.index).match(/\n/g) || []).length + 1;
232
+
233
+ if (normalized) {
234
+ // Dynamic route
235
+ expectations.push({
236
+ type: 'spa_navigation',
237
+ targetPath: normalized.examplePath,
238
+ originalPattern: normalized.originalPattern,
239
+ isDynamic: true,
240
+ exampleExecution: true,
241
+ navigationMethod: method,
242
+ proof: 'PROVEN_EXPECTATION',
243
+ sourceRef: `${relative(projectRoot, filePath).replace(/\\/g, '/')}:${line}`,
244
+ selectorHint: null,
245
+ metadata: {
246
+ elementFile: relative(projectRoot, filePath).replace(/\\/g, '/'),
247
+ elementLine: line,
248
+ handlerName: `router.${method}`,
249
+ eventType: 'programmatic'
250
+ }
251
+ });
252
+ } else {
253
+ // Static route
254
+ expectations.push({
255
+ type: 'spa_navigation',
256
+ targetPath: path,
257
+ navigationMethod: method,
258
+ proof: 'PROVEN_EXPECTATION',
259
+ sourceRef: `${relative(projectRoot, filePath).replace(/\\/g, '/')}:${line}`,
260
+ selectorHint: null,
261
+ metadata: {
262
+ elementFile: relative(projectRoot, filePath).replace(/\\/g, '/'),
263
+ elementLine: line,
264
+ handlerName: `router.${method}`,
265
+ eventType: 'programmatic'
266
+ }
267
+ });
268
+ }
269
+ }
270
+ }
271
+ }
272
+ } catch (err) {
273
+ // Skip if file can't be parsed
274
+ }
275
+
276
+ return expectations;
277
+ }
278
+
279
+ /**
280
+ * Find router.push/replace calls in AST.
281
+ *
282
+ * @param {ts.SourceFile} ast - Source file
283
+ * @returns {Array} - Call expression nodes
284
+ */
285
+ function findRouterCalls(ast) {
286
+ const calls = [];
287
+
288
+ const callExpressions = findNodes(ast, node => {
289
+ if (!ts.isCallExpression(node)) return false;
290
+ const expr = node.expression;
291
+ if (!ts.isPropertyAccessExpression(expr)) return false;
292
+
293
+ const obj = expr.expression;
294
+ const prop = expr.name;
295
+
296
+ if (!ts.isIdentifier(obj) || !ts.isIdentifier(prop)) return false;
297
+
298
+ return obj.text === 'router' && (prop.text === 'push' || prop.text === 'replace');
299
+ });
300
+
301
+ return callExpressions;
302
+ }
303
+
304
+ /**
305
+ * Extract expectation from router call.
306
+ *
307
+ * @param {ts.CallExpression} call - Call expression
308
+ * @param {ts.SourceFile} ast - Source file
309
+ * @param {string} projectRoot - Project root
310
+ * @returns {Object|null} - Expectation or null
311
+ */
312
+ function extractFromRouterCall(call, ast, projectRoot) {
313
+ const expr = call.expression;
314
+ if (!ts.isPropertyAccessExpression(expr)) return null;
315
+
316
+ const prop = expr.name;
317
+ const method = prop.text; // 'push' or 'replace'
318
+
319
+ if (call.arguments.length === 0) return null;
320
+
321
+ const arg = call.arguments[0];
322
+ const location = getNodeLocation(ast, call, projectRoot);
323
+
324
+ // String literal: router.push('/path')
325
+ const path = getStringLiteral(arg);
326
+ if (path && path.startsWith('/')) {
327
+ // Normalize dynamic routes
328
+ const normalized = normalizeDynamicRoute(path);
329
+ if (normalized) {
330
+ return {
331
+ type: 'spa_navigation',
332
+ targetPath: normalized.examplePath,
333
+ originalPattern: normalized.originalPattern,
334
+ isDynamic: true,
335
+ exampleExecution: true,
336
+ navigationMethod: method,
337
+ proof: 'PROVEN_EXPECTATION',
338
+ sourceRef: location.sourceRef,
339
+ selectorHint: null,
340
+ metadata: {
341
+ elementFile: location.file,
342
+ elementLine: location.line,
343
+ handlerName: `router.${method}`,
344
+ eventType: 'programmatic'
345
+ }
346
+ };
347
+ }
348
+ // Static route
349
+ return {
350
+ type: 'spa_navigation',
351
+ targetPath: path,
352
+ navigationMethod: method,
353
+ proof: 'PROVEN_EXPECTATION',
354
+ sourceRef: location.sourceRef,
355
+ selectorHint: null,
356
+ metadata: {
357
+ elementFile: location.file,
358
+ elementLine: location.line,
359
+ handlerName: `router.${method}`,
360
+ eventType: 'programmatic'
361
+ }
362
+ };
363
+ }
364
+
365
+ // Template literal: router.push(`/users/${id}`)
366
+ if (ts.isTemplateExpression(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) {
367
+ let templateText = null;
368
+ if (ts.isTemplateExpression(arg)) {
369
+ // Build template string with ${} placeholders
370
+ let text = arg.head?.text || '';
371
+ for (const span of arg.templateSpans || []) {
372
+ const expr = span.expression;
373
+ if (ts.isIdentifier(expr)) {
374
+ text += '${' + expr.text + '}';
375
+ } else {
376
+ // Complex expression - cannot normalize safely
377
+ return null;
378
+ }
379
+ text += span.literal?.text || '';
380
+ }
381
+ templateText = text;
382
+ } else if (ts.isNoSubstitutionTemplateLiteral(arg)) {
383
+ templateText = arg.text;
384
+ }
385
+
386
+ if (templateText && templateText.startsWith('/')) {
387
+ // Normalize template literal
388
+ const normalized = normalizeTemplateLiteral(templateText) || normalizeDynamicRoute(templateText);
389
+ if (normalized) {
390
+ return {
391
+ type: 'spa_navigation',
392
+ targetPath: normalized.examplePath,
393
+ originalPattern: normalized.originalPattern,
394
+ isDynamic: true,
395
+ exampleExecution: true,
396
+ navigationMethod: method,
397
+ proof: 'PROVEN_EXPECTATION',
398
+ sourceRef: location.sourceRef,
399
+ selectorHint: null,
400
+ metadata: {
401
+ elementFile: location.file,
402
+ elementLine: location.line,
403
+ handlerName: `router.${method}`,
404
+ eventType: 'programmatic'
405
+ }
406
+ };
407
+ }
408
+ }
409
+ }
410
+
411
+ // Object literal: router.push({ path: '/path' })
412
+ if (ts.isObjectLiteralExpression(arg)) {
413
+ for (const prop of arg.properties) {
414
+ if (!ts.isPropertyAssignment(prop)) continue;
415
+ const name = prop.name;
416
+ if (!ts.isIdentifier(name)) continue;
417
+ if (name.text !== 'path') continue;
418
+
419
+ const pathValue = getStringLiteral(prop.initializer);
420
+ if (pathValue && pathValue.startsWith('/')) {
421
+ // Normalize dynamic routes
422
+ const normalized = normalizeDynamicRoute(pathValue);
423
+ if (normalized) {
424
+ return {
425
+ type: 'spa_navigation',
426
+ targetPath: normalized.examplePath,
427
+ originalPattern: normalized.originalPattern,
428
+ isDynamic: true,
429
+ exampleExecution: true,
430
+ navigationMethod: method,
431
+ proof: 'PROVEN_EXPECTATION',
432
+ sourceRef: location.sourceRef,
433
+ selectorHint: null,
434
+ metadata: {
435
+ elementFile: location.file,
436
+ elementLine: location.line,
437
+ handlerName: `router.${method}`,
438
+ eventType: 'programmatic'
439
+ }
440
+ };
441
+ }
442
+ // Static route
443
+ return {
444
+ type: 'spa_navigation',
445
+ targetPath: pathValue,
446
+ navigationMethod: method,
447
+ proof: 'PROVEN_EXPECTATION',
448
+ sourceRef: location.sourceRef,
449
+ selectorHint: null,
450
+ metadata: {
451
+ elementFile: location.file,
452
+ elementLine: location.line,
453
+ handlerName: `router.${method}`,
454
+ eventType: 'programmatic'
455
+ }
456
+ };
457
+ }
458
+ }
459
+ }
460
+
461
+ return null;
462
+ }
463
+
464
+ /**
465
+ * Find RouterLink JSX elements.
466
+ *
467
+ * @param {ts.SourceFile} ast - Source file
468
+ * @returns {Array} - JSX element nodes
469
+ */
470
+ function findRouterLinkElements(ast) {
471
+ const elements = [];
472
+
473
+ const jsxElements = findNodes(ast, node => {
474
+ if (!ts.isJsxOpeningElement(node) && !ts.isJsxSelfClosingElement(node)) return false;
475
+
476
+ const tagName = node.tagName;
477
+ if (!ts.isIdentifier(tagName)) return false;
478
+
479
+ return tagName.text === 'RouterLink' || tagName.text === 'router-link';
480
+ });
481
+
482
+ return jsxElements;
483
+ }
484
+
485
+ /**
486
+ * Extract expectation from RouterLink element.
487
+ *
488
+ * @param {ts.Node} element - JSX element
489
+ * @param {ts.SourceFile} ast - Source file
490
+ * @param {string} projectRoot - Project root
491
+ * @returns {Object|null} - Expectation or null
492
+ */
493
+ function extractFromRouterLink(element, ast, projectRoot) {
494
+ const attributes = element.attributes;
495
+ if (!attributes || !attributes.properties) return null;
496
+
497
+ let targetPath = null;
498
+
499
+ for (const attr of attributes.properties) {
500
+ if (!ts.isJsxAttribute(attr)) continue;
501
+
502
+ const name = attr.name;
503
+ if (!ts.isIdentifier(name)) continue;
504
+
505
+ if (name.text === 'to') {
506
+ const initializer = attr.initializer;
507
+
508
+ // String literal: to="/path"
509
+ if (ts.isStringLiteral(initializer)) {
510
+ targetPath = initializer.text;
511
+ }
512
+ // JSX expression: to={"/path"} or to={{ path: "/path" }}
513
+ else if (ts.isJsxExpression(initializer)) {
514
+ const expr = initializer.expression;
515
+
516
+ // String literal in expression
517
+ if (ts.isStringLiteral(expr)) {
518
+ targetPath = expr.text;
519
+ }
520
+ // Object literal: to={{ path: "/path" }}
521
+ else if (ts.isObjectLiteralExpression(expr)) {
522
+ for (const prop of expr.properties) {
523
+ if (!ts.isPropertyAssignment(prop)) continue;
524
+ const propName = prop.name;
525
+ if (!ts.isIdentifier(propName)) continue;
526
+ if (propName.text !== 'path') continue;
527
+
528
+ const pathValue = getStringLiteral(prop.initializer);
529
+ if (pathValue) {
530
+ targetPath = pathValue;
531
+ }
532
+ }
533
+ }
534
+ }
535
+ }
536
+ }
537
+
538
+ if (targetPath && targetPath.startsWith('/')) {
539
+ const location = getNodeLocation(ast, element, projectRoot);
540
+ const normalized = normalizeDynamicRoute(targetPath);
541
+
542
+ if (normalized) {
543
+ // Dynamic route
544
+ return {
545
+ type: 'spa_navigation',
546
+ targetPath: normalized.examplePath,
547
+ originalPattern: normalized.originalPattern,
548
+ isDynamic: true,
549
+ exampleExecution: true,
550
+ matchAttribute: 'to',
551
+ proof: 'PROVEN_EXPECTATION',
552
+ sourceRef: location.sourceRef,
553
+ selectorHint: 'RouterLink',
554
+ metadata: {
555
+ elementFile: location.file,
556
+ elementLine: location.line,
557
+ eventType: 'click'
558
+ }
559
+ };
560
+ }
561
+
562
+ // Static route
563
+ return {
564
+ type: 'spa_navigation',
565
+ targetPath: targetPath,
566
+ matchAttribute: 'to',
567
+ proof: 'PROVEN_EXPECTATION',
568
+ sourceRef: location.sourceRef,
569
+ selectorHint: 'RouterLink',
570
+ metadata: {
571
+ elementFile: location.file,
572
+ elementLine: location.line,
573
+ eventType: 'click'
574
+ }
575
+ };
576
+ }
577
+
578
+ return null;
579
+ }