@arcadialdev/arcality 3.0.4 → 4.0.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 +75 -6
- package/bin/arcality.mjs +26 -17
- package/package.json +2 -1
- package/playwright.config.js +22 -21
- package/scripts/gen-and-run.mjs +2610 -2608
- package/scripts/generate.mjs +215 -0
- package/scripts/init.mjs +278 -253
- package/scripts/rebrand-report.mjs +19 -18
- package/src/configManager.mjs +57 -51
- package/src/services/codebaseAnalyzer.mjs +59 -0
- package/src/services/generatedMissionSchema.mjs +76 -0
- package/src/services/generatedMissionStore.mjs +117 -0
- package/src/services/generationContext.mjs +242 -0
- package/src/services/missionGenerator.mjs +328 -0
- package/src/services/routeDiscovery.mjs +747 -0
- package/src/testRunner.ts +2 -1
- package/tests/_helpers/agentic-runner.bundle.spec.js +401 -60
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const PAGE_FILE_RE = /\.(js|jsx|ts|tsx)$/i;
|
|
5
|
+
const IGNORE_DIRS = new Set([
|
|
6
|
+
'.git',
|
|
7
|
+
'.arcality',
|
|
8
|
+
'node_modules',
|
|
9
|
+
'dist',
|
|
10
|
+
'build',
|
|
11
|
+
'coverage',
|
|
12
|
+
'logs',
|
|
13
|
+
'public',
|
|
14
|
+
'tests',
|
|
15
|
+
'bin'
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
function toPosix(value) {
|
|
19
|
+
return String(value || '').split(path.sep).join('/');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function walk(dir, out = []) {
|
|
23
|
+
if (!fs.existsSync(dir)) return out;
|
|
24
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
if (entry.isDirectory()) {
|
|
27
|
+
if (IGNORE_DIRS.has(entry.name)) continue;
|
|
28
|
+
walk(path.join(dir, entry.name), out);
|
|
29
|
+
} else {
|
|
30
|
+
out.push(path.join(dir, entry.name));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stripExtension(filePath) {
|
|
37
|
+
return filePath.replace(/\.(js|jsx|ts|tsx)$/i, '');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function humanizeSegment(value) {
|
|
41
|
+
return String(value || '')
|
|
42
|
+
.replace(/\[[^\]]+\]/g, 'detalle')
|
|
43
|
+
.replace(/[()]/g, '')
|
|
44
|
+
.replace(/[-_]+/g, ' ')
|
|
45
|
+
.replace(/\b\w/g, char => char.toUpperCase());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeRoute(route) {
|
|
49
|
+
const text = String(route || '').trim();
|
|
50
|
+
if (!text || text === '/') return '/';
|
|
51
|
+
const normalized = text.startsWith('/') ? text : `/${text}`;
|
|
52
|
+
return normalized.replace(/\/+/g, '/');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getBaseUrlPathSegments(baseUrl) {
|
|
56
|
+
try {
|
|
57
|
+
const pathname = new URL(String(baseUrl || '')).pathname || '/';
|
|
58
|
+
return pathname.split('/').filter(Boolean);
|
|
59
|
+
} catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function extractRouteParams(route) {
|
|
65
|
+
const params = [];
|
|
66
|
+
const normalized = normalizeRoute(route);
|
|
67
|
+
const nextMatches = normalized.matchAll(/\[([^\]]+)\]/g);
|
|
68
|
+
for (const match of nextMatches) {
|
|
69
|
+
const raw = match[1];
|
|
70
|
+
if (!raw) continue;
|
|
71
|
+
const catchAll = raw.startsWith('...');
|
|
72
|
+
const optionalCatchAll = raw.startsWith('[...');
|
|
73
|
+
const name = raw.replace(/^\.\.\./, '').replace(/^\[|\]$/g, '');
|
|
74
|
+
params.push({
|
|
75
|
+
name,
|
|
76
|
+
kind: catchAll || optionalCatchAll ? 'catch-all' : 'segment'
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const reactMatches = normalized.matchAll(/:([a-zA-Z0-9_]+)/g);
|
|
81
|
+
for (const match of reactMatches) {
|
|
82
|
+
const name = match[1];
|
|
83
|
+
if (!name) continue;
|
|
84
|
+
if (params.some(param => param.name === name)) continue;
|
|
85
|
+
params.push({
|
|
86
|
+
name,
|
|
87
|
+
kind: 'segment'
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return params;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function inferExampleValue(name, kind = 'segment', context = {}) {
|
|
95
|
+
const lower = String(name || '').toLowerCase();
|
|
96
|
+
const preferred = Array.isArray(context.preferredValues) ? context.preferredValues : [];
|
|
97
|
+
if (preferred.length > 0) return preferred[0];
|
|
98
|
+
if (kind === 'catch-all') return 'sample/nested/path';
|
|
99
|
+
if (lower === 'id' || lower.endsWith('id')) return '123';
|
|
100
|
+
if (lower.includes('uuid')) return '11111111-1111-1111-1111-111111111111';
|
|
101
|
+
if (lower.includes('slug')) return 'sample-item';
|
|
102
|
+
if (lower.includes('email')) return 'qa@example.com';
|
|
103
|
+
if (lower.includes('token')) return 'sample-token';
|
|
104
|
+
if (lower.includes('date')) return '2026-01-15';
|
|
105
|
+
if (lower.includes('year')) return '2026';
|
|
106
|
+
if (lower.includes('month')) return '06';
|
|
107
|
+
return 'sample-value';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function buildExampleRoute(route, params = [], context = {}) {
|
|
111
|
+
let exampleRoute = normalizeRoute(route);
|
|
112
|
+
for (const param of params) {
|
|
113
|
+
const nextToken = `[${param.kind === 'catch-all' ? '...' : ''}${param.name}]`;
|
|
114
|
+
const reactToken = `:${param.name}`;
|
|
115
|
+
const value = inferExampleValue(param.name, param.kind, param.exampleContext || context);
|
|
116
|
+
exampleRoute = exampleRoute.replace(nextToken, value).replace(reactToken, value);
|
|
117
|
+
}
|
|
118
|
+
return exampleRoute;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function inferRouteFamily(route) {
|
|
122
|
+
const normalized = normalizeRoute(route);
|
|
123
|
+
if (normalized === '/') return '/';
|
|
124
|
+
const parts = normalized.split('/').filter(Boolean);
|
|
125
|
+
if (parts.length <= 1) return normalized;
|
|
126
|
+
return `/${parts.filter(part => !/^\[.*\]$/.test(part) && !/^:/.test(part)).slice(0, 2).join('/')}` || normalized;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function inferPrimaryRouteTag(route) {
|
|
130
|
+
const parts = normalizeRoute(route).split('/').filter(Boolean);
|
|
131
|
+
const firstStatic = parts.find(part => !/^\[.*\]$/.test(part) && !/^:/.test(part));
|
|
132
|
+
return firstStatic || (parts[0] || 'general');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function slugifyRouteSegment(value) {
|
|
136
|
+
return String(value || '')
|
|
137
|
+
.replace(/^\[\.{3}/, '')
|
|
138
|
+
.replace(/^\[/, '')
|
|
139
|
+
.replace(/\]$/, '')
|
|
140
|
+
.replace(/[^\w-]+/g, '_')
|
|
141
|
+
.replace(/_+/g, '_')
|
|
142
|
+
.replace(/^_+|_+$/g, '')
|
|
143
|
+
.toLowerCase();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function buildRouteParams(route, projectContext = {}) {
|
|
147
|
+
const params = extractRouteParams(route);
|
|
148
|
+
const baseSegments = getBaseUrlPathSegments(projectContext.baseUrl);
|
|
149
|
+
let baseIndex = 0;
|
|
150
|
+
|
|
151
|
+
return params.map(param => {
|
|
152
|
+
const lower = String(param.name || '').toLowerCase();
|
|
153
|
+
let preferredValues = [];
|
|
154
|
+
|
|
155
|
+
if (lower === 'lang' || lower === 'locale') {
|
|
156
|
+
if (baseSegments[baseIndex]) {
|
|
157
|
+
preferredValues.push(baseSegments[baseIndex]);
|
|
158
|
+
baseIndex += 1;
|
|
159
|
+
}
|
|
160
|
+
} else if (lower === 'code' || lower === 'site' || lower === 'tenant') {
|
|
161
|
+
if (baseSegments[baseIndex]) {
|
|
162
|
+
preferredValues.push(baseSegments[baseIndex]);
|
|
163
|
+
baseIndex += 1;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
...param,
|
|
169
|
+
exampleContext: { preferredValues }
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function buildMissionName(route) {
|
|
175
|
+
if (route === '/') return 'GEN_home';
|
|
176
|
+
const parts = normalizeRoute(route).split('/').filter(Boolean);
|
|
177
|
+
const staticParts = parts.filter(part => !/^\[.*\]$/.test(part) && !/^:/.test(part)).map(slugifyRouteSegment).filter(Boolean);
|
|
178
|
+
const dynamicParts = parts
|
|
179
|
+
.filter(part => /^\[.*\]$/.test(part) || /^:/.test(part))
|
|
180
|
+
.map(part => slugifyRouteSegment(part.replace(/^:/, '')))
|
|
181
|
+
.filter(Boolean);
|
|
182
|
+
|
|
183
|
+
const staticTail = staticParts.slice(-3);
|
|
184
|
+
const dynamicTail = dynamicParts.slice(-1);
|
|
185
|
+
const slugParts = staticTail.length > 0 ? [...staticTail, ...dynamicTail] : (dynamicTail.length > 0 ? dynamicTail : ['route']);
|
|
186
|
+
const slug = slugParts.join('_') || 'home';
|
|
187
|
+
return `GEN_${slug}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function buildPrompt(route, routeMeta = {}) {
|
|
191
|
+
if (route === '/') {
|
|
192
|
+
return 'Abre la pagina principal y valida que cargue correctamente, mostrando contenido util y sin errores visibles.';
|
|
193
|
+
}
|
|
194
|
+
const label = humanizeSegment(route.replace(/^\/+/, ''));
|
|
195
|
+
if (routeMeta.isDynamicRoute && routeMeta.exampleRoute) {
|
|
196
|
+
return `Navega a ${route} y valida el flujo principal de ${label}, usando una ruta ejemplo como ${routeMeta.exampleRoute} para comprobar carga correcta, contexto del registro y ausencia de errores observables.`;
|
|
197
|
+
}
|
|
198
|
+
return `Navega a ${route} y valida el flujo principal de ${label}, verificando carga correcta, elementos clave visibles y ausencia de errores observables.`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function buildExpectedResult(route, routeMeta = {}) {
|
|
202
|
+
if (route === '/') {
|
|
203
|
+
return 'La pagina principal carga correctamente y muestra contenido clave sin errores visibles.';
|
|
204
|
+
}
|
|
205
|
+
if (routeMeta.isDynamicRoute) {
|
|
206
|
+
return `La pagina ${route} carga correctamente con un registro de ejemplo y permite validar contexto observable sin errores visibles.`;
|
|
207
|
+
}
|
|
208
|
+
return `La pagina ${route} carga correctamente y permite validar al menos un flujo observable sin errores visibles.`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function analyzeSourceSignals(sourceFile, route) {
|
|
212
|
+
let raw = '';
|
|
213
|
+
try {
|
|
214
|
+
raw = fs.readFileSync(sourceFile, 'utf8');
|
|
215
|
+
} catch {
|
|
216
|
+
raw = '';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const lower = raw.toLowerCase();
|
|
220
|
+
const routeLower = String(route || '').toLowerCase();
|
|
221
|
+
const fileLower = toPosix(sourceFile).toLowerCase();
|
|
222
|
+
const semanticFileLower = fileLower.replace(/\([^/\\]+\)/g, '');
|
|
223
|
+
const has = (...patterns) => patterns.some(pattern => lower.includes(pattern));
|
|
224
|
+
const routeHas = (...patterns) => patterns.some(pattern => routeLower.includes(pattern) || semanticFileLower.includes(pattern));
|
|
225
|
+
const isRouterConfigFile = has('createbrowserrouter', 'useroutes', '<routes', '<route');
|
|
226
|
+
const contextualHas = (...patterns) => {
|
|
227
|
+
if (routeHas(...patterns)) return true;
|
|
228
|
+
return !isRouterConfigFile && has(...patterns);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const signals = {
|
|
232
|
+
hasForm: has('<form', 'useform', 'formik', 'react-hook-form', 'zodresolver', 'onsubmit', 'handleSubmit'.toLowerCase()),
|
|
233
|
+
hasTable: has('<table', 'datatable', 'ag-grid', 'tanstack table', 'role="table"', 'role="grid"'),
|
|
234
|
+
hasSearch: contextualHas('search', 'buscar', 'query', 'placeholder="buscar', 'placeholder=\'buscar'),
|
|
235
|
+
hasFilter: contextualHas('filter', 'filtro', 'filtrar'),
|
|
236
|
+
hasCreateAction: contextualHas('crear', 'create', 'nuevo', 'new ', 'agregar', 'add '),
|
|
237
|
+
hasEditAction: contextualHas('editar', 'edit', 'actualizar', 'update'),
|
|
238
|
+
hasAuth: has('password', 'contraseña', 'iniciar sesión', 'login', 'sign in') || routeHas('login', 'signin', 'auth'),
|
|
239
|
+
hasCheckout: contextualHas('payment', 'card', 'checkout', 'billing', 'stripe'),
|
|
240
|
+
hasSettings: has('settings', 'configuración', 'preferencias', 'preferences') || routeHas('settings', 'config', 'preferences'),
|
|
241
|
+
hasUpload: contextualHas('type="file"', "type='file'", 'dropzone', 'upload', 'subir archivo', 'subir imagen'),
|
|
242
|
+
isDynamicRoute: /\[[^\]]+\]/.test(route) || /:\w+/.test(route),
|
|
243
|
+
routeSegments: routeLower.replace(/^\/+/, '').split('/').filter(Boolean),
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
signals.hasAuth = contextualHas('password', 'login', 'signin', 'sign in', 'auth', 'contrase', 'iniciar sesi');
|
|
247
|
+
signals.hasSettings = contextualHas('settings', 'preferences', 'preferencias', 'config');
|
|
248
|
+
|
|
249
|
+
signals.isDashboardLike = route === '/' || routeHas('dashboard', 'welcome', 'home', 'inicio');
|
|
250
|
+
signals.isListLike = signals.hasTable || signals.hasSearch || signals.hasFilter || routeHas('list', 'index', 'catalog', 'admin', 'users');
|
|
251
|
+
signals.isCrudLike = signals.hasCreateAction || signals.hasEditAction || routeHas('create', 'new', 'edit');
|
|
252
|
+
signals.keywords = Array.from(new Set([
|
|
253
|
+
...(signals.hasForm ? ['form'] : []),
|
|
254
|
+
...(signals.hasTable ? ['table'] : []),
|
|
255
|
+
...(signals.hasSearch ? ['search'] : []),
|
|
256
|
+
...(signals.hasFilter ? ['filter'] : []),
|
|
257
|
+
...(signals.hasAuth ? ['auth'] : []),
|
|
258
|
+
...(signals.hasCheckout ? ['checkout'] : []),
|
|
259
|
+
...(signals.hasSettings ? ['settings'] : []),
|
|
260
|
+
...(signals.hasUpload ? ['upload'] : []),
|
|
261
|
+
...(signals.isDynamicRoute ? ['detail'] : []),
|
|
262
|
+
...(signals.isDashboardLike ? ['dashboard'] : []),
|
|
263
|
+
...(signals.isCrudLike ? ['crud'] : []),
|
|
264
|
+
]));
|
|
265
|
+
|
|
266
|
+
return signals;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function buildRouteTags(route, signals) {
|
|
270
|
+
const topLevel = route === '/' ? 'root' : inferPrimaryRouteTag(route);
|
|
271
|
+
return Array.from(new Set(['generated', topLevel, ...signals.keywords, ...(signals.isDynamicRoute ? ['dynamic'] : [])]));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function inferPriority(signals) {
|
|
275
|
+
if (signals.hasCheckout || signals.hasAuth) return 'high';
|
|
276
|
+
if (signals.hasForm || signals.isCrudLike || signals.isDynamicRoute) return 'normal';
|
|
277
|
+
return 'normal';
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function countRouteDepth(route) {
|
|
281
|
+
return normalizeRoute(route).split('/').filter(Boolean).length;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function scoreRouterKind(routerKind) {
|
|
285
|
+
switch (routerKind) {
|
|
286
|
+
case 'next-app':
|
|
287
|
+
return 120;
|
|
288
|
+
case 'next-pages':
|
|
289
|
+
return 115;
|
|
290
|
+
case 'react-router-config':
|
|
291
|
+
return 110;
|
|
292
|
+
case 'react-router-jsx':
|
|
293
|
+
return 105;
|
|
294
|
+
case 'react-router-config-index':
|
|
295
|
+
return 103;
|
|
296
|
+
case 'react-router-jsx-index':
|
|
297
|
+
return 100;
|
|
298
|
+
case 'react-router':
|
|
299
|
+
return 90;
|
|
300
|
+
case 'filesystem-fallback':
|
|
301
|
+
return 40;
|
|
302
|
+
case 'spa-root':
|
|
303
|
+
return 20;
|
|
304
|
+
default:
|
|
305
|
+
return 10;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function scoreCandidate(candidate) {
|
|
310
|
+
const signals = candidate.signals || {};
|
|
311
|
+
let score = scoreRouterKind(candidate.routerKind);
|
|
312
|
+
score += countRouteDepth(candidate.route) * 2;
|
|
313
|
+
score += (candidate.routeParams || []).length * 6;
|
|
314
|
+
score += signals.hasCheckout ? 8 : 0;
|
|
315
|
+
score += signals.hasAuth ? 8 : 0;
|
|
316
|
+
score += signals.hasForm ? 4 : 0;
|
|
317
|
+
score += signals.hasTable ? 3 : 0;
|
|
318
|
+
score += signals.hasSearch ? 2 : 0;
|
|
319
|
+
score += signals.hasFilter ? 2 : 0;
|
|
320
|
+
score += signals.isDynamicRoute ? 3 : 0;
|
|
321
|
+
score += signals.hasSettings ? 2 : 0;
|
|
322
|
+
score += signals.hasUpload ? 2 : 0;
|
|
323
|
+
score += signals.isDashboardLike ? 1 : 0;
|
|
324
|
+
return score;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function compareCandidates(a, b) {
|
|
328
|
+
const scoreDelta = scoreCandidate(b) - scoreCandidate(a);
|
|
329
|
+
if (scoreDelta !== 0) return scoreDelta;
|
|
330
|
+
|
|
331
|
+
const priorityRank = { high: 3, normal: 2, low: 1 };
|
|
332
|
+
const priorityDelta = (priorityRank[b.priority] || 0) - (priorityRank[a.priority] || 0);
|
|
333
|
+
if (priorityDelta !== 0) return priorityDelta;
|
|
334
|
+
|
|
335
|
+
const depthDelta = countRouteDepth(b.route) - countRouteDepth(a.route);
|
|
336
|
+
if (depthDelta !== 0) return depthDelta;
|
|
337
|
+
|
|
338
|
+
return a.pagePath.localeCompare(b.pagePath);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function dedupeAndSortCandidates(candidates = []) {
|
|
342
|
+
const bestByRoute = new Map();
|
|
343
|
+
for (const candidate of candidates) {
|
|
344
|
+
const key = normalizeRoute(candidate.route);
|
|
345
|
+
const current = bestByRoute.get(key);
|
|
346
|
+
if (!current || compareCandidates(candidate, current) < 0) {
|
|
347
|
+
bestByRoute.set(key, candidate);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return Array.from(bestByRoute.values()).sort(compareCandidates);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function createCandidate(projectRoot, sourceFile, route, framework, routerKind, projectContext = {}) {
|
|
355
|
+
const sourcePath = toPosix(path.relative(projectRoot, sourceFile));
|
|
356
|
+
const normalizedRoute = normalizeRoute(route);
|
|
357
|
+
const signals = analyzeSourceSignals(sourceFile, normalizedRoute);
|
|
358
|
+
const routeParams = buildRouteParams(normalizedRoute, projectContext);
|
|
359
|
+
const routeMeta = {
|
|
360
|
+
routeParams,
|
|
361
|
+
isDynamicRoute: routeParams.length > 0,
|
|
362
|
+
exampleRoute: buildExampleRoute(normalizedRoute, routeParams, projectContext),
|
|
363
|
+
routeFamily: inferRouteFamily(normalizedRoute)
|
|
364
|
+
};
|
|
365
|
+
return {
|
|
366
|
+
route: normalizedRoute,
|
|
367
|
+
pagePath: sourcePath,
|
|
368
|
+
sourcePath,
|
|
369
|
+
framework,
|
|
370
|
+
routerKind,
|
|
371
|
+
signals,
|
|
372
|
+
routeParams,
|
|
373
|
+
routeExamplePath: routeMeta.exampleRoute,
|
|
374
|
+
routeFamily: routeMeta.routeFamily,
|
|
375
|
+
name: buildMissionName(normalizedRoute),
|
|
376
|
+
prompt: buildPrompt(normalizedRoute, routeMeta),
|
|
377
|
+
expectedResult: buildExpectedResult(normalizedRoute, routeMeta),
|
|
378
|
+
tags: buildRouteTags(normalizedRoute, signals),
|
|
379
|
+
priority: inferPriority(signals)
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function normalizeNextAppRoute(relativeFile) {
|
|
384
|
+
const withoutApp = toPosix(relativeFile).replace(/^(src\/)?app\//, '');
|
|
385
|
+
const withoutPage = withoutApp.replace(/\/page\.(js|jsx|ts|tsx)$/i, '');
|
|
386
|
+
const segments = withoutPage
|
|
387
|
+
.split('/')
|
|
388
|
+
.filter(Boolean)
|
|
389
|
+
.filter(segment => !/^\(.*\)$/.test(segment))
|
|
390
|
+
.filter(segment => !/^@/.test(segment));
|
|
391
|
+
if (segments.length === 0) return '/';
|
|
392
|
+
return `/${segments.join('/')}`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function normalizeNextPagesRoute(relativeFile) {
|
|
396
|
+
const withoutPages = toPosix(relativeFile).replace(/^(src\/)?pages\//, '');
|
|
397
|
+
const noExt = stripExtension(withoutPages);
|
|
398
|
+
const normalized = noExt.replace(/\/index$/i, '');
|
|
399
|
+
if (!normalized) return '/';
|
|
400
|
+
return `/${normalized}`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function extractBalancedBlock(text, startIndex, openChar, closeChar) {
|
|
404
|
+
let depth = 0;
|
|
405
|
+
let quote = null;
|
|
406
|
+
for (let i = startIndex; i < text.length; i++) {
|
|
407
|
+
const char = text[i];
|
|
408
|
+
const previous = text[i - 1];
|
|
409
|
+
if (quote) {
|
|
410
|
+
if (char === quote && previous !== '\\') quote = null;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (char === '"' || char === '\'' || char === '`') {
|
|
414
|
+
quote = char;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (char === openChar) depth += 1;
|
|
418
|
+
if (char === closeChar) {
|
|
419
|
+
depth -= 1;
|
|
420
|
+
if (depth === 0) {
|
|
421
|
+
return {
|
|
422
|
+
content: text.slice(startIndex, i + 1),
|
|
423
|
+
endIndex: i
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function findTopLevelPropertyValue(objectLiteral, propertyName, expectedOpenChar) {
|
|
432
|
+
let depthCurly = 0;
|
|
433
|
+
let depthSquare = 0;
|
|
434
|
+
let depthParen = 0;
|
|
435
|
+
let quote = null;
|
|
436
|
+
const search = `${propertyName}:`;
|
|
437
|
+
|
|
438
|
+
for (let i = 0; i < objectLiteral.length; i++) {
|
|
439
|
+
const char = objectLiteral[i];
|
|
440
|
+
const previous = objectLiteral[i - 1];
|
|
441
|
+
|
|
442
|
+
if (quote) {
|
|
443
|
+
if (char === quote && previous !== '\\') quote = null;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (char === '"' || char === '\'' || char === '`') {
|
|
448
|
+
quote = char;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (char === '{') depthCurly += 1;
|
|
453
|
+
else if (char === '}') depthCurly -= 1;
|
|
454
|
+
else if (char === '[') depthSquare += 1;
|
|
455
|
+
else if (char === ']') depthSquare -= 1;
|
|
456
|
+
else if (char === '(') depthParen += 1;
|
|
457
|
+
else if (char === ')') depthParen -= 1;
|
|
458
|
+
|
|
459
|
+
if (depthCurly === 1 && depthSquare === 0 && depthParen === 0 && objectLiteral.slice(i, i + search.length) === search) {
|
|
460
|
+
let valueStart = i + search.length;
|
|
461
|
+
while (/\s/.test(objectLiteral[valueStart] || '')) valueStart += 1;
|
|
462
|
+
if (expectedOpenChar) {
|
|
463
|
+
if (objectLiteral[valueStart] !== expectedOpenChar) return null;
|
|
464
|
+
const closeChar = expectedOpenChar === '[' ? ']' : expectedOpenChar === '{' ? '}' : expectedOpenChar;
|
|
465
|
+
const block = extractBalancedBlock(objectLiteral, valueStart, expectedOpenChar, closeChar);
|
|
466
|
+
return block ? block.content : null;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const valueMatch = objectLiteral.slice(valueStart).match(/^(['"`])([^'"`]+)\1/);
|
|
470
|
+
if (valueMatch) return valueMatch[2];
|
|
471
|
+
|
|
472
|
+
const bareMatch = objectLiteral.slice(valueStart).match(/^(true|false|null|\d+)/);
|
|
473
|
+
return bareMatch ? bareMatch[1] : null;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function combineRoutes(parentRoute, childRoute, isIndex = false) {
|
|
481
|
+
const parent = normalizeRoute(parentRoute || '/');
|
|
482
|
+
const child = String(childRoute || '').trim();
|
|
483
|
+
if (isIndex || !child) return parent;
|
|
484
|
+
if (child === '/') return '/';
|
|
485
|
+
if (child.startsWith('/')) return normalizeRoute(child);
|
|
486
|
+
if (parent === '/') return normalizeRoute(child);
|
|
487
|
+
return normalizeRoute(`${parent}/${child}`);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function parseRouteObjectsFromArray(arrayLiteral, parentRoute, pushRoute) {
|
|
491
|
+
let quote = null;
|
|
492
|
+
let depthCurly = 0;
|
|
493
|
+
let depthSquare = 0;
|
|
494
|
+
let depthParen = 0;
|
|
495
|
+
|
|
496
|
+
for (let i = 0; i < arrayLiteral.length; i++) {
|
|
497
|
+
const char = arrayLiteral[i];
|
|
498
|
+
const previous = arrayLiteral[i - 1];
|
|
499
|
+
|
|
500
|
+
if (quote) {
|
|
501
|
+
if (char === quote && previous !== '\\') quote = null;
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (char === '"' || char === '\'' || char === '`') {
|
|
506
|
+
quote = char;
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (char === '[') {
|
|
511
|
+
depthSquare += 1;
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (char === ']') {
|
|
515
|
+
depthSquare -= 1;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
if (char === '(') {
|
|
519
|
+
depthParen += 1;
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
if (char === ')') {
|
|
523
|
+
depthParen -= 1;
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (char === '{') {
|
|
527
|
+
depthCurly += 1;
|
|
528
|
+
if (depthSquare === 1 && depthCurly === 1 && depthParen === 0) {
|
|
529
|
+
const block = extractBalancedBlock(arrayLiteral, i, '{', '}');
|
|
530
|
+
if (block) {
|
|
531
|
+
parseRouteObjectLiteral(block.content, parentRoute, pushRoute);
|
|
532
|
+
i = block.endIndex;
|
|
533
|
+
depthCurly = 0;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if (char === '}') {
|
|
539
|
+
depthCurly -= 1;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function parseRouteObjectLiteral(objectLiteral, parentRoute, pushRoute) {
|
|
545
|
+
const pathValue = findTopLevelPropertyValue(objectLiteral, 'path', null);
|
|
546
|
+
const indexValue = findTopLevelPropertyValue(objectLiteral, 'index', null);
|
|
547
|
+
const childrenLiteral = findTopLevelPropertyValue(objectLiteral, 'children', '[');
|
|
548
|
+
const isIndex = indexValue === 'true';
|
|
549
|
+
const currentRoute = combineRoutes(parentRoute, pathValue, isIndex);
|
|
550
|
+
|
|
551
|
+
if (pathValue && pathValue !== '*') {
|
|
552
|
+
pushRoute(currentRoute, 'react-router-config');
|
|
553
|
+
} else if (isIndex) {
|
|
554
|
+
pushRoute(currentRoute, 'react-router-config-index');
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (childrenLiteral) {
|
|
558
|
+
parseRouteObjectsFromArray(childrenLiteral, currentRoute, pushRoute);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function extractConfiguredRoutes(raw, pushRoute) {
|
|
563
|
+
let found = false;
|
|
564
|
+
const configPatterns = ['createBrowserRouter', 'useRoutes', 'createRoutesFromElements'];
|
|
565
|
+
for (const pattern of configPatterns) {
|
|
566
|
+
let searchIndex = 0;
|
|
567
|
+
while (searchIndex < raw.length) {
|
|
568
|
+
const start = raw.indexOf(pattern, searchIndex);
|
|
569
|
+
if (start === -1) break;
|
|
570
|
+
const arrayStart = raw.indexOf('[', start);
|
|
571
|
+
if (arrayStart === -1) break;
|
|
572
|
+
const arrayBlock = extractBalancedBlock(raw, arrayStart, '[', ']');
|
|
573
|
+
if (arrayBlock) {
|
|
574
|
+
parseRouteObjectsFromArray(arrayBlock.content, '/', pushRoute);
|
|
575
|
+
found = true;
|
|
576
|
+
searchIndex = arrayBlock.endIndex + 1;
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
searchIndex = start + pattern.length;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return found;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function parseJsxRouteAttributes(tagText) {
|
|
586
|
+
const pathMatch = tagText.match(/\bpath\s*=\s*["'`]([^"'`]+)["'`]/);
|
|
587
|
+
const indexMatch = /\bindex(?:\s*=\s*\{?\s*true\s*\}?)?/.test(tagText);
|
|
588
|
+
return {
|
|
589
|
+
path: pathMatch ? pathMatch[1].trim() : null,
|
|
590
|
+
index: indexMatch
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function extractJsxNestedRoutes(raw, pushRoute) {
|
|
595
|
+
const routeTagRegex = /<\/?Route\b[^>]*\/?>/g;
|
|
596
|
+
const stack = [];
|
|
597
|
+
let found = false;
|
|
598
|
+
|
|
599
|
+
for (const match of raw.matchAll(routeTagRegex)) {
|
|
600
|
+
const tagText = match[0];
|
|
601
|
+
const isClosing = /^<\//.test(tagText);
|
|
602
|
+
const isSelfClosing = /\/>$/.test(tagText);
|
|
603
|
+
|
|
604
|
+
if (isClosing) {
|
|
605
|
+
stack.pop();
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const attrs = parseJsxRouteAttributes(tagText);
|
|
610
|
+
const parentRoute = stack.length > 0 ? stack[stack.length - 1].route : '/';
|
|
611
|
+
const currentRoute = combineRoutes(parentRoute, attrs.path, attrs.index);
|
|
612
|
+
|
|
613
|
+
if ((attrs.path && attrs.path !== '*') || attrs.index) {
|
|
614
|
+
pushRoute(currentRoute, attrs.index ? 'react-router-jsx-index' : 'react-router-jsx');
|
|
615
|
+
found = true;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (!isSelfClosing) {
|
|
619
|
+
stack.push({
|
|
620
|
+
route: currentRoute
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return found;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function discoverNextAppRoutes(projectRoot, projectContext = {}) {
|
|
629
|
+
const appDirs = [
|
|
630
|
+
path.join(projectRoot, 'app'),
|
|
631
|
+
path.join(projectRoot, 'src', 'app')
|
|
632
|
+
];
|
|
633
|
+
const candidates = [];
|
|
634
|
+
|
|
635
|
+
for (const appDir of appDirs) {
|
|
636
|
+
if (!fs.existsSync(appDir)) continue;
|
|
637
|
+
candidates.push(
|
|
638
|
+
...walk(appDir)
|
|
639
|
+
.filter(file => /[\\/]page\.(js|jsx|ts|tsx)$/i.test(file))
|
|
640
|
+
.map(file => {
|
|
641
|
+
const relative = path.relative(projectRoot, file);
|
|
642
|
+
return createCandidate(projectRoot, file, normalizeNextAppRoute(relative), 'next', 'next-app', projectContext);
|
|
643
|
+
})
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return dedupeAndSortCandidates(candidates);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function discoverNextPagesRoutes(projectRoot, projectContext = {}) {
|
|
651
|
+
const pagesDirs = [
|
|
652
|
+
path.join(projectRoot, 'pages'),
|
|
653
|
+
path.join(projectRoot, 'src', 'pages')
|
|
654
|
+
];
|
|
655
|
+
const candidates = [];
|
|
656
|
+
|
|
657
|
+
for (const pagesDir of pagesDirs) {
|
|
658
|
+
if (!fs.existsSync(pagesDir)) continue;
|
|
659
|
+
candidates.push(
|
|
660
|
+
...walk(pagesDir)
|
|
661
|
+
.filter(file => PAGE_FILE_RE.test(file))
|
|
662
|
+
.filter(file => !/[\\/]pages[\\/](api)([\\/]|$)/i.test(file))
|
|
663
|
+
.filter(file => !/[\\/]_(app|document|error)\.(js|jsx|ts|tsx)$/i.test(file))
|
|
664
|
+
.map(file => {
|
|
665
|
+
const relative = path.relative(projectRoot, file);
|
|
666
|
+
return createCandidate(projectRoot, file, normalizeNextPagesRoute(relative), 'next', 'next-pages', projectContext);
|
|
667
|
+
})
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return dedupeAndSortCandidates(candidates);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function findReactRouterPaths(projectRoot, projectContext = {}) {
|
|
675
|
+
const srcDir = path.join(projectRoot, 'src');
|
|
676
|
+
if (!fs.existsSync(srcDir)) return [];
|
|
677
|
+
const files = walk(srcDir).filter(file => PAGE_FILE_RE.test(file));
|
|
678
|
+
const routes = new Map();
|
|
679
|
+
const pushRoute = (route, file, routerKind = 'react-router') => {
|
|
680
|
+
const normalizedRoute = route === '' ? '/' : normalizeRoute(route);
|
|
681
|
+
if (!routes.has(normalizedRoute)) {
|
|
682
|
+
routes.set(normalizedRoute, createCandidate(projectRoot, file, normalizedRoute, 'vite', routerKind, projectContext));
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
for (const file of files) {
|
|
687
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
688
|
+
const hasConfiguredRoutes = extractConfiguredRoutes(raw, (route, routerKind) => pushRoute(route, file, routerKind));
|
|
689
|
+
const hasNestedJsxRoutes = extractJsxNestedRoutes(raw, (route, routerKind) => pushRoute(route, file, routerKind));
|
|
690
|
+
const routeRegexes = [
|
|
691
|
+
...(!hasNestedJsxRoutes ? [/<Route\b[^>]*\bpath=["'`]([^"'`]+)["'`]/g] : []),
|
|
692
|
+
...(!hasConfiguredRoutes ? [/\bpath\s*:\s*["'`]([^"'`]+)["'`]/g] : [])
|
|
693
|
+
];
|
|
694
|
+
for (const regex of routeRegexes) {
|
|
695
|
+
for (const match of raw.matchAll(regex)) {
|
|
696
|
+
const route = match[1]?.trim();
|
|
697
|
+
if (!route || route === '*') continue;
|
|
698
|
+
pushRoute(route.startsWith('/') ? route : `/${route}`, file);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (routes.size > 0) return dedupeAndSortCandidates(Array.from(routes.values()));
|
|
704
|
+
|
|
705
|
+
const fallbackDirs = [
|
|
706
|
+
path.join(srcDir, 'pages'),
|
|
707
|
+
path.join(srcDir, 'views')
|
|
708
|
+
];
|
|
709
|
+
for (const dir of fallbackDirs) {
|
|
710
|
+
if (!fs.existsSync(dir)) continue;
|
|
711
|
+
const candidates = walk(dir)
|
|
712
|
+
.filter(file => PAGE_FILE_RE.test(file))
|
|
713
|
+
.map(file => {
|
|
714
|
+
const relative = toPosix(path.relative(srcDir, file));
|
|
715
|
+
const route = `/${stripExtension(relative).replace(/^(pages|views)\//, '').replace(/\/index$/i, '')}`.replace(/\/+/g, '/');
|
|
716
|
+
return createCandidate(projectRoot, file, route === '/' ? '/' : route, 'vite', 'filesystem-fallback', projectContext);
|
|
717
|
+
});
|
|
718
|
+
if (candidates.length > 0) return dedupeAndSortCandidates(candidates);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const appLikeFiles = files.filter(file => /[\\/]src[\\/](App|main|index)\.(js|jsx|ts|tsx)$/i.test(file));
|
|
722
|
+
if (appLikeFiles.length > 0) {
|
|
723
|
+
return dedupeAndSortCandidates([createCandidate(projectRoot, appLikeFiles[0], '/', 'vite', 'spa-root', projectContext)]);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return [];
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
export function discoverProjectRoutes(projectRoot, framework = 'unknown', projectContext = {}) {
|
|
730
|
+
if (framework === 'next') {
|
|
731
|
+
const appRoutes = discoverNextAppRoutes(projectRoot, projectContext);
|
|
732
|
+
if (appRoutes.length > 0) return appRoutes;
|
|
733
|
+
return discoverNextPagesRoutes(projectRoot, projectContext);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (framework === 'vite' || framework === 'cra') {
|
|
737
|
+
return findReactRouterPaths(projectRoot, projectContext);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const nextApp = discoverNextAppRoutes(projectRoot, projectContext);
|
|
741
|
+
if (nextApp.length > 0) return nextApp;
|
|
742
|
+
|
|
743
|
+
const nextPages = discoverNextPagesRoutes(projectRoot, projectContext);
|
|
744
|
+
if (nextPages.length > 0) return nextPages;
|
|
745
|
+
|
|
746
|
+
return findReactRouterPaths(projectRoot, projectContext);
|
|
747
|
+
}
|