@veraxhq/verax 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -18
- package/bin/verax.js +7 -0
- package/package.json +3 -3
- package/src/cli/commands/baseline.js +104 -0
- package/src/cli/commands/default.js +79 -25
- package/src/cli/commands/ga.js +243 -0
- package/src/cli/commands/gates.js +95 -0
- package/src/cli/commands/inspect.js +131 -2
- package/src/cli/commands/release-check.js +213 -0
- package/src/cli/commands/run.js +246 -35
- package/src/cli/commands/security-check.js +211 -0
- package/src/cli/commands/truth.js +114 -0
- package/src/cli/entry.js +304 -67
- package/src/cli/util/angular-component-extractor.js +179 -0
- package/src/cli/util/angular-navigation-detector.js +141 -0
- package/src/cli/util/angular-network-detector.js +161 -0
- package/src/cli/util/angular-state-detector.js +162 -0
- package/src/cli/util/ast-interactive-detector.js +546 -0
- package/src/cli/util/ast-network-detector.js +603 -0
- package/src/cli/util/ast-usestate-detector.js +602 -0
- package/src/cli/util/bootstrap-guard.js +86 -0
- package/src/cli/util/determinism-runner.js +123 -0
- package/src/cli/util/determinism-writer.js +129 -0
- package/src/cli/util/env-url.js +4 -0
- package/src/cli/util/expectation-extractor.js +369 -73
- package/src/cli/util/findings-writer.js +126 -16
- package/src/cli/util/learn-writer.js +3 -1
- package/src/cli/util/observe-writer.js +3 -1
- package/src/cli/util/paths.js +3 -12
- package/src/cli/util/project-discovery.js +3 -0
- package/src/cli/util/project-writer.js +3 -1
- package/src/cli/util/run-resolver.js +64 -0
- package/src/cli/util/source-requirement.js +55 -0
- package/src/cli/util/summary-writer.js +1 -0
- package/src/cli/util/svelte-navigation-detector.js +163 -0
- package/src/cli/util/svelte-network-detector.js +80 -0
- package/src/cli/util/svelte-sfc-extractor.js +147 -0
- package/src/cli/util/svelte-state-detector.js +243 -0
- package/src/cli/util/vue-navigation-detector.js +177 -0
- package/src/cli/util/vue-sfc-extractor.js +162 -0
- package/src/cli/util/vue-state-detector.js +215 -0
- package/src/verax/cli/finding-explainer.js +56 -3
- package/src/verax/core/artifacts/registry.js +154 -0
- package/src/verax/core/artifacts/verifier.js +980 -0
- package/src/verax/core/baseline/baseline.enforcer.js +137 -0
- package/src/verax/core/baseline/baseline.snapshot.js +231 -0
- package/src/verax/core/capabilities/gates.js +499 -0
- package/src/verax/core/capabilities/registry.js +475 -0
- package/src/verax/core/confidence/confidence-compute.js +137 -0
- package/src/verax/core/confidence/confidence-invariants.js +234 -0
- package/src/verax/core/confidence/confidence-report-writer.js +112 -0
- package/src/verax/core/confidence/confidence-weights.js +44 -0
- package/src/verax/core/confidence/confidence.defaults.js +65 -0
- package/src/verax/core/confidence/confidence.loader.js +79 -0
- package/src/verax/core/confidence/confidence.schema.js +94 -0
- package/src/verax/core/confidence-engine-refactor.js +484 -0
- package/src/verax/core/confidence-engine.js +486 -0
- package/src/verax/core/confidence-engine.js.backup +471 -0
- package/src/verax/core/contracts/index.js +29 -0
- package/src/verax/core/contracts/types.js +185 -0
- package/src/verax/core/contracts/validators.js +381 -0
- package/src/verax/core/decision-snapshot.js +30 -3
- package/src/verax/core/decisions/decision.trace.js +276 -0
- package/src/verax/core/determinism/contract-writer.js +89 -0
- package/src/verax/core/determinism/contract.js +139 -0
- package/src/verax/core/determinism/diff.js +364 -0
- package/src/verax/core/determinism/engine.js +221 -0
- package/src/verax/core/determinism/finding-identity.js +148 -0
- package/src/verax/core/determinism/normalize.js +438 -0
- package/src/verax/core/determinism/report-writer.js +92 -0
- package/src/verax/core/determinism/run-fingerprint.js +118 -0
- package/src/verax/core/dynamic-route-intelligence.js +528 -0
- package/src/verax/core/evidence/evidence-capture-service.js +307 -0
- package/src/verax/core/evidence/evidence-intent-ledger.js +165 -0
- package/src/verax/core/evidence-builder.js +487 -0
- package/src/verax/core/execution-mode-context.js +77 -0
- package/src/verax/core/execution-mode-detector.js +190 -0
- package/src/verax/core/failures/exit-codes.js +86 -0
- package/src/verax/core/failures/failure-summary.js +76 -0
- package/src/verax/core/failures/failure.factory.js +225 -0
- package/src/verax/core/failures/failure.ledger.js +132 -0
- package/src/verax/core/failures/failure.types.js +196 -0
- package/src/verax/core/failures/index.js +10 -0
- package/src/verax/core/ga/ga-report-writer.js +43 -0
- package/src/verax/core/ga/ga.artifact.js +49 -0
- package/src/verax/core/ga/ga.contract.js +434 -0
- package/src/verax/core/ga/ga.enforcer.js +86 -0
- package/src/verax/core/guardrails/guardrails-report-writer.js +109 -0
- package/src/verax/core/guardrails/policy.defaults.js +210 -0
- package/src/verax/core/guardrails/policy.loader.js +83 -0
- package/src/verax/core/guardrails/policy.schema.js +110 -0
- package/src/verax/core/guardrails/truth-reconciliation.js +136 -0
- package/src/verax/core/guardrails-engine.js +505 -0
- package/src/verax/core/observe/run-timeline.js +316 -0
- package/src/verax/core/perf/perf.contract.js +186 -0
- package/src/verax/core/perf/perf.display.js +65 -0
- package/src/verax/core/perf/perf.enforcer.js +91 -0
- package/src/verax/core/perf/perf.monitor.js +209 -0
- package/src/verax/core/perf/perf.report.js +198 -0
- package/src/verax/core/pipeline-tracker.js +238 -0
- package/src/verax/core/product-definition.js +127 -0
- package/src/verax/core/release/provenance.builder.js +271 -0
- package/src/verax/core/release/release-report-writer.js +40 -0
- package/src/verax/core/release/release.enforcer.js +159 -0
- package/src/verax/core/release/reproducibility.check.js +221 -0
- package/src/verax/core/release/sbom.builder.js +283 -0
- package/src/verax/core/report/cross-index.js +192 -0
- package/src/verax/core/report/human-summary.js +222 -0
- package/src/verax/core/route-intelligence.js +419 -0
- package/src/verax/core/security/secrets.scan.js +326 -0
- package/src/verax/core/security/security-report.js +50 -0
- package/src/verax/core/security/security.enforcer.js +124 -0
- package/src/verax/core/security/supplychain.defaults.json +38 -0
- package/src/verax/core/security/supplychain.policy.js +326 -0
- package/src/verax/core/security/vuln.scan.js +265 -0
- package/src/verax/core/truth/truth.certificate.js +250 -0
- package/src/verax/core/ui-feedback-intelligence.js +515 -0
- package/src/verax/detect/confidence-engine.js +628 -40
- package/src/verax/detect/confidence-helper.js +33 -0
- package/src/verax/detect/detection-engine.js +18 -1
- package/src/verax/detect/dynamic-route-findings.js +335 -0
- package/src/verax/detect/expectation-chain-detector.js +417 -0
- package/src/verax/detect/expectation-model.js +3 -1
- package/src/verax/detect/findings-writer.js +141 -5
- package/src/verax/detect/index.js +229 -5
- package/src/verax/detect/journey-stall-detector.js +558 -0
- package/src/verax/detect/route-findings.js +218 -0
- package/src/verax/detect/ui-feedback-findings.js +207 -0
- package/src/verax/detect/verdict-engine.js +57 -3
- package/src/verax/detect/view-switch-correlator.js +242 -0
- package/src/verax/index.js +413 -45
- package/src/verax/learn/action-contract-extractor.js +682 -64
- package/src/verax/learn/route-validator.js +4 -1
- package/src/verax/observe/index.js +88 -843
- package/src/verax/observe/interaction-runner.js +25 -8
- package/src/verax/observe/observe-context.js +205 -0
- package/src/verax/observe/observe-helpers.js +191 -0
- package/src/verax/observe/observe-runner.js +226 -0
- package/src/verax/observe/observers/budget-observer.js +185 -0
- package/src/verax/observe/observers/console-observer.js +102 -0
- package/src/verax/observe/observers/coverage-observer.js +107 -0
- package/src/verax/observe/observers/interaction-observer.js +471 -0
- package/src/verax/observe/observers/navigation-observer.js +132 -0
- package/src/verax/observe/observers/network-observer.js +87 -0
- package/src/verax/observe/observers/safety-observer.js +82 -0
- package/src/verax/observe/observers/ui-feedback-observer.js +99 -0
- package/src/verax/observe/ui-feedback-detector.js +742 -0
- package/src/verax/observe/ui-signal-sensor.js +148 -2
- package/src/verax/scan-summary-writer.js +42 -8
- package/src/verax/shared/artifact-manager.js +8 -5
- package/src/verax/shared/css-spinner-rules.js +204 -0
- package/src/verax/shared/view-switch-rules.js +208 -0
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
* NO HEURISTICS. Only static, deterministic analysis.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { normalizeTemplateLiteral } from '../shared/dynamic-route-utils.js';
|
|
11
|
+
import { normalizeTemplateLiteral } from '../shared/dynamic-route-utils.js';
|
|
12
|
+
import { parse } from '@babel/parser';
|
|
12
13
|
import traverse from '@babel/traverse';
|
|
13
|
-
import { readFileSync } from 'fs';
|
|
14
|
-
import { relative, sep } from 'path';
|
|
14
|
+
import { readFileSync, existsSync } from 'fs';
|
|
15
|
+
import { relative, sep, dirname, resolve, extname } from 'path';
|
|
16
|
+
import { isViewSwitchFunction, isDetectableLiteralArg, VIEW_SWITCH_REASON_CODES } from '../shared/view-switch-rules.js';
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* Extract action contracts from a source file.
|
|
@@ -30,18 +32,41 @@ export function extractActionContracts(filePath, workspaceRoot) {
|
|
|
30
32
|
}
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
function extractActionContractsFromCode(filePath, workspaceRoot, code, lineOffset = 0) {
|
|
35
|
+
export function extractActionContractsFromCode(filePath, workspaceRoot, code, lineOffset = 0) {
|
|
34
36
|
const contracts = [];
|
|
35
37
|
|
|
36
38
|
// Track function declarations and arrow function assignments with location
|
|
37
39
|
const functionBodies = new Map(); // name -> { body, loc }
|
|
40
|
+
|
|
41
|
+
// Track imports for cross-file resolution
|
|
42
|
+
const importMap = new Map(); // localName -> { modulePath, exportName }
|
|
43
|
+
const fileDir = dirname(filePath);
|
|
38
44
|
|
|
39
45
|
const ast = parse(code, {
|
|
40
46
|
sourceType: 'unambiguous',
|
|
41
47
|
plugins: ['jsx', 'typescript'],
|
|
42
48
|
});
|
|
43
|
-
|
|
49
|
+
|
|
50
|
+
// First pass: collect imports and function definitions
|
|
44
51
|
traverse.default(ast, {
|
|
52
|
+
ImportDeclaration(path) {
|
|
53
|
+
const source = path.node.source.value;
|
|
54
|
+
if (!source.startsWith('.') && !source.startsWith('/')) {
|
|
55
|
+
return; // Skip external packages
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const resolvedPath = resolveModulePath(source, fileDir, workspaceRoot);
|
|
59
|
+
if (!resolvedPath) return;
|
|
60
|
+
|
|
61
|
+
path.node.specifiers.forEach(spec => {
|
|
62
|
+
if (spec.type === 'ImportSpecifier' || spec.type === 'ImportDefaultSpecifier') {
|
|
63
|
+
const localName = spec.local.name;
|
|
64
|
+
const exportName = spec.type === 'ImportDefaultSpecifier' ? 'default' : (spec.imported?.name || localName);
|
|
65
|
+
importMap.set(localName, { modulePath: resolvedPath, exportName });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
|
|
45
70
|
FunctionDeclaration(path) {
|
|
46
71
|
if (path.node.id && path.node.id.name) {
|
|
47
72
|
functionBodies.set(path.node.id.name, { body: path.node.body, loc: path.node.loc });
|
|
@@ -56,8 +81,11 @@ function extractActionContractsFromCode(filePath, workspaceRoot, code, lineOffse
|
|
|
56
81
|
) {
|
|
57
82
|
functionBodies.set(path.node.id.name, { body: path.node.init.body, loc: path.node.loc });
|
|
58
83
|
}
|
|
59
|
-
}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
60
86
|
|
|
87
|
+
// Second pass: extract contracts with cross-file support
|
|
88
|
+
traverse.default(ast, {
|
|
61
89
|
// JSX handlers (React)
|
|
62
90
|
JSXAttribute(path) {
|
|
63
91
|
const attrName = path.node.name.name;
|
|
@@ -75,7 +103,8 @@ function extractActionContractsFromCode(filePath, workspaceRoot, code, lineOffse
|
|
|
75
103
|
value.expression.type === 'ArrowFunctionExpression'
|
|
76
104
|
) {
|
|
77
105
|
const handlerBody = value.expression.body;
|
|
78
|
-
const networkCalls = findNetworkCallsInNode(handlerBody);
|
|
106
|
+
const networkCalls = findNetworkCallsInNode(handlerBody, functionBodies, importMap, filePath, workspaceRoot, 0);
|
|
107
|
+
const viewSwitchCalls = findViewSwitchCallsInNode(handlerBody, functionBodies, importMap, filePath, workspaceRoot, 0);
|
|
79
108
|
|
|
80
109
|
for (const call of networkCalls) {
|
|
81
110
|
const loc = path.node.loc;
|
|
@@ -92,52 +121,116 @@ function extractActionContractsFromCode(filePath, workspaceRoot, code, lineOffse
|
|
|
92
121
|
selectorHint: null
|
|
93
122
|
});
|
|
94
123
|
} else if (call.kind !== 'VALIDATION_BLOCK') {
|
|
95
|
-
|
|
124
|
+
const contract = {
|
|
96
125
|
kind: call.kind || 'NETWORK_ACTION',
|
|
97
126
|
method: call.method,
|
|
98
127
|
urlPath: call.url,
|
|
99
128
|
source: sourceRef,
|
|
100
129
|
elementType: path.parent.name.name,
|
|
101
130
|
handlerRef: sourceRef
|
|
102
|
-
}
|
|
131
|
+
};
|
|
132
|
+
if (call.isDynamic) {
|
|
133
|
+
contract.isDynamic = true;
|
|
134
|
+
contract.dynamicSegments = call.dynamicSegments;
|
|
135
|
+
if (call.astSnippet) contract.astSnippet = call.astSnippet;
|
|
136
|
+
}
|
|
137
|
+
contracts.push(contract);
|
|
103
138
|
}
|
|
104
139
|
}
|
|
140
|
+
|
|
141
|
+
// Extract view switch promises
|
|
142
|
+
for (const call of viewSwitchCalls) {
|
|
143
|
+
const loc = path.node.loc;
|
|
144
|
+
const sourceRef = formatSourceRef(filePath, workspaceRoot, loc, lineOffset);
|
|
145
|
+
|
|
146
|
+
contracts.push({
|
|
147
|
+
kind: 'VIEW_SWITCH_PROMISE',
|
|
148
|
+
target: call.target,
|
|
149
|
+
viewKind: call.viewKind,
|
|
150
|
+
pattern: call.pattern,
|
|
151
|
+
isUrlChanging: false,
|
|
152
|
+
source: sourceRef,
|
|
153
|
+
elementType: path.parent.name.name,
|
|
154
|
+
handlerRef: sourceRef,
|
|
155
|
+
astSnippet: call.astSnippet,
|
|
156
|
+
reasonCode: call.reasonCode,
|
|
157
|
+
confidenceHint: 'WEAK' // UI-bound but requires correlation
|
|
158
|
+
});
|
|
159
|
+
}
|
|
105
160
|
} else if (
|
|
106
161
|
value.type === 'JSXExpressionContainer' &&
|
|
107
162
|
value.expression.type === 'Identifier'
|
|
108
163
|
) {
|
|
109
164
|
const refName = value.expression.name;
|
|
110
165
|
const handlerRecord = functionBodies.get(refName);
|
|
111
|
-
|
|
166
|
+
|
|
167
|
+
// Try local function first
|
|
168
|
+
let networkCalls = [];
|
|
169
|
+
let viewSwitchCalls = [];
|
|
112
170
|
if (handlerRecord) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
171
|
+
networkCalls = findNetworkCallsInNode(handlerRecord.body, functionBodies, importMap, filePath, workspaceRoot, 0);
|
|
172
|
+
viewSwitchCalls = findViewSwitchCallsInNode(handlerRecord.body, functionBodies, importMap, filePath, workspaceRoot, 0);
|
|
173
|
+
} else {
|
|
174
|
+
// Try cross-file resolution
|
|
175
|
+
const importInfo = importMap.get(refName);
|
|
176
|
+
if (importInfo) {
|
|
177
|
+
const crossFileCalls = followCrossFileFunction(importInfo, workspaceRoot, 0);
|
|
178
|
+
networkCalls = crossFileCalls.filter(c => c.kind !== 'VIEW_SWITCH_PROMISE');
|
|
179
|
+
viewSwitchCalls = crossFileCalls.filter(c => c.kind === 'VIEW_SWITCH_PROMISE');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const call of networkCalls) {
|
|
184
|
+
const loc = path.node.loc;
|
|
185
|
+
const sourceRef = formatSourceRef(filePath, workspaceRoot, loc, lineOffset);
|
|
186
|
+
|
|
187
|
+
if (call.kind === 'VALIDATION_BLOCK' && isSubmitHandler) {
|
|
188
|
+
contracts.push({
|
|
189
|
+
kind: 'VALIDATION_BLOCK',
|
|
190
|
+
method: call.method,
|
|
191
|
+
urlPath: null,
|
|
192
|
+
source: sourceRef,
|
|
193
|
+
elementType: path.parent.name.name,
|
|
194
|
+
handlerRef: call.handlerRef || sourceRef,
|
|
195
|
+
selectorHint: null
|
|
196
|
+
});
|
|
197
|
+
} else if (call.kind !== 'VALIDATION_BLOCK') {
|
|
198
|
+
const contract = {
|
|
199
|
+
kind: call.kind || 'NETWORK_ACTION',
|
|
200
|
+
method: call.method,
|
|
201
|
+
urlPath: call.url,
|
|
202
|
+
source: sourceRef,
|
|
203
|
+
elementType: path.parent.name.name,
|
|
204
|
+
handlerRef: call.handlerRef || sourceRef
|
|
205
|
+
};
|
|
206
|
+
if (call.isDynamic) {
|
|
207
|
+
contract.isDynamic = true;
|
|
208
|
+
contract.dynamicSegments = call.dynamicSegments;
|
|
209
|
+
if (call.astSnippet) contract.astSnippet = call.astSnippet;
|
|
138
210
|
}
|
|
211
|
+
contracts.push(contract);
|
|
139
212
|
}
|
|
140
213
|
}
|
|
214
|
+
|
|
215
|
+
// Extract view switch promises
|
|
216
|
+
for (const call of viewSwitchCalls) {
|
|
217
|
+
const loc = path.node.loc;
|
|
218
|
+
const sourceRef = formatSourceRef(filePath, workspaceRoot, loc, lineOffset);
|
|
219
|
+
|
|
220
|
+
contracts.push({
|
|
221
|
+
kind: 'VIEW_SWITCH_PROMISE',
|
|
222
|
+
target: call.target,
|
|
223
|
+
viewKind: call.viewKind,
|
|
224
|
+
pattern: call.pattern,
|
|
225
|
+
isUrlChanging: false,
|
|
226
|
+
source: sourceRef,
|
|
227
|
+
elementType: path.parent.name.name,
|
|
228
|
+
handlerRef: sourceRef,
|
|
229
|
+
astSnippet: call.astSnippet,
|
|
230
|
+
reasonCode: call.reasonCode,
|
|
231
|
+
confidenceHint: 'WEAK'
|
|
232
|
+
});
|
|
233
|
+
}
|
|
141
234
|
}
|
|
142
235
|
},
|
|
143
236
|
|
|
@@ -167,12 +260,68 @@ function extractActionContractsFromCode(filePath, workspaceRoot, code, lineOffse
|
|
|
167
260
|
if (record) {
|
|
168
261
|
handlerBody = record.body;
|
|
169
262
|
handlerLoc = record.loc || path.node.loc;
|
|
263
|
+
} else {
|
|
264
|
+
// Try cross-file resolution
|
|
265
|
+
const importInfo = importMap.get(handlerArg.name);
|
|
266
|
+
if (importInfo) {
|
|
267
|
+
const crossFileCalls = followCrossFileFunction(importInfo, workspaceRoot, 0);
|
|
268
|
+
if (crossFileCalls.length > 0) {
|
|
269
|
+
handlerBody = { type: 'BlockStatement', body: [] }; // Dummy body for location
|
|
270
|
+
handlerLoc = path.node.loc;
|
|
271
|
+
const handlerRef = formatSourceRef(filePath, workspaceRoot, handlerLoc, lineOffset);
|
|
272
|
+
|
|
273
|
+
for (const call of crossFileCalls) {
|
|
274
|
+
if (call.kind === 'VALIDATION_BLOCK' && isSubmitEvent) {
|
|
275
|
+
contracts.push({
|
|
276
|
+
kind: 'VALIDATION_BLOCK',
|
|
277
|
+
method: call.method,
|
|
278
|
+
urlPath: null,
|
|
279
|
+
source: handlerRef,
|
|
280
|
+
handlerRef: call.handlerRef || `${handlerRef}#${eventType}`,
|
|
281
|
+
elementType: 'dom',
|
|
282
|
+
selectorHint: null
|
|
283
|
+
});
|
|
284
|
+
} else if (call.kind === 'VIEW_SWITCH_PROMISE') {
|
|
285
|
+
contracts.push({
|
|
286
|
+
kind: 'VIEW_SWITCH_PROMISE',
|
|
287
|
+
target: call.target,
|
|
288
|
+
viewKind: call.viewKind,
|
|
289
|
+
pattern: call.pattern,
|
|
290
|
+
isUrlChanging: false,
|
|
291
|
+
source: handlerRef,
|
|
292
|
+
handlerRef: call.handlerRef || `${handlerRef}#${eventType}`,
|
|
293
|
+
elementType: 'dom',
|
|
294
|
+
astSnippet: call.astSnippet,
|
|
295
|
+
reasonCode: call.reasonCode,
|
|
296
|
+
confidenceHint: 'WEAK'
|
|
297
|
+
});
|
|
298
|
+
} else if (call.kind !== 'VALIDATION_BLOCK') {
|
|
299
|
+
const contract = {
|
|
300
|
+
kind: call.kind || 'NETWORK_ACTION',
|
|
301
|
+
method: call.method,
|
|
302
|
+
urlPath: call.url,
|
|
303
|
+
source: handlerRef,
|
|
304
|
+
handlerRef: call.handlerRef || `${handlerRef}#${eventType}`,
|
|
305
|
+
elementType: 'dom'
|
|
306
|
+
};
|
|
307
|
+
if (call.isDynamic) {
|
|
308
|
+
contract.isDynamic = true;
|
|
309
|
+
contract.dynamicSegments = call.dynamicSegments;
|
|
310
|
+
if (call.astSnippet) contract.astSnippet = call.astSnippet;
|
|
311
|
+
}
|
|
312
|
+
contracts.push(contract);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return; // Already processed
|
|
316
|
+
}
|
|
317
|
+
}
|
|
170
318
|
}
|
|
171
319
|
}
|
|
172
320
|
|
|
173
321
|
if (!handlerBody) return;
|
|
174
322
|
|
|
175
|
-
const networkCalls = findNetworkCallsInNode(handlerBody);
|
|
323
|
+
const networkCalls = findNetworkCallsInNode(handlerBody, functionBodies, importMap, filePath, workspaceRoot, 0);
|
|
324
|
+
const viewSwitchCalls = findViewSwitchCallsInNode(handlerBody, functionBodies, importMap, filePath, workspaceRoot, 0);
|
|
176
325
|
const isSubmitEvent = eventType === 'submit';
|
|
177
326
|
|
|
178
327
|
for (const call of networkCalls) {
|
|
@@ -189,14 +338,20 @@ function extractActionContractsFromCode(filePath, workspaceRoot, code, lineOffse
|
|
|
189
338
|
selectorHint: null
|
|
190
339
|
});
|
|
191
340
|
} else if (call.kind !== 'VALIDATION_BLOCK') {
|
|
192
|
-
|
|
341
|
+
const contract = {
|
|
193
342
|
kind: call.kind || 'NETWORK_ACTION',
|
|
194
343
|
method: call.method,
|
|
195
344
|
urlPath: call.url,
|
|
196
345
|
source: handlerRef,
|
|
197
346
|
handlerRef: `${handlerRef}#${eventType}`,
|
|
198
347
|
elementType: 'dom'
|
|
199
|
-
}
|
|
348
|
+
};
|
|
349
|
+
if (call.isDynamic) {
|
|
350
|
+
contract.isDynamic = true;
|
|
351
|
+
contract.dynamicSegments = call.dynamicSegments;
|
|
352
|
+
if (call.astSnippet) contract.astSnippet = call.astSnippet;
|
|
353
|
+
}
|
|
354
|
+
contracts.push(contract);
|
|
200
355
|
}
|
|
201
356
|
}
|
|
202
357
|
}
|
|
@@ -209,22 +364,37 @@ function extractActionContractsFromCode(filePath, workspaceRoot, code, lineOffse
|
|
|
209
364
|
/**
|
|
210
365
|
* Extract template literal pattern from node.
|
|
211
366
|
* Returns null if template has complex expressions.
|
|
367
|
+
*
|
|
368
|
+
* TRUTH BOUNDARY:
|
|
369
|
+
* - Detectable: Template literals with simple Identifier expressions
|
|
370
|
+
* - `/api/users/${id}` → `/api/users/:id`
|
|
371
|
+
* - `/api/${resource}/${id}` → `/api/:resource/:id`
|
|
372
|
+
* - Ambiguous/Not detectable: Complex expressions (function calls, member access, etc.)
|
|
373
|
+
* - `/api/${getPath()}` → null (rejected)
|
|
374
|
+
* - `/api/${obj.prop}` → null (rejected)
|
|
375
|
+
*
|
|
376
|
+
* @param {Object} node - AST TemplateLiteral node
|
|
377
|
+
* @returns {Object|null} - { templateStr, normalizedUrl, dynamicSegments, astSnippet } or null
|
|
212
378
|
*/
|
|
213
379
|
function extractTemplateLiteralPath(node) {
|
|
214
380
|
if (!node || node.type !== 'TemplateLiteral') {
|
|
215
381
|
return null;
|
|
216
382
|
}
|
|
217
383
|
|
|
218
|
-
// Build template string
|
|
384
|
+
// Build template string with ${param} placeholders
|
|
219
385
|
let templateStr = node.quasis[0]?.value?.cooked || '';
|
|
386
|
+
const dynamicSegments = [];
|
|
220
387
|
|
|
221
388
|
for (let i = 0; i < node.expressions.length; i++) {
|
|
222
389
|
const expr = node.expressions[i];
|
|
223
390
|
|
|
224
|
-
// Only support simple identifiers
|
|
391
|
+
// Only support simple identifiers (truth boundary)
|
|
225
392
|
if (expr.type === 'Identifier') {
|
|
226
|
-
|
|
393
|
+
const paramName = expr.name;
|
|
394
|
+
templateStr += '${' + paramName + '}';
|
|
395
|
+
dynamicSegments.push(paramName);
|
|
227
396
|
} else {
|
|
397
|
+
// Complex expression - reject (truth boundary)
|
|
228
398
|
return null;
|
|
229
399
|
}
|
|
230
400
|
|
|
@@ -233,7 +403,150 @@ function extractTemplateLiteralPath(node) {
|
|
|
233
403
|
}
|
|
234
404
|
}
|
|
235
405
|
|
|
236
|
-
|
|
406
|
+
// Normalize to :param format (not example values)
|
|
407
|
+
let normalizedUrl = templateStr;
|
|
408
|
+
for (const paramName of dynamicSegments) {
|
|
409
|
+
normalizedUrl = normalizedUrl.replace(`\${${paramName}}`, `:${paramName}`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Preserve AST snippet for evidence
|
|
413
|
+
const astSnippet = {
|
|
414
|
+
type: 'TemplateLiteral',
|
|
415
|
+
quasis: node.quasis.map(q => ({ value: q.value?.cooked || '' })),
|
|
416
|
+
expressions: node.expressions.map(e =>
|
|
417
|
+
e.type === 'Identifier' ? { type: 'Identifier', name: e.name } : null
|
|
418
|
+
).filter(Boolean)
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
templateStr,
|
|
423
|
+
normalizedUrl,
|
|
424
|
+
dynamicSegments,
|
|
425
|
+
astSnippet
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Resolve module path from import specifier
|
|
431
|
+
*/
|
|
432
|
+
function resolveModulePath(specifier, fromDir, workspaceRoot) {
|
|
433
|
+
if (!specifier.startsWith('.') && !specifier.startsWith('/')) {
|
|
434
|
+
return null; // External package
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
let resolved = resolve(fromDir, specifier);
|
|
438
|
+
|
|
439
|
+
// Try with extensions
|
|
440
|
+
const extensions = ['.js', '.jsx', '.ts', '.tsx', ''];
|
|
441
|
+
for (const ext of extensions) {
|
|
442
|
+
const withExt = ext ? resolved + ext : resolved;
|
|
443
|
+
if (existsSync(withExt)) {
|
|
444
|
+
return withExt;
|
|
445
|
+
}
|
|
446
|
+
// Try index files
|
|
447
|
+
const indexPath = resolve(withExt, 'index' + ext);
|
|
448
|
+
if (existsSync(indexPath)) {
|
|
449
|
+
return indexPath;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Follow function call across files
|
|
458
|
+
*/
|
|
459
|
+
function followCrossFileFunction(importInfo, workspaceRoot, depth) {
|
|
460
|
+
if (depth > 3) return []; // Prevent infinite recursion
|
|
461
|
+
|
|
462
|
+
const { modulePath, exportName } = importInfo;
|
|
463
|
+
if (!existsSync(modulePath)) return [];
|
|
464
|
+
|
|
465
|
+
try {
|
|
466
|
+
const code = readFileSync(modulePath, 'utf-8');
|
|
467
|
+
const fileDir = dirname(modulePath);
|
|
468
|
+
const ast = parse(code, {
|
|
469
|
+
sourceType: 'unambiguous',
|
|
470
|
+
plugins: ['jsx', 'typescript'],
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
const functionBodies = new Map();
|
|
474
|
+
const importMap = new Map();
|
|
475
|
+
|
|
476
|
+
// Collect function definitions and imports
|
|
477
|
+
traverse.default(ast, {
|
|
478
|
+
ImportDeclaration(path) {
|
|
479
|
+
const source = path.node.source.value;
|
|
480
|
+
if (!source.startsWith('.') && !source.startsWith('/')) return;
|
|
481
|
+
const resolved = resolveModulePath(source, fileDir, workspaceRoot);
|
|
482
|
+
if (!resolved) return;
|
|
483
|
+
path.node.specifiers.forEach(spec => {
|
|
484
|
+
if (spec.type === 'ImportSpecifier' || spec.type === 'ImportDefaultSpecifier') {
|
|
485
|
+
const localName = spec.local.name;
|
|
486
|
+
const expName = spec.type === 'ImportDefaultSpecifier' ? 'default' : (spec.imported?.name || localName);
|
|
487
|
+
importMap.set(localName, { modulePath: resolved, exportName: expName });
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
},
|
|
491
|
+
FunctionDeclaration(path) {
|
|
492
|
+
if (path.node.id && path.node.id.name) {
|
|
493
|
+
functionBodies.set(path.node.id.name, { body: path.node.body, loc: path.node.loc });
|
|
494
|
+
}
|
|
495
|
+
},
|
|
496
|
+
VariableDeclarator(path) {
|
|
497
|
+
if (path.node.id.type === 'Identifier' && path.node.init &&
|
|
498
|
+
(path.node.init.type === 'ArrowFunctionExpression' || path.node.init.type === 'FunctionExpression')) {
|
|
499
|
+
functionBodies.set(path.node.id.name, { body: path.node.init.body, loc: path.node.loc });
|
|
500
|
+
}
|
|
501
|
+
},
|
|
502
|
+
ExportNamedDeclaration(path) {
|
|
503
|
+
if (exportName === 'default') return;
|
|
504
|
+
const decl = path.node.declaration;
|
|
505
|
+
if (decl && decl.type === 'FunctionDeclaration' && decl.id && decl.id.name === exportName) {
|
|
506
|
+
functionBodies.set(exportName, { body: decl.body, loc: decl.loc });
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
ExportDefaultDeclaration(path) {
|
|
510
|
+
if (exportName !== 'default') return;
|
|
511
|
+
const decl = path.node.declaration;
|
|
512
|
+
if (decl && decl.type === 'FunctionDeclaration' && decl.id) {
|
|
513
|
+
functionBodies.set(decl.id.name, { body: decl.body, loc: decl.loc });
|
|
514
|
+
} else if (decl && (decl.type === 'ArrowFunctionExpression' || decl.type === 'FunctionExpression')) {
|
|
515
|
+
// Anonymous default export - scan directly
|
|
516
|
+
const calls = findNetworkCallsInNode(decl.body, functionBodies, importMap, modulePath, workspaceRoot, depth + 1);
|
|
517
|
+
return calls.map(call => ({
|
|
518
|
+
...call,
|
|
519
|
+
handlerRef: formatSourceRef(modulePath, workspaceRoot, decl.loc || { start: { line: 1, column: 0 } }, 0)
|
|
520
|
+
}));
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// Find the exported function
|
|
526
|
+
const funcDef = functionBodies.get(exportName);
|
|
527
|
+
if (funcDef) {
|
|
528
|
+
const networkCalls = findNetworkCallsInNode(funcDef.body, functionBodies, importMap, modulePath, workspaceRoot, depth + 1);
|
|
529
|
+
const viewSwitchCalls = findViewSwitchCallsInNode(funcDef.body, functionBodies, importMap, modulePath, workspaceRoot, depth + 1);
|
|
530
|
+
const handlerRef = formatSourceRef(modulePath, workspaceRoot, funcDef.loc || { start: { line: 1, column: 0 } }, 0);
|
|
531
|
+
|
|
532
|
+
const allCalls = [
|
|
533
|
+
...networkCalls.map(call => ({
|
|
534
|
+
...call,
|
|
535
|
+
handlerRef
|
|
536
|
+
})),
|
|
537
|
+
...viewSwitchCalls.map(call => ({
|
|
538
|
+
...call,
|
|
539
|
+
handlerRef
|
|
540
|
+
}))
|
|
541
|
+
];
|
|
542
|
+
|
|
543
|
+
return allCalls;
|
|
544
|
+
}
|
|
545
|
+
} catch (err) {
|
|
546
|
+
// Ignore parse errors
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return [];
|
|
237
550
|
}
|
|
238
551
|
|
|
239
552
|
/**
|
|
@@ -241,9 +554,14 @@ function extractTemplateLiteralPath(node) {
|
|
|
241
554
|
* Only returns calls with static URL/path literals or template patterns.
|
|
242
555
|
*
|
|
243
556
|
* @param {Object} node - AST node to scan
|
|
557
|
+
* @param {Map} functionBodies - Map of function name -> body for local functions
|
|
558
|
+
* @param {Map} importMap - Map of import name -> module info
|
|
559
|
+
* @param {string} filePath - Current file path
|
|
560
|
+
* @param {string} workspaceRoot - Workspace root
|
|
561
|
+
* @param {number} depth - Recursion depth
|
|
244
562
|
* @returns {Array<Object>} - Array of {kind, method, url} where kind is 'NETWORK_ACTION' or 'NAVIGATION_ACTION'
|
|
245
563
|
*/
|
|
246
|
-
function findNetworkCallsInNode(node) {
|
|
564
|
+
function findNetworkCallsInNode(node, functionBodies = new Map(), importMap = new Map(), filePath = '', workspaceRoot = '', depth = 0) {
|
|
247
565
|
const calls = [];
|
|
248
566
|
|
|
249
567
|
// Track if we found preventDefault or return false for validation block detection
|
|
@@ -257,27 +575,57 @@ function findNetworkCallsInNode(node) {
|
|
|
257
575
|
// Check if this is a CallExpression
|
|
258
576
|
if (n.type === 'CallExpression') {
|
|
259
577
|
const callee = n.callee;
|
|
578
|
+
|
|
579
|
+
// Follow function calls to their definitions (cross-file support)
|
|
580
|
+
if (callee.type === 'Identifier' && depth < 3) {
|
|
581
|
+
const funcName = callee.name;
|
|
582
|
+
const localFunc = functionBodies.get(funcName);
|
|
583
|
+
if (localFunc) {
|
|
584
|
+
// Recursively scan local function
|
|
585
|
+
const innerCalls = findNetworkCallsInNode(localFunc.body, functionBodies, importMap, filePath, workspaceRoot, depth + 1);
|
|
586
|
+
calls.push(...innerCalls);
|
|
587
|
+
} else {
|
|
588
|
+
// Try cross-file resolution
|
|
589
|
+
const importInfo = importMap.get(funcName);
|
|
590
|
+
if (importInfo) {
|
|
591
|
+
const crossFileCalls = followCrossFileFunction(importInfo, workspaceRoot, depth);
|
|
592
|
+
calls.push(...crossFileCalls);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
260
596
|
|
|
261
597
|
// Case 1: fetch(url, options)
|
|
262
598
|
if (callee.type === 'Identifier' && callee.name === 'fetch') {
|
|
263
599
|
const urlArg = n.arguments[0];
|
|
264
600
|
const optionsArg = n.arguments[1];
|
|
265
601
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
);
|
|
275
|
-
if (methodProp && methodProp.value.type === 'StringLiteral') {
|
|
276
|
-
method = methodProp.value.value.toUpperCase();
|
|
277
|
-
}
|
|
602
|
+
let method = 'GET'; // default
|
|
603
|
+
// Try to extract method from options
|
|
604
|
+
if (optionsArg && optionsArg.type === 'ObjectExpression') {
|
|
605
|
+
const methodProp = optionsArg.properties.find(
|
|
606
|
+
(p) => p.key && p.key.name === 'method'
|
|
607
|
+
);
|
|
608
|
+
if (methodProp && methodProp.value.type === 'StringLiteral') {
|
|
609
|
+
method = methodProp.value.value.toUpperCase();
|
|
278
610
|
}
|
|
611
|
+
}
|
|
279
612
|
|
|
613
|
+
if (urlArg && urlArg.type === 'StringLiteral') {
|
|
614
|
+
// Static URL
|
|
280
615
|
calls.push({ kind: 'NETWORK_ACTION', method, url: urlArg.value });
|
|
616
|
+
} else if (urlArg && urlArg.type === 'TemplateLiteral') {
|
|
617
|
+
// Dynamic URL: fetch(`/api/users/${id}`)
|
|
618
|
+
const templateInfo = extractTemplateLiteralPath(urlArg);
|
|
619
|
+
if (templateInfo && templateInfo.normalizedUrl.startsWith('/')) {
|
|
620
|
+
calls.push({
|
|
621
|
+
kind: 'NETWORK_ACTION',
|
|
622
|
+
method,
|
|
623
|
+
url: templateInfo.normalizedUrl,
|
|
624
|
+
isDynamic: true,
|
|
625
|
+
dynamicSegments: templateInfo.dynamicSegments.length,
|
|
626
|
+
astSnippet: templateInfo.astSnippet
|
|
627
|
+
});
|
|
628
|
+
}
|
|
281
629
|
}
|
|
282
630
|
}
|
|
283
631
|
|
|
@@ -292,7 +640,57 @@ function findNetworkCallsInNode(node) {
|
|
|
292
640
|
const urlArg = n.arguments[0];
|
|
293
641
|
|
|
294
642
|
if (urlArg && urlArg.type === 'StringLiteral') {
|
|
643
|
+
// Static URL
|
|
295
644
|
calls.push({ kind: 'NETWORK_ACTION', method: methodName, url: urlArg.value });
|
|
645
|
+
} else if (urlArg && urlArg.type === 'TemplateLiteral') {
|
|
646
|
+
// Dynamic URL: axios.post(`/api/users/${id}`)
|
|
647
|
+
const templateInfo = extractTemplateLiteralPath(urlArg);
|
|
648
|
+
if (templateInfo && templateInfo.normalizedUrl.startsWith('/')) {
|
|
649
|
+
calls.push({
|
|
650
|
+
kind: 'NETWORK_ACTION',
|
|
651
|
+
method: methodName,
|
|
652
|
+
url: templateInfo.normalizedUrl,
|
|
653
|
+
isDynamic: true,
|
|
654
|
+
dynamicSegments: templateInfo.dynamicSegments.length,
|
|
655
|
+
astSnippet: templateInfo.astSnippet
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Case 2b: apiClient.get(url), apiClient.post(url), http.get(url), etc. (wrapped API clients)
|
|
662
|
+
// Check this only if not axios (already handled above)
|
|
663
|
+
if (
|
|
664
|
+
callee.type === 'MemberExpression' &&
|
|
665
|
+
callee.object.type === 'Identifier' &&
|
|
666
|
+
callee.object.name !== 'axios' &&
|
|
667
|
+
callee.property.type === 'Identifier'
|
|
668
|
+
) {
|
|
669
|
+
const objectName = callee.object.name;
|
|
670
|
+
const methodName = callee.property.name.toUpperCase();
|
|
671
|
+
const urlArg = n.arguments[0];
|
|
672
|
+
|
|
673
|
+
// Common API client patterns (exclude axios which is handled above)
|
|
674
|
+
const apiClientNames = ['apiClient', 'http', 'request', 'api', 'client', 'httpClient'];
|
|
675
|
+
const httpMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
|
|
676
|
+
if (apiClientNames.includes(objectName) && httpMethods.includes(methodName)) {
|
|
677
|
+
if (urlArg && urlArg.type === 'StringLiteral') {
|
|
678
|
+
// Static URL
|
|
679
|
+
calls.push({ kind: 'NETWORK_ACTION', method: methodName, url: urlArg.value });
|
|
680
|
+
} else if (urlArg && urlArg.type === 'TemplateLiteral') {
|
|
681
|
+
// Dynamic URL: apiClient.post(`/api/users/${id}`)
|
|
682
|
+
const templateInfo = extractTemplateLiteralPath(urlArg);
|
|
683
|
+
if (templateInfo && templateInfo.normalizedUrl.startsWith('/')) {
|
|
684
|
+
calls.push({
|
|
685
|
+
kind: 'NETWORK_ACTION',
|
|
686
|
+
method: methodName,
|
|
687
|
+
url: templateInfo.normalizedUrl,
|
|
688
|
+
isDynamic: true,
|
|
689
|
+
dynamicSegments: templateInfo.dynamicSegments.length,
|
|
690
|
+
astSnippet: templateInfo.astSnippet
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
}
|
|
296
694
|
}
|
|
297
695
|
}
|
|
298
696
|
|
|
@@ -306,15 +704,30 @@ function findNetworkCallsInNode(node) {
|
|
|
306
704
|
const methodArg = n.arguments[0];
|
|
307
705
|
const urlArg = n.arguments[1];
|
|
308
706
|
|
|
309
|
-
if (
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
707
|
+
if (methodArg && methodArg.type === 'StringLiteral') {
|
|
708
|
+
const method = methodArg.value.toUpperCase();
|
|
709
|
+
|
|
710
|
+
if (urlArg && urlArg.type === 'StringLiteral') {
|
|
711
|
+
// Static URL
|
|
712
|
+
calls.push({
|
|
713
|
+
kind: 'NETWORK_ACTION',
|
|
714
|
+
method,
|
|
715
|
+
url: urlArg.value,
|
|
716
|
+
});
|
|
717
|
+
} else if (urlArg && urlArg.type === 'TemplateLiteral') {
|
|
718
|
+
// Dynamic URL: xhr.open('GET', `/api/users/${id}`)
|
|
719
|
+
const templateInfo = extractTemplateLiteralPath(urlArg);
|
|
720
|
+
if (templateInfo && templateInfo.normalizedUrl.startsWith('/')) {
|
|
721
|
+
calls.push({
|
|
722
|
+
kind: 'NETWORK_ACTION',
|
|
723
|
+
method,
|
|
724
|
+
url: templateInfo.normalizedUrl,
|
|
725
|
+
isDynamic: true,
|
|
726
|
+
dynamicSegments: templateInfo.dynamicSegments.length,
|
|
727
|
+
astSnippet: templateInfo.astSnippet
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
}
|
|
318
731
|
}
|
|
319
732
|
}
|
|
320
733
|
|
|
@@ -419,6 +832,211 @@ function findNetworkCallsInNode(node) {
|
|
|
419
832
|
return calls;
|
|
420
833
|
}
|
|
421
834
|
|
|
835
|
+
/**
|
|
836
|
+
* Find view switch promise calls (setView, dispatch(NAVIGATE), etc.) in an AST node.
|
|
837
|
+
* TRUTH BOUNDARY: Only literal string/number arguments are accepted.
|
|
838
|
+
*
|
|
839
|
+
* @param {Object} node - AST node to scan
|
|
840
|
+
* @param {Map} functionBodies - Map of function name -> body for local functions
|
|
841
|
+
* @param {Map} importMap - Map of import name -> module info
|
|
842
|
+
* @param {string} filePath - Current file path
|
|
843
|
+
* @param {string} workspaceRoot - Workspace root
|
|
844
|
+
* @param {number} depth - Recursion depth
|
|
845
|
+
* @returns {Array<Object>} - Array of {kind: 'VIEW_SWITCH_PROMISE', target, kind, isUrlChanging, astSnippet}
|
|
846
|
+
*/
|
|
847
|
+
function findViewSwitchCallsInNode(node, functionBodies = new Map(), importMap = new Map(), filePath = '', workspaceRoot = '', depth = 0) {
|
|
848
|
+
const calls = [];
|
|
849
|
+
|
|
850
|
+
function scan(n) {
|
|
851
|
+
if (!n || typeof n !== 'object') return;
|
|
852
|
+
|
|
853
|
+
if (n.type === 'CallExpression') {
|
|
854
|
+
const callee = n.callee;
|
|
855
|
+
|
|
856
|
+
// Follow function calls to their definitions (cross-file support)
|
|
857
|
+
if (callee.type === 'Identifier' && depth < 3) {
|
|
858
|
+
const funcName = callee.name;
|
|
859
|
+
const localFunc = functionBodies.get(funcName);
|
|
860
|
+
if (localFunc) {
|
|
861
|
+
const innerCalls = findViewSwitchCallsInNode(localFunc.body, functionBodies, importMap, filePath, workspaceRoot, depth + 1);
|
|
862
|
+
calls.push(...innerCalls);
|
|
863
|
+
} else {
|
|
864
|
+
const importInfo = importMap.get(funcName);
|
|
865
|
+
if (importInfo) {
|
|
866
|
+
const crossFileCalls = followCrossFileFunction(importInfo, workspaceRoot, depth);
|
|
867
|
+
// Filter for view switch calls only
|
|
868
|
+
const viewSwitchCalls = crossFileCalls.filter(c => c.kind === 'VIEW_SWITCH_PROMISE');
|
|
869
|
+
calls.push(...viewSwitchCalls);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Case 1: React setState patterns: setView('settings'), setTab('billing')
|
|
875
|
+
if (callee.type === 'Identifier') {
|
|
876
|
+
const funcName = callee.name;
|
|
877
|
+
const viewSwitchInfo = isViewSwitchFunction(funcName);
|
|
878
|
+
|
|
879
|
+
if (viewSwitchInfo) {
|
|
880
|
+
const firstArg = n.arguments[0];
|
|
881
|
+
const literalArg = isDetectableLiteralArg(firstArg);
|
|
882
|
+
|
|
883
|
+
if (literalArg && literalArg.value) {
|
|
884
|
+
// Preserve AST snippet for evidence
|
|
885
|
+
const astSnippet = {
|
|
886
|
+
type: 'CallExpression',
|
|
887
|
+
callee: { type: 'Identifier', name: funcName },
|
|
888
|
+
arguments: [{
|
|
889
|
+
type: firstArg.type,
|
|
890
|
+
value: firstArg.type === 'StringLiteral' ? firstArg.value : firstArg.value
|
|
891
|
+
}]
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
calls.push({
|
|
895
|
+
kind: 'VIEW_SWITCH_PROMISE',
|
|
896
|
+
target: literalArg.value,
|
|
897
|
+
viewKind: viewSwitchInfo.kind,
|
|
898
|
+
pattern: viewSwitchInfo.pattern,
|
|
899
|
+
isUrlChanging: false,
|
|
900
|
+
astSnippet,
|
|
901
|
+
reasonCode: literalArg.reasonCode
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Case 2: Redux dispatch({type: 'NAVIGATE', to: 'profile'}) or dispatch(navigate('profile'))
|
|
908
|
+
if (callee.type === 'Identifier' && callee.name === 'dispatch') {
|
|
909
|
+
const firstArg = n.arguments[0];
|
|
910
|
+
|
|
911
|
+
// dispatch({type: 'NAVIGATE', to: 'profile'})
|
|
912
|
+
if (firstArg && firstArg.type === 'ObjectExpression') {
|
|
913
|
+
const typeProp = firstArg.properties.find(p =>
|
|
914
|
+
p.key && p.key.type === 'Identifier' && p.key.name === 'type'
|
|
915
|
+
);
|
|
916
|
+
const toProp = firstArg.properties.find(p =>
|
|
917
|
+
p.key && (p.key.name === 'to' || p.key.name === 'target' || p.key.name === 'view')
|
|
918
|
+
);
|
|
919
|
+
|
|
920
|
+
if (typeProp && typeProp.value && typeProp.value.type === 'StringLiteral') {
|
|
921
|
+
const actionType = typeProp.value.value;
|
|
922
|
+
const viewSwitchInfo = isViewSwitchFunction(actionType);
|
|
923
|
+
|
|
924
|
+
if (viewSwitchInfo && toProp && toProp.value) {
|
|
925
|
+
const literalArg = isDetectableLiteralArg(toProp.value);
|
|
926
|
+
|
|
927
|
+
if (literalArg && literalArg.value) {
|
|
928
|
+
const astSnippet = {
|
|
929
|
+
type: 'CallExpression',
|
|
930
|
+
callee: { type: 'Identifier', name: 'dispatch' },
|
|
931
|
+
arguments: [{
|
|
932
|
+
type: 'ObjectExpression',
|
|
933
|
+
properties: [
|
|
934
|
+
{ key: 'type', value: actionType },
|
|
935
|
+
{ key: toProp.key.name, value: literalArg.value }
|
|
936
|
+
]
|
|
937
|
+
}]
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
calls.push({
|
|
941
|
+
kind: 'VIEW_SWITCH_PROMISE',
|
|
942
|
+
target: literalArg.value,
|
|
943
|
+
viewKind: viewSwitchInfo.kind,
|
|
944
|
+
pattern: 'redux',
|
|
945
|
+
isUrlChanging: false,
|
|
946
|
+
astSnippet,
|
|
947
|
+
reasonCode: literalArg.reasonCode
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// dispatch(navigate('profile')) - action creator call
|
|
955
|
+
if (firstArg && firstArg.type === 'CallExpression') {
|
|
956
|
+
const actionCallee = firstArg.callee;
|
|
957
|
+
if (actionCallee.type === 'Identifier') {
|
|
958
|
+
const actionName = actionCallee.name;
|
|
959
|
+
const viewSwitchInfo = isViewSwitchFunction(actionName);
|
|
960
|
+
|
|
961
|
+
if (viewSwitchInfo) {
|
|
962
|
+
const actionFirstArg = firstArg.arguments[0];
|
|
963
|
+
const literalArg = isDetectableLiteralArg(actionFirstArg);
|
|
964
|
+
|
|
965
|
+
if (literalArg && literalArg.value) {
|
|
966
|
+
const astSnippet = {
|
|
967
|
+
type: 'CallExpression',
|
|
968
|
+
callee: { type: 'Identifier', name: 'dispatch' },
|
|
969
|
+
arguments: [{
|
|
970
|
+
type: 'CallExpression',
|
|
971
|
+
callee: { type: 'Identifier', name: actionName },
|
|
972
|
+
arguments: [{ type: actionFirstArg.type, value: literalArg.value }]
|
|
973
|
+
}]
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
calls.push({
|
|
977
|
+
kind: 'VIEW_SWITCH_PROMISE',
|
|
978
|
+
target: literalArg.value,
|
|
979
|
+
viewKind: viewSwitchInfo.kind,
|
|
980
|
+
pattern: 'redux',
|
|
981
|
+
isUrlChanging: false,
|
|
982
|
+
astSnippet,
|
|
983
|
+
reasonCode: literalArg.reasonCode
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Case 3: Generic function calls: showModal('login'), openDrawer('cart')
|
|
992
|
+
if (callee.type === 'Identifier') {
|
|
993
|
+
const funcName = callee.name;
|
|
994
|
+
const viewSwitchInfo = isViewSwitchFunction(funcName);
|
|
995
|
+
|
|
996
|
+
if (viewSwitchInfo && viewSwitchInfo.pattern === 'generic') {
|
|
997
|
+
const firstArg = n.arguments[0];
|
|
998
|
+
const literalArg = isDetectableLiteralArg(firstArg);
|
|
999
|
+
|
|
1000
|
+
if (literalArg && literalArg.value) {
|
|
1001
|
+
const astSnippet = {
|
|
1002
|
+
type: 'CallExpression',
|
|
1003
|
+
callee: { type: 'Identifier', name: funcName },
|
|
1004
|
+
arguments: [{
|
|
1005
|
+
type: firstArg.type,
|
|
1006
|
+
value: literalArg.value
|
|
1007
|
+
}]
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
calls.push({
|
|
1011
|
+
kind: 'VIEW_SWITCH_PROMISE',
|
|
1012
|
+
target: literalArg.value,
|
|
1013
|
+
viewKind: viewSwitchInfo.kind,
|
|
1014
|
+
pattern: 'generic',
|
|
1015
|
+
isUrlChanging: false,
|
|
1016
|
+
astSnippet,
|
|
1017
|
+
reasonCode: literalArg.reasonCode
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Recursively scan child nodes
|
|
1025
|
+
for (const key in n) {
|
|
1026
|
+
if (key === 'parent' || key === 'leadingComments' || key === 'trailingComments') continue;
|
|
1027
|
+
const child = n[key];
|
|
1028
|
+
if (Array.isArray(child)) {
|
|
1029
|
+
child.forEach(item => scan(item));
|
|
1030
|
+
} else if (child && typeof child === 'object') {
|
|
1031
|
+
scan(child);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
scan(node);
|
|
1037
|
+
return calls;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
422
1040
|
/**
|
|
423
1041
|
* Format source reference as "file:line:col"
|
|
424
1042
|
* Normalizes Windows paths to use forward slashes.
|