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