@appilots/cli 0.3.0 → 0.4.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/dist/cli/index.js +1812 -241
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.mts +200 -12
- package/dist/index.d.ts +200 -12
- package/dist/index.js +1594 -43
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1583 -38
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs, { readFile, mkdir, writeFile } from 'fs/promises';
|
|
2
|
-
import * as
|
|
3
|
-
import
|
|
2
|
+
import * as path8 from 'path';
|
|
3
|
+
import path8__default, { join, resolve } from 'path';
|
|
4
4
|
import traverse4 from '@babel/traverse';
|
|
5
5
|
import * as BabelTypes from '@babel/types';
|
|
6
6
|
import glob from 'fast-glob';
|
|
@@ -392,12 +392,12 @@ var ScreenAnalyzer = class {
|
|
|
392
392
|
cwd: this.config.rootDir,
|
|
393
393
|
ignore: exclude
|
|
394
394
|
});
|
|
395
|
-
screenPatternFiles = new Set(matched.map((f) =>
|
|
395
|
+
screenPatternFiles = new Set(matched.map((f) => path8__default.resolve(this.config.rootDir, f)));
|
|
396
396
|
}
|
|
397
397
|
const screens = [];
|
|
398
398
|
this.screensFilteredOut = 0;
|
|
399
399
|
for (const file of files) {
|
|
400
|
-
const filePath =
|
|
400
|
+
const filePath = path8__default.resolve(this.config.rootDir, file);
|
|
401
401
|
try {
|
|
402
402
|
const descriptor = await this.analyzeFile(filePath);
|
|
403
403
|
if (!descriptor) continue;
|
|
@@ -1186,7 +1186,7 @@ var ScreenAnalyzer = class {
|
|
|
1186
1186
|
* heuristic must hit OR the JSDoc tag must be present.
|
|
1187
1187
|
*/
|
|
1188
1188
|
isElementDestructive(element, handlerName, actionId) {
|
|
1189
|
-
const
|
|
1189
|
+
const DESTRUCTIVE_VERB3 = /(delete|destroy|remove|discard|wipe|erase|drop|terminate|revoke|deactivate|disable)/i;
|
|
1190
1190
|
for (const attr of element.attributes) {
|
|
1191
1191
|
if (!BabelTypes.isJSXAttribute(attr)) continue;
|
|
1192
1192
|
const attrName = BabelTypes.isJSXIdentifier(attr.name) ? attr.name.name : null;
|
|
@@ -1201,8 +1201,8 @@ var ScreenAnalyzer = class {
|
|
|
1201
1201
|
}
|
|
1202
1202
|
}
|
|
1203
1203
|
}
|
|
1204
|
-
if (handlerName &&
|
|
1205
|
-
if (actionId &&
|
|
1204
|
+
if (handlerName && DESTRUCTIVE_VERB3.test(handlerName)) return true;
|
|
1205
|
+
if (actionId && DESTRUCTIVE_VERB3.test(actionId)) return true;
|
|
1206
1206
|
return false;
|
|
1207
1207
|
}
|
|
1208
1208
|
/**
|
|
@@ -1546,7 +1546,7 @@ var ScreenAnalyzer = class {
|
|
|
1546
1546
|
* E.g., /src/screens/ItemListScreen.tsx -> ItemListScreen
|
|
1547
1547
|
*/
|
|
1548
1548
|
extractScreenName(filePath) {
|
|
1549
|
-
const basename2 =
|
|
1549
|
+
const basename2 = path8__default.basename(filePath);
|
|
1550
1550
|
return basename2.replace(/\.(tsx?|jsx?)$/, "");
|
|
1551
1551
|
}
|
|
1552
1552
|
};
|
|
@@ -1601,7 +1601,7 @@ var NavigationAnalyzer = class {
|
|
|
1601
1601
|
cwd: this.config.rootDir,
|
|
1602
1602
|
ignore: excludePatterns
|
|
1603
1603
|
});
|
|
1604
|
-
return files.map((file) =>
|
|
1604
|
+
return files.map((file) => path8__default.join(this.config.rootDir, file));
|
|
1605
1605
|
}
|
|
1606
1606
|
/** Parse navigator definitions from a file */
|
|
1607
1607
|
parseNavigators(content, filePath) {
|
|
@@ -1804,7 +1804,7 @@ var NavigationAnalyzer = class {
|
|
|
1804
1804
|
if (type.type === "TSUndefinedKeyword") return "undefined";
|
|
1805
1805
|
if (type.type === "TSNullKeyword") return "null";
|
|
1806
1806
|
if (type.type === "TSUnionType") {
|
|
1807
|
-
return type.types.map((
|
|
1807
|
+
return type.types.map((t12) => this.typeToString(t12)).join(" | ");
|
|
1808
1808
|
}
|
|
1809
1809
|
if (type.type === "TSTypeLiteral") {
|
|
1810
1810
|
return "object";
|
|
@@ -1825,7 +1825,7 @@ var NavigationAnalyzer = class {
|
|
|
1825
1825
|
/** Attach parsed type params to navigator screens */
|
|
1826
1826
|
attachParamsToNavigators(navigators, types) {
|
|
1827
1827
|
for (const navigator of navigators) {
|
|
1828
|
-
const matchingType = types.find((
|
|
1828
|
+
const matchingType = types.find((t12) => t12.type === navigator.type);
|
|
1829
1829
|
if (matchingType) {
|
|
1830
1830
|
for (const screen of navigator.screens) {
|
|
1831
1831
|
const screenParams = matchingType.paramEntries.get(screen.name);
|
|
@@ -1910,8 +1910,8 @@ var ComponentAnalyzer = class {
|
|
|
1910
1910
|
});
|
|
1911
1911
|
const components = [];
|
|
1912
1912
|
traverse4(ast, {
|
|
1913
|
-
JSXElement: (
|
|
1914
|
-
const component = this.extractComponentFromJSXElement(
|
|
1913
|
+
JSXElement: (path9) => {
|
|
1914
|
+
const component = this.extractComponentFromJSXElement(path9.node);
|
|
1915
1915
|
if (component) {
|
|
1916
1916
|
components.push(component);
|
|
1917
1917
|
}
|
|
@@ -2038,13 +2038,13 @@ var FormAnalyzer = class {
|
|
|
2038
2038
|
this.inputElements = [];
|
|
2039
2039
|
this.submitButtons = [];
|
|
2040
2040
|
traverse4(ast, {
|
|
2041
|
-
CallExpression: (
|
|
2042
|
-
this.extractStateVariables(
|
|
2041
|
+
CallExpression: (path9) => {
|
|
2042
|
+
this.extractStateVariables(path9.node);
|
|
2043
2043
|
}
|
|
2044
2044
|
});
|
|
2045
2045
|
traverse4(ast, {
|
|
2046
|
-
JSXElement: (
|
|
2047
|
-
this.extractFormElements(
|
|
2046
|
+
JSXElement: (path9) => {
|
|
2047
|
+
this.extractFormElements(path9.node);
|
|
2048
2048
|
}
|
|
2049
2049
|
});
|
|
2050
2050
|
const validationRules = this.extractValidationRules(ast);
|
|
@@ -2144,8 +2144,8 @@ var FormAnalyzer = class {
|
|
|
2144
2144
|
extractValidationRules(ast) {
|
|
2145
2145
|
const rules = {};
|
|
2146
2146
|
traverse4(ast, {
|
|
2147
|
-
IfStatement: (
|
|
2148
|
-
const test =
|
|
2147
|
+
IfStatement: (path9) => {
|
|
2148
|
+
const test = path9.node.test;
|
|
2149
2149
|
const rule = this.extractRuleFromCondition(test);
|
|
2150
2150
|
if (rule) {
|
|
2151
2151
|
const { field, description } = rule;
|
|
@@ -2199,7 +2199,7 @@ var FormAnalyzer = class {
|
|
|
2199
2199
|
}
|
|
2200
2200
|
buildForms(filePath, validationRules) {
|
|
2201
2201
|
if (this.inputElements.length === 0) return [];
|
|
2202
|
-
const fileName =
|
|
2202
|
+
const fileName = path8.basename(filePath, path8.extname(filePath));
|
|
2203
2203
|
const formId = `${fileName}Form`.replace(/Screen$/, "").toLowerCase();
|
|
2204
2204
|
const fields = this.inputElements.map((input) => {
|
|
2205
2205
|
const fieldType = this.inferFieldType(input);
|
|
@@ -2277,7 +2277,7 @@ var ReactNativePlatformAnalyzer = class {
|
|
|
2277
2277
|
`[ReactNativePlatformAnalyzer] Analyzing components and forms from ${screenFiles.length} files...`
|
|
2278
2278
|
);
|
|
2279
2279
|
const enrichmentPromises = screenFiles.map(async (file) => {
|
|
2280
|
-
const filePath =
|
|
2280
|
+
const filePath = path8__default.resolve(config.rootDir, file);
|
|
2281
2281
|
try {
|
|
2282
2282
|
const [components, forms] = await Promise.all([
|
|
2283
2283
|
componentAnalyzer.analyzeFile(filePath),
|
|
@@ -2409,6 +2409,1480 @@ var GenericPlatformAnalyzer = class {
|
|
|
2409
2409
|
}
|
|
2410
2410
|
};
|
|
2411
2411
|
|
|
2412
|
+
// src/ast/jsx/web/classify.ts
|
|
2413
|
+
var VIEW_TAGS = /* @__PURE__ */ new Set([
|
|
2414
|
+
"div",
|
|
2415
|
+
"span",
|
|
2416
|
+
"section",
|
|
2417
|
+
"main",
|
|
2418
|
+
"article",
|
|
2419
|
+
"aside",
|
|
2420
|
+
"header",
|
|
2421
|
+
"footer",
|
|
2422
|
+
"nav",
|
|
2423
|
+
"fieldset",
|
|
2424
|
+
"form",
|
|
2425
|
+
"p"
|
|
2426
|
+
]);
|
|
2427
|
+
var LIST_TAGS = /* @__PURE__ */ new Set(["ul", "ol", "dl", "table", "tbody"]);
|
|
2428
|
+
var BUTTON_TAGS = /* @__PURE__ */ new Set(["button"]);
|
|
2429
|
+
var INPUT_TAGS = /* @__PURE__ */ new Set(["input", "textarea"]);
|
|
2430
|
+
var MODAL_TAGS = /* @__PURE__ */ new Set(["dialog"]);
|
|
2431
|
+
var BUTTON_COMPONENTS2 = /* @__PURE__ */ new Set(["Button", "IconButton", "Link", "NavLink"]);
|
|
2432
|
+
var INPUT_COMPONENTS2 = /* @__PURE__ */ new Set(["Input", "TextField", "TextArea", "Textarea"]);
|
|
2433
|
+
var LIST_COMPONENTS2 = /* @__PURE__ */ new Set(["List", "Table", "DataGrid", "DataTable"]);
|
|
2434
|
+
var MODAL_COMPONENTS2 = /* @__PURE__ */ new Set(["Modal", "Dialog", "Drawer", "Popover", "BottomSheet"]);
|
|
2435
|
+
var ARIA_ROLE_TO_SEMANTIC = {
|
|
2436
|
+
button: "button",
|
|
2437
|
+
link: "button",
|
|
2438
|
+
tab: "button",
|
|
2439
|
+
menuitem: "button",
|
|
2440
|
+
textbox: "input",
|
|
2441
|
+
searchbox: "input",
|
|
2442
|
+
spinbutton: "input",
|
|
2443
|
+
listbox: "select",
|
|
2444
|
+
combobox: "select",
|
|
2445
|
+
radiogroup: "select",
|
|
2446
|
+
radio: "select",
|
|
2447
|
+
option: "select",
|
|
2448
|
+
checkbox: "toggle",
|
|
2449
|
+
switch: "toggle",
|
|
2450
|
+
dialog: "modal",
|
|
2451
|
+
alertdialog: "modal",
|
|
2452
|
+
list: "list",
|
|
2453
|
+
table: "list",
|
|
2454
|
+
grid: "list",
|
|
2455
|
+
form: "view"
|
|
2456
|
+
};
|
|
2457
|
+
var INPUT_TYPE_TO_SEMANTIC = {
|
|
2458
|
+
checkbox: "toggle",
|
|
2459
|
+
radio: "select",
|
|
2460
|
+
date: "date",
|
|
2461
|
+
"datetime-local": "date",
|
|
2462
|
+
month: "date",
|
|
2463
|
+
week: "date",
|
|
2464
|
+
time: "date",
|
|
2465
|
+
submit: "button",
|
|
2466
|
+
button: "button",
|
|
2467
|
+
reset: "button",
|
|
2468
|
+
image: "button",
|
|
2469
|
+
hidden: "custom",
|
|
2470
|
+
range: "input",
|
|
2471
|
+
file: "input",
|
|
2472
|
+
color: "input"
|
|
2473
|
+
};
|
|
2474
|
+
function classifyWebJsxComponent(name, element) {
|
|
2475
|
+
if (element) {
|
|
2476
|
+
const role = getStringAttr(element, "role");
|
|
2477
|
+
if (role && ARIA_ROLE_TO_SEMANTIC[role]) return ARIA_ROLE_TO_SEMANTIC[role];
|
|
2478
|
+
}
|
|
2479
|
+
if (/^[a-z]/.test(name)) {
|
|
2480
|
+
if (name === "input") {
|
|
2481
|
+
const type = element ? getStringAttr(element, "type") : void 0;
|
|
2482
|
+
if (type && INPUT_TYPE_TO_SEMANTIC[type]) return INPUT_TYPE_TO_SEMANTIC[type];
|
|
2483
|
+
return "input";
|
|
2484
|
+
}
|
|
2485
|
+
if (INPUT_TAGS.has(name)) return "input";
|
|
2486
|
+
if (name === "select") return "select";
|
|
2487
|
+
if (BUTTON_TAGS.has(name)) return "button";
|
|
2488
|
+
if (name === "a") {
|
|
2489
|
+
if (element && (hasJsxAttribute(element, "href") || hasJsxAttribute(element, "onClick"))) {
|
|
2490
|
+
return "button";
|
|
2491
|
+
}
|
|
2492
|
+
return "view";
|
|
2493
|
+
}
|
|
2494
|
+
if (LIST_TAGS.has(name)) return "list";
|
|
2495
|
+
if (MODAL_TAGS.has(name)) return "modal";
|
|
2496
|
+
if (VIEW_TAGS.has(name)) return "view";
|
|
2497
|
+
return "custom";
|
|
2498
|
+
}
|
|
2499
|
+
if (LIST_COMPONENTS2.has(name)) return "list";
|
|
2500
|
+
if (MODAL_COMPONENTS2.has(name)) return "modal";
|
|
2501
|
+
if (/date/i.test(name)) return "date";
|
|
2502
|
+
if (/(select|picker|dropdown|radio)/i.test(name)) return "select";
|
|
2503
|
+
if (/(checkbox|switch|toggle)/i.test(name)) return "toggle";
|
|
2504
|
+
if (INPUT_COMPONENTS2.has(name)) return "input";
|
|
2505
|
+
if (BUTTON_COMPONENTS2.has(name)) return "button";
|
|
2506
|
+
if (element) {
|
|
2507
|
+
const hasOptions = hasJsxAttribute(element, "options");
|
|
2508
|
+
const hasValue = hasJsxAttribute(element, "value");
|
|
2509
|
+
const hasChecked = hasJsxAttribute(element, "checked") || hasJsxAttribute(element, "selected");
|
|
2510
|
+
const hasOnChange = hasJsxAttribute(element, "onChange") || hasJsxAttribute(element, "onValueChange");
|
|
2511
|
+
const hasOnClick = hasJsxAttribute(element, "onClick");
|
|
2512
|
+
if (hasOptions && (hasValue || hasOnChange)) return "select";
|
|
2513
|
+
if (hasChecked && hasOnChange) return "toggle";
|
|
2514
|
+
if (hasOnChange && (hasJsxAttribute(element, "label") || hasJsxAttribute(element, "placeholder"))) {
|
|
2515
|
+
return "input";
|
|
2516
|
+
}
|
|
2517
|
+
if (hasOnClick) return "button";
|
|
2518
|
+
if (getStringAttr(element, "open") || getExpressionIdentifierAttr(element, "open") || getExpressionIdentifierAttr(element, "isOpen")) {
|
|
2519
|
+
return "modal";
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
return "custom";
|
|
2523
|
+
}
|
|
2524
|
+
function getJsxElementName(openingElement) {
|
|
2525
|
+
return getJsxName(openingElement.name);
|
|
2526
|
+
}
|
|
2527
|
+
function getJsxName(name) {
|
|
2528
|
+
if (BabelTypes.isJSXIdentifier(name)) return name.name;
|
|
2529
|
+
if (BabelTypes.isJSXNamespacedName(name)) return `${name.namespace.name}:${name.name.name}`;
|
|
2530
|
+
if (BabelTypes.isJSXMemberExpression(name)) {
|
|
2531
|
+
const objectName = getJsxName(name.object);
|
|
2532
|
+
const propertyName = BabelTypes.isJSXIdentifier(name.property) ? name.property.name : null;
|
|
2533
|
+
return objectName && propertyName ? `${objectName}.${propertyName}` : null;
|
|
2534
|
+
}
|
|
2535
|
+
return null;
|
|
2536
|
+
}
|
|
2537
|
+
var ROUTERISH_OBJECTS = /^(router|history|navigation)$/;
|
|
2538
|
+
function staticRoutePath(node) {
|
|
2539
|
+
if (!node) return void 0;
|
|
2540
|
+
if (BabelTypes.isStringLiteral(node)) return node.value;
|
|
2541
|
+
if (BabelTypes.isTemplateLiteral(node)) {
|
|
2542
|
+
let path9 = "";
|
|
2543
|
+
node.quasis.forEach((quasi, index) => {
|
|
2544
|
+
path9 += quasi.value.cooked ?? quasi.value.raw;
|
|
2545
|
+
const expr = node.expressions[index];
|
|
2546
|
+
if (expr) path9 += `:${paramNameOf(expr)}`;
|
|
2547
|
+
});
|
|
2548
|
+
return path9;
|
|
2549
|
+
}
|
|
2550
|
+
return void 0;
|
|
2551
|
+
}
|
|
2552
|
+
function paramNameOf(expr) {
|
|
2553
|
+
if (BabelTypes.isIdentifier(expr)) return expr.name;
|
|
2554
|
+
if (BabelTypes.isMemberExpression(expr) && BabelTypes.isIdentifier(expr.property)) return expr.property.name;
|
|
2555
|
+
return "param";
|
|
2556
|
+
}
|
|
2557
|
+
function extractWebNavigationCalls(ast) {
|
|
2558
|
+
const calls = [];
|
|
2559
|
+
const inspect = (node) => {
|
|
2560
|
+
if (BabelTypes.isCallExpression(node)) {
|
|
2561
|
+
if (BabelTypes.isIdentifier(node.callee) && node.callee.name === "navigate") {
|
|
2562
|
+
const first = node.arguments[0];
|
|
2563
|
+
const targetPath = staticRoutePath(first);
|
|
2564
|
+
if (targetPath !== void 0) {
|
|
2565
|
+
calls.push({
|
|
2566
|
+
method: hasReplaceOption(node.arguments[1]) ? "replace" : "navigate",
|
|
2567
|
+
targetPath
|
|
2568
|
+
});
|
|
2569
|
+
} else if (BabelTypes.isNumericLiteral(first) || BabelTypes.isUnaryExpression(first) && first.operator === "-") {
|
|
2570
|
+
calls.push({ method: "goBack" });
|
|
2571
|
+
}
|
|
2572
|
+
return;
|
|
2573
|
+
}
|
|
2574
|
+
if (BabelTypes.isMemberExpression(node.callee) && BabelTypes.isIdentifier(node.callee.object) && ROUTERISH_OBJECTS.test(node.callee.object.name) && BabelTypes.isIdentifier(node.callee.property)) {
|
|
2575
|
+
const method = node.callee.property.name;
|
|
2576
|
+
const targetPath = staticRoutePath(node.arguments[0]);
|
|
2577
|
+
if ((method === "push" || method === "navigate") && targetPath !== void 0) {
|
|
2578
|
+
calls.push({ method: "navigate", targetPath });
|
|
2579
|
+
} else if (method === "replace" && targetPath !== void 0) {
|
|
2580
|
+
calls.push({ method: "replace", targetPath });
|
|
2581
|
+
} else if (method === "back" || method === "goBack") {
|
|
2582
|
+
calls.push({ method: "goBack" });
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
return;
|
|
2586
|
+
}
|
|
2587
|
+
if (BabelTypes.isAssignmentExpression(node) && BabelTypes.isMemberExpression(node.left) && BabelTypes.isIdentifier(node.left.property) && node.left.property.name === "href" && isLocationExpression(node.left.object) && BabelTypes.isStringLiteral(node.right) && node.right.value.startsWith("/")) {
|
|
2588
|
+
calls.push({ method: "navigate", targetPath: node.right.value });
|
|
2589
|
+
}
|
|
2590
|
+
};
|
|
2591
|
+
traverse4(ast, {
|
|
2592
|
+
noScope: !BabelTypes.isFile(ast),
|
|
2593
|
+
enter: (nodePath) => inspect(nodePath.node)
|
|
2594
|
+
});
|
|
2595
|
+
return calls;
|
|
2596
|
+
}
|
|
2597
|
+
function hasReplaceOption(arg) {
|
|
2598
|
+
if (!arg || !BabelTypes.isObjectExpression(arg)) return false;
|
|
2599
|
+
return arg.properties.some(
|
|
2600
|
+
(prop) => BabelTypes.isObjectProperty(prop) && BabelTypes.isIdentifier(prop.key) && prop.key.name === "replace" && BabelTypes.isBooleanLiteral(prop.value) && prop.value.value === true
|
|
2601
|
+
);
|
|
2602
|
+
}
|
|
2603
|
+
function isLocationExpression(node) {
|
|
2604
|
+
if (BabelTypes.isIdentifier(node)) return node.name === "location";
|
|
2605
|
+
return BabelTypes.isMemberExpression(node) && BabelTypes.isIdentifier(node.object) && node.object.name === "window" && BabelTypes.isIdentifier(node.property) && node.property.name === "location";
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
// src/analyzers/web/WebScreenAnalyzer.ts
|
|
2609
|
+
var DEFAULT_WEB_SCREEN_PATTERNS = [
|
|
2610
|
+
"**/pages/**/*.{ts,tsx,js,jsx}",
|
|
2611
|
+
"**/routes/**/*.{ts,tsx,js,jsx}",
|
|
2612
|
+
"**/views/**/*.{ts,tsx,js,jsx}",
|
|
2613
|
+
"**/app/**/*.{ts,tsx,js,jsx}",
|
|
2614
|
+
"**/*Page.{ts,tsx,js,jsx}",
|
|
2615
|
+
"**/*Screen.{ts,tsx,js,jsx}",
|
|
2616
|
+
"**/*View.{ts,tsx,js,jsx}"
|
|
2617
|
+
];
|
|
2618
|
+
var DESTRUCTIVE_VERB2 = /(delete|destroy|remove|discard|wipe|erase|drop|terminate|revoke|deactivate|disable)/i;
|
|
2619
|
+
var LISTISH_TAGS = /* @__PURE__ */ new Set(["ul", "ol", "dl", "table", "tbody"]);
|
|
2620
|
+
var WebScreenAnalyzer = class {
|
|
2621
|
+
config;
|
|
2622
|
+
verbose = process.env.VERBOSE === "true";
|
|
2623
|
+
screenPatterns;
|
|
2624
|
+
constructor(config, options) {
|
|
2625
|
+
this.config = config;
|
|
2626
|
+
this.screenPatterns = options?.screenPatterns ?? DEFAULT_WEB_SCREEN_PATTERNS;
|
|
2627
|
+
}
|
|
2628
|
+
async analyze() {
|
|
2629
|
+
const {
|
|
2630
|
+
include = ["**/*.tsx", "**/*.ts", "**/*.jsx", "**/*.js"],
|
|
2631
|
+
exclude = ["**/node_modules/**", "**/dist/**", "**/build/**"]
|
|
2632
|
+
} = this.config;
|
|
2633
|
+
const files = await glob(include, { cwd: this.config.rootDir, ignore: exclude });
|
|
2634
|
+
const patternMatches = await glob(this.screenPatterns, {
|
|
2635
|
+
cwd: this.config.rootDir,
|
|
2636
|
+
ignore: exclude
|
|
2637
|
+
});
|
|
2638
|
+
const patternSet = new Set(patternMatches.map((f) => path8__default.resolve(this.config.rootDir, f)));
|
|
2639
|
+
const candidates = [];
|
|
2640
|
+
for (const file of files) {
|
|
2641
|
+
const filePath = path8__default.resolve(this.config.rootDir, file);
|
|
2642
|
+
try {
|
|
2643
|
+
const candidate = await this.analyzeFile(filePath);
|
|
2644
|
+
if (!candidate) continue;
|
|
2645
|
+
candidate.matchesScreenPattern = patternSet.has(filePath);
|
|
2646
|
+
candidates.push(candidate);
|
|
2647
|
+
if (this.verbose) {
|
|
2648
|
+
console.log(`[WebScreenAnalyzer] \u2713 Analyzed: ${candidate.descriptor.name} (${file})`);
|
|
2649
|
+
}
|
|
2650
|
+
} catch (error) {
|
|
2651
|
+
if (this.verbose) {
|
|
2652
|
+
console.warn(
|
|
2653
|
+
`[WebScreenAnalyzer] Failed to parse ${file}:`,
|
|
2654
|
+
error instanceof Error ? error.message : error
|
|
2655
|
+
);
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
return { candidates, analyzedFiles: files.length };
|
|
2660
|
+
}
|
|
2661
|
+
async analyzeFile(filePath) {
|
|
2662
|
+
const source = await fs.readFile(filePath, "utf-8");
|
|
2663
|
+
const ast = parseSource(source, this.config.parserPlugins);
|
|
2664
|
+
const registerScreenMeta = this.extractRegisterScreenMetadata(ast);
|
|
2665
|
+
const componentName = this.extractComponentName(ast);
|
|
2666
|
+
const labelsByHtmlFor = this.collectHtmlForLabels(ast);
|
|
2667
|
+
const handlerBehaviors = this.collectWebHandlerBehaviors(ast);
|
|
2668
|
+
const forms = this.mergeForms(
|
|
2669
|
+
registerScreenMeta?.forms ?? [],
|
|
2670
|
+
this.extractForms(ast, labelsByHtmlFor)
|
|
2671
|
+
);
|
|
2672
|
+
const actions = this.extractActions(ast, registerScreenMeta, handlerBehaviors);
|
|
2673
|
+
const components = this.extractComponents(ast);
|
|
2674
|
+
const collections = this.extractCollections(ast);
|
|
2675
|
+
const navigationTargets = this.extractNavigationTargets(ast);
|
|
2676
|
+
const permissionsFromJsDoc = extractPermissionsFromJsDoc(source);
|
|
2677
|
+
const destructiveTags = extractDestructiveJsDocTargets(source);
|
|
2678
|
+
if (destructiveTags) {
|
|
2679
|
+
for (const action of actions) {
|
|
2680
|
+
if (destructiveTags === "*" || destructiveTags.has(action.id)) {
|
|
2681
|
+
action.destructive = true;
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
const name = registerScreenMeta?.name || componentName || path8__default.basename(filePath).replace(/\.(tsx?|jsx?)$/, "");
|
|
2686
|
+
const descriptor = {
|
|
2687
|
+
name,
|
|
2688
|
+
filePath,
|
|
2689
|
+
title: registerScreenMeta?.title,
|
|
2690
|
+
description: registerScreenMeta?.description,
|
|
2691
|
+
components,
|
|
2692
|
+
forms,
|
|
2693
|
+
actions,
|
|
2694
|
+
navigationTargets,
|
|
2695
|
+
...collections.length > 0 ? { collections } : {},
|
|
2696
|
+
...registerScreenMeta?.suggestedPrompts && registerScreenMeta.suggestedPrompts.length > 0 ? { suggestedPrompts: registerScreenMeta.suggestedPrompts } : {},
|
|
2697
|
+
...permissionsFromJsDoc ? {
|
|
2698
|
+
permissions: permissionsFromJsDoc,
|
|
2699
|
+
...permissionsFromJsDoc.isPii ? { isPii: true } : {}
|
|
2700
|
+
} : {}
|
|
2701
|
+
};
|
|
2702
|
+
return {
|
|
2703
|
+
descriptor,
|
|
2704
|
+
hasRegisterScreen: registerScreenMeta !== null || this.detectRegisterScreenCall(ast),
|
|
2705
|
+
matchesScreenPattern: false
|
|
2706
|
+
};
|
|
2707
|
+
}
|
|
2708
|
+
// ── registerScreen ────────────────────────────────────────────────
|
|
2709
|
+
detectRegisterScreenCall(ast) {
|
|
2710
|
+
let found = false;
|
|
2711
|
+
traverse4(ast, {
|
|
2712
|
+
CallExpression: (nodePath) => {
|
|
2713
|
+
if (found) return;
|
|
2714
|
+
if (isRegisterScreenCallee(nodePath.node.callee)) {
|
|
2715
|
+
found = true;
|
|
2716
|
+
nodePath.stop();
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
});
|
|
2720
|
+
return found;
|
|
2721
|
+
}
|
|
2722
|
+
extractRegisterScreenMetadata(ast) {
|
|
2723
|
+
let plain = null;
|
|
2724
|
+
traverse4(ast, {
|
|
2725
|
+
CallExpression: (nodePath) => {
|
|
2726
|
+
if (!isRegisterScreenCallee(nodePath.node.callee)) return;
|
|
2727
|
+
const arg = nodePath.node.arguments[0];
|
|
2728
|
+
if (BabelTypes.isObjectExpression(arg)) {
|
|
2729
|
+
plain = literalToPlain(arg);
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
});
|
|
2733
|
+
if (!plain) return null;
|
|
2734
|
+
const meta = plain;
|
|
2735
|
+
const result = {
|
|
2736
|
+
name: typeof meta.name === "string" ? meta.name : "",
|
|
2737
|
+
actions: [],
|
|
2738
|
+
forms: [],
|
|
2739
|
+
navigationTargets: [],
|
|
2740
|
+
components: []
|
|
2741
|
+
};
|
|
2742
|
+
if (typeof meta.title === "string") result.title = meta.title;
|
|
2743
|
+
if (typeof meta.description === "string") result.description = meta.description;
|
|
2744
|
+
if (Array.isArray(meta.suggestedPrompts)) {
|
|
2745
|
+
const prompts = meta.suggestedPrompts.filter((p) => typeof p === "string").map((p) => p.trim()).filter((p) => p.length > 0);
|
|
2746
|
+
if (prompts.length > 0) result.suggestedPrompts = prompts;
|
|
2747
|
+
}
|
|
2748
|
+
if (Array.isArray(meta.actions)) {
|
|
2749
|
+
result.actions = meta.actions.filter((a) => typeof a === "object" && a !== null).filter((a) => typeof a.id === "string" && a.id.length > 0).map((a) => ({ type: "custom", ...a }));
|
|
2750
|
+
}
|
|
2751
|
+
if (Array.isArray(meta.fields)) {
|
|
2752
|
+
const fields = meta.fields.filter((f) => typeof f === "object" && f !== null).filter((f) => typeof f.id === "string" && f.id.length > 0).map(
|
|
2753
|
+
(f) => ({
|
|
2754
|
+
name: f.id,
|
|
2755
|
+
type: typeof f.type === "string" ? f.type : "text",
|
|
2756
|
+
required: f.required === true,
|
|
2757
|
+
...typeof f.label === "string" ? { label: f.label } : {},
|
|
2758
|
+
...typeof f.placeholder === "string" ? { placeholder: f.placeholder } : {},
|
|
2759
|
+
...f.defaultValue !== void 0 ? { defaultValue: f.defaultValue } : {},
|
|
2760
|
+
...Array.isArray(f.options) ? { options: f.options } : {}
|
|
2761
|
+
})
|
|
2762
|
+
);
|
|
2763
|
+
if (fields.length > 0) result.forms = [{ id: "default", fields }];
|
|
2764
|
+
}
|
|
2765
|
+
return result;
|
|
2766
|
+
}
|
|
2767
|
+
// ── Component name / structure ────────────────────────────────────
|
|
2768
|
+
/** Default-exported component name, else the first exported capitalized function. */
|
|
2769
|
+
extractComponentName(ast) {
|
|
2770
|
+
let defaultName = "";
|
|
2771
|
+
let firstExported = "";
|
|
2772
|
+
traverse4(ast, {
|
|
2773
|
+
ExportDefaultDeclaration: (nodePath) => {
|
|
2774
|
+
const declaration = nodePath.node.declaration;
|
|
2775
|
+
if (BabelTypes.isFunctionDeclaration(declaration) && declaration.id?.name) {
|
|
2776
|
+
defaultName = declaration.id.name;
|
|
2777
|
+
} else if (BabelTypes.isIdentifier(declaration)) {
|
|
2778
|
+
defaultName = declaration.name;
|
|
2779
|
+
}
|
|
2780
|
+
},
|
|
2781
|
+
ExportNamedDeclaration: (nodePath) => {
|
|
2782
|
+
if (firstExported) return;
|
|
2783
|
+
const declaration = nodePath.node.declaration;
|
|
2784
|
+
if (BabelTypes.isFunctionDeclaration(declaration) && declaration.id && /^[A-Z]/.test(declaration.id.name)) {
|
|
2785
|
+
firstExported = declaration.id.name;
|
|
2786
|
+
} else if (BabelTypes.isVariableDeclaration(declaration)) {
|
|
2787
|
+
for (const declarator of declaration.declarations) {
|
|
2788
|
+
if (BabelTypes.isIdentifier(declarator.id) && /^[A-Z]/.test(declarator.id.name) && (BabelTypes.isArrowFunctionExpression(declarator.init) || BabelTypes.isFunctionExpression(declarator.init))) {
|
|
2789
|
+
firstExported = declarator.id.name;
|
|
2790
|
+
break;
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
});
|
|
2796
|
+
return defaultName || firstExported;
|
|
2797
|
+
}
|
|
2798
|
+
extractComponents(ast) {
|
|
2799
|
+
const components = [];
|
|
2800
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2801
|
+
traverse4(ast, {
|
|
2802
|
+
JSXOpeningElement: (nodePath) => {
|
|
2803
|
+
const element = nodePath.node;
|
|
2804
|
+
const name = getJsxElementName(element);
|
|
2805
|
+
if (!name || seen.has(name)) return;
|
|
2806
|
+
seen.add(name);
|
|
2807
|
+
const role = classifyWebJsxComponent(name, element);
|
|
2808
|
+
const type = role === "select" || role === "toggle" || role === "date" ? "input" : role === "input" || role === "button" || role === "list" || role === "modal" || role === "view" ? role : "custom";
|
|
2809
|
+
const component = { name, type };
|
|
2810
|
+
const testId = getStringAttr(element, "data-testid") ?? getStringAttr(element, "testID");
|
|
2811
|
+
const ariaLabel = getStringAttr(element, "aria-label");
|
|
2812
|
+
if (testId) component.testID = testId;
|
|
2813
|
+
if (ariaLabel) component.accessibilityLabel = ariaLabel;
|
|
2814
|
+
components.push(component);
|
|
2815
|
+
}
|
|
2816
|
+
});
|
|
2817
|
+
return components;
|
|
2818
|
+
}
|
|
2819
|
+
// ── <label htmlFor> association ───────────────────────────────────
|
|
2820
|
+
collectHtmlForLabels(ast) {
|
|
2821
|
+
const labels = /* @__PURE__ */ new Map();
|
|
2822
|
+
traverse4(ast, {
|
|
2823
|
+
JSXElement: (nodePath) => {
|
|
2824
|
+
const element = nodePath.node;
|
|
2825
|
+
if (getJsxElementName(element.openingElement) !== "label") return;
|
|
2826
|
+
const htmlFor = getStringAttr(element.openingElement, "htmlFor");
|
|
2827
|
+
if (!htmlFor) return;
|
|
2828
|
+
const text = jsxTextContent(element);
|
|
2829
|
+
if (text) labels.set(htmlFor, text);
|
|
2830
|
+
}
|
|
2831
|
+
});
|
|
2832
|
+
return labels;
|
|
2833
|
+
}
|
|
2834
|
+
// ── Forms ─────────────────────────────────────────────────────────
|
|
2835
|
+
extractForms(ast, labelsByHtmlFor) {
|
|
2836
|
+
const formBuckets = /* @__PURE__ */ new Map();
|
|
2837
|
+
const usedIds = /* @__PURE__ */ new Set();
|
|
2838
|
+
let formCount = 0;
|
|
2839
|
+
const uniqueId = (preferred) => {
|
|
2840
|
+
if (!usedIds.has(preferred)) {
|
|
2841
|
+
usedIds.add(preferred);
|
|
2842
|
+
return preferred;
|
|
2843
|
+
}
|
|
2844
|
+
let suffix = 2;
|
|
2845
|
+
while (usedIds.has(`${preferred}-${suffix}`)) suffix += 1;
|
|
2846
|
+
const id = `${preferred}-${suffix}`;
|
|
2847
|
+
usedIds.add(id);
|
|
2848
|
+
return id;
|
|
2849
|
+
};
|
|
2850
|
+
const bucketFor = (formElement) => {
|
|
2851
|
+
let bucket = formBuckets.get(formElement);
|
|
2852
|
+
if (bucket) return bucket;
|
|
2853
|
+
formCount += 1;
|
|
2854
|
+
let preferred = "default";
|
|
2855
|
+
let submitAction;
|
|
2856
|
+
if (formElement) {
|
|
2857
|
+
const opening = formElement.openingElement;
|
|
2858
|
+
preferred = getStringAttr(opening, "id") ?? getStringAttr(opening, "name") ?? getStringAttr(opening, "data-testid") ?? (formCount === 1 ? "default" : `form-${formCount}`);
|
|
2859
|
+
submitAction = this.handlerNameFromAttr(opening, "onSubmit");
|
|
2860
|
+
}
|
|
2861
|
+
bucket = { id: uniqueId(preferred), fields: /* @__PURE__ */ new Map(), submitAction };
|
|
2862
|
+
formBuckets.set(formElement, bucket);
|
|
2863
|
+
return bucket;
|
|
2864
|
+
};
|
|
2865
|
+
traverse4(ast, {
|
|
2866
|
+
JSXElement: (nodePath) => {
|
|
2867
|
+
const element = nodePath.node;
|
|
2868
|
+
const name = getJsxElementName(element.openingElement);
|
|
2869
|
+
if (!name) return;
|
|
2870
|
+
const role = classifyWebJsxComponent(name, element.openingElement);
|
|
2871
|
+
if (!["input", "select", "toggle", "date"].includes(role)) return;
|
|
2872
|
+
if (name === "option") return;
|
|
2873
|
+
const field = this.extractField(element, role, labelsByHtmlFor);
|
|
2874
|
+
if (!field.name) return;
|
|
2875
|
+
const formParent = nodePath.findParent(
|
|
2876
|
+
(p) => p.isJSXElement() && (getJsxElementName(p.node.openingElement) === "form" || getStringAttr(p.node.openingElement, "role") === "form")
|
|
2877
|
+
);
|
|
2878
|
+
const bucket = bucketFor(formParent ? formParent.node : null);
|
|
2879
|
+
if (!bucket.fields.has(field.name)) bucket.fields.set(field.name, field);
|
|
2880
|
+
}
|
|
2881
|
+
});
|
|
2882
|
+
traverse4(ast, {
|
|
2883
|
+
JSXElement: (nodePath) => {
|
|
2884
|
+
const element = nodePath.node;
|
|
2885
|
+
const name = getJsxElementName(element.openingElement);
|
|
2886
|
+
if (!name) return;
|
|
2887
|
+
if (!this.isSubmitElement(name, element.openingElement)) return;
|
|
2888
|
+
const formParent = nodePath.findParent(
|
|
2889
|
+
(p) => p.isJSXElement() && getJsxElementName(p.node.openingElement) === "form"
|
|
2890
|
+
);
|
|
2891
|
+
if (!formParent) return;
|
|
2892
|
+
const bucket = formBuckets.get(formParent.node);
|
|
2893
|
+
if (!bucket) return;
|
|
2894
|
+
const handler = this.handlerNameFromAttr(element.openingElement, "onClick");
|
|
2895
|
+
if (!bucket.submitAction && handler) bucket.submitAction = handler;
|
|
2896
|
+
}
|
|
2897
|
+
});
|
|
2898
|
+
return Array.from(formBuckets.values()).filter((bucket) => bucket.fields.size > 0).map((bucket) => ({
|
|
2899
|
+
id: bucket.id,
|
|
2900
|
+
fields: Array.from(bucket.fields.values()),
|
|
2901
|
+
...bucket.submitAction && bucket.submitAction !== "anonymous" ? { submitAction: bucket.submitAction } : {}
|
|
2902
|
+
}));
|
|
2903
|
+
}
|
|
2904
|
+
isSubmitElement(name, opening) {
|
|
2905
|
+
const type = getStringAttr(opening, "type");
|
|
2906
|
+
if (name === "button") return type === "submit" || type === void 0;
|
|
2907
|
+
if (name === "input") return type === "submit";
|
|
2908
|
+
return false;
|
|
2909
|
+
}
|
|
2910
|
+
extractField(element, role, labelsByHtmlFor) {
|
|
2911
|
+
const opening = element.openingElement;
|
|
2912
|
+
const componentName = getJsxElementName(opening) ?? void 0;
|
|
2913
|
+
const field = {
|
|
2914
|
+
name: "",
|
|
2915
|
+
type: "text",
|
|
2916
|
+
required: false,
|
|
2917
|
+
sourceComponent: componentName
|
|
2918
|
+
};
|
|
2919
|
+
const appilotsId = getStringAttr(opening, "appilotsId");
|
|
2920
|
+
const dataTestId = getStringAttr(opening, "data-testid");
|
|
2921
|
+
const domId = getStringAttr(opening, "id");
|
|
2922
|
+
const nameAttr = getStringAttr(opening, "name");
|
|
2923
|
+
const ariaLabel = getStringAttr(opening, "aria-label");
|
|
2924
|
+
const placeholder = getStringAttr(opening, "placeholder");
|
|
2925
|
+
if (placeholder) field.placeholder = placeholder;
|
|
2926
|
+
if (ariaLabel) field.label = ariaLabel;
|
|
2927
|
+
if (domId && labelsByHtmlFor.has(domId)) field.label = labelsByHtmlFor.get(domId);
|
|
2928
|
+
if (appilotsId) {
|
|
2929
|
+
field.name = appilotsId;
|
|
2930
|
+
field.locator = mergeLocator(field.locator, {
|
|
2931
|
+
id: appilotsId,
|
|
2932
|
+
appilotsId,
|
|
2933
|
+
source: "appilotsId"
|
|
2934
|
+
});
|
|
2935
|
+
}
|
|
2936
|
+
if (dataTestId) {
|
|
2937
|
+
if (!field.name) field.name = dataTestId.replace(/^(input-|field-|txt-)/, "");
|
|
2938
|
+
field.locator = mergeLocator(field.locator, {
|
|
2939
|
+
...field.locator?.id ? {} : { id: dataTestId },
|
|
2940
|
+
testID: dataTestId,
|
|
2941
|
+
source: field.locator?.source ?? "data-testid"
|
|
2942
|
+
});
|
|
2943
|
+
}
|
|
2944
|
+
if (nameAttr && !field.name) field.name = nameAttr;
|
|
2945
|
+
if (domId) {
|
|
2946
|
+
if (!field.name) field.name = domId;
|
|
2947
|
+
field.locator = mergeLocator(field.locator, {
|
|
2948
|
+
...field.locator?.id ? {} : { id: domId },
|
|
2949
|
+
source: field.locator?.source ?? "id"
|
|
2950
|
+
});
|
|
2951
|
+
}
|
|
2952
|
+
if (ariaLabel) {
|
|
2953
|
+
field.locator = mergeLocator(field.locator, {
|
|
2954
|
+
...field.locator?.id ? {} : { id: slugify(ariaLabel) },
|
|
2955
|
+
accessibilityLabel: ariaLabel,
|
|
2956
|
+
source: field.locator?.source ?? "aria-label"
|
|
2957
|
+
});
|
|
2958
|
+
}
|
|
2959
|
+
for (const attr of opening.attributes) {
|
|
2960
|
+
if (!BabelTypes.isJSXAttribute(attr) || !BabelTypes.isJSXIdentifier(attr.name)) continue;
|
|
2961
|
+
const attrName = attr.name.name;
|
|
2962
|
+
if (attrName === "required") {
|
|
2963
|
+
if (attr.value === null) field.required = true;
|
|
2964
|
+
else if (BabelTypes.isJSXExpressionContainer(attr.value) && BabelTypes.isBooleanLiteral(attr.value.expression)) {
|
|
2965
|
+
field.required = attr.value.expression.value;
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
if ((attrName === "value" || attrName === "checked") && attr.value && BabelTypes.isJSXExpressionContainer(attr.value) && BabelTypes.isIdentifier(attr.value.expression)) {
|
|
2969
|
+
field.valueBinding = attr.value.expression.name;
|
|
2970
|
+
if (!field.name) field.name = attr.value.expression.name;
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
if (getStringAttr(opening, "aria-required") === "true") field.required = true;
|
|
2974
|
+
if (role === "select") field.type = "select";
|
|
2975
|
+
else if (role === "toggle") field.type = "toggle";
|
|
2976
|
+
else if (role === "date") field.type = "date";
|
|
2977
|
+
else {
|
|
2978
|
+
field.type = this.inferInputType(opening, field);
|
|
2979
|
+
}
|
|
2980
|
+
if (componentName === "select") {
|
|
2981
|
+
const options = this.extractSelectOptions(element);
|
|
2982
|
+
if (options.length > 0) field.options = options;
|
|
2983
|
+
}
|
|
2984
|
+
if (!field.name || isWeakInferredFieldName(field.name)) {
|
|
2985
|
+
const labelish = field.label ?? field.placeholder;
|
|
2986
|
+
if (labelish) field.name = slugify(labelish);
|
|
2987
|
+
}
|
|
2988
|
+
if (!field.locator && field.name) {
|
|
2989
|
+
field.locator = { id: field.name, label: field.label, source: "inferred" };
|
|
2990
|
+
} else if (field.locator && !field.locator.id && field.name) {
|
|
2991
|
+
field.locator = mergeLocator(field.locator, {
|
|
2992
|
+
id: field.name,
|
|
2993
|
+
label: field.label,
|
|
2994
|
+
source: field.locator.source ?? "inferred"
|
|
2995
|
+
});
|
|
2996
|
+
}
|
|
2997
|
+
return field;
|
|
2998
|
+
}
|
|
2999
|
+
inferInputType(opening, field) {
|
|
3000
|
+
const type = getStringAttr(opening, "type");
|
|
3001
|
+
if (type === "email") return "email";
|
|
3002
|
+
if (type === "tel") return "phone";
|
|
3003
|
+
if (type === "number") return "number";
|
|
3004
|
+
if (type === "date" || type === "datetime-local" || type === "month" || type === "week") return "date";
|
|
3005
|
+
const inputMode = getStringAttr(opening, "inputMode") ?? getStringAttr(opening, "inputmode");
|
|
3006
|
+
if (inputMode === "email") return "email";
|
|
3007
|
+
if (inputMode === "tel") return "phone";
|
|
3008
|
+
if (inputMode === "numeric" || inputMode === "decimal") return "number";
|
|
3009
|
+
const combined = `${field.name} ${field.label ?? ""} ${field.placeholder ?? ""}`.toLowerCase();
|
|
3010
|
+
if (combined.includes("email")) return "email";
|
|
3011
|
+
if (combined.includes("phone") || combined.includes("tel")) return "phone";
|
|
3012
|
+
return "text";
|
|
3013
|
+
}
|
|
3014
|
+
extractSelectOptions(selectElement) {
|
|
3015
|
+
const options = [];
|
|
3016
|
+
for (const child of selectElement.children) {
|
|
3017
|
+
if (!BabelTypes.isJSXElement(child)) continue;
|
|
3018
|
+
if (getJsxElementName(child.openingElement) !== "option") continue;
|
|
3019
|
+
const value = getStringAttr(child.openingElement, "value");
|
|
3020
|
+
const label = jsxTextContent(child) || value || "";
|
|
3021
|
+
if (label && value) options.push({ label, value });
|
|
3022
|
+
}
|
|
3023
|
+
return options;
|
|
3024
|
+
}
|
|
3025
|
+
mergeForms(primary, secondary) {
|
|
3026
|
+
const out = primary.map((form) => ({ ...form, fields: [...form.fields] }));
|
|
3027
|
+
for (const form of secondary) {
|
|
3028
|
+
const existing = out.find((candidate) => candidate.id === form.id);
|
|
3029
|
+
if (!existing) {
|
|
3030
|
+
out.push({ ...form, fields: [...form.fields] });
|
|
3031
|
+
continue;
|
|
3032
|
+
}
|
|
3033
|
+
for (const field of form.fields) {
|
|
3034
|
+
const existingField = existing.fields.find((candidate) => candidate.name === field.name);
|
|
3035
|
+
if (!existingField) {
|
|
3036
|
+
existing.fields.push(field);
|
|
3037
|
+
continue;
|
|
3038
|
+
}
|
|
3039
|
+
existingField.label = existingField.label ?? field.label;
|
|
3040
|
+
existingField.placeholder = existingField.placeholder ?? field.placeholder;
|
|
3041
|
+
existingField.options = existingField.options ?? field.options;
|
|
3042
|
+
existingField.locator = existingField.locator ?? field.locator;
|
|
3043
|
+
existingField.sourceComponent = existingField.sourceComponent ?? field.sourceComponent;
|
|
3044
|
+
existingField.valueBinding = existingField.valueBinding ?? field.valueBinding;
|
|
3045
|
+
existingField.required = existingField.required || field.required;
|
|
3046
|
+
if (existingField.type === "text" && field.type !== "text") existingField.type = field.type;
|
|
3047
|
+
}
|
|
3048
|
+
existing.submitAction = existing.submitAction ?? form.submitAction;
|
|
3049
|
+
}
|
|
3050
|
+
return out;
|
|
3051
|
+
}
|
|
3052
|
+
// ── Actions ───────────────────────────────────────────────────────
|
|
3053
|
+
extractActions(ast, registerScreenMeta, handlerBehaviors) {
|
|
3054
|
+
const actions = [...registerScreenMeta?.actions ?? []];
|
|
3055
|
+
const actionIds = new Set(actions.map((a) => a.id));
|
|
3056
|
+
const actionLabels = new Map(
|
|
3057
|
+
actions.filter((a) => a.label).map((a) => [normalizeLabel(a.label), a])
|
|
3058
|
+
);
|
|
3059
|
+
traverse4(ast, {
|
|
3060
|
+
JSXElement: (nodePath) => {
|
|
3061
|
+
const element = nodePath.node;
|
|
3062
|
+
const name = getJsxElementName(element.openingElement);
|
|
3063
|
+
if (!name) return;
|
|
3064
|
+
if (classifyWebJsxComponent(name, element.openingElement) !== "button") return;
|
|
3065
|
+
const action = this.extractActionFromElement(element, nodePath);
|
|
3066
|
+
if (!action) return;
|
|
3067
|
+
const existingByLabel = action.label ? actionLabels.get(normalizeLabel(action.label)) : void 0;
|
|
3068
|
+
if (existingByLabel) {
|
|
3069
|
+
this.mergeActionMetadata(existingByLabel, action);
|
|
3070
|
+
return;
|
|
3071
|
+
}
|
|
3072
|
+
if (action.id && !actionIds.has(action.id)) {
|
|
3073
|
+
actions.push(action);
|
|
3074
|
+
actionIds.add(action.id);
|
|
3075
|
+
if (action.label) actionLabels.set(normalizeLabel(action.label), action);
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
});
|
|
3079
|
+
this.enrichActionsFromHandlers(actions, handlerBehaviors);
|
|
3080
|
+
return actions;
|
|
3081
|
+
}
|
|
3082
|
+
extractActionFromElement(element, nodePath) {
|
|
3083
|
+
const opening = element.openingElement;
|
|
3084
|
+
const componentName = getJsxElementName(opening) ?? void 0;
|
|
3085
|
+
const action = { id: "", type: "custom", sourceComponent: componentName };
|
|
3086
|
+
const ariaLabel = getStringAttr(opening, "aria-label");
|
|
3087
|
+
const label = ariaLabel ?? jsxTextContent(element) ?? getStringAttr(opening, "value") ?? getStringAttr(opening, "title");
|
|
3088
|
+
if (label) action.label = label;
|
|
3089
|
+
if (ariaLabel) {
|
|
3090
|
+
action.locator = mergeLocator(action.locator, {
|
|
3091
|
+
accessibilityLabel: ariaLabel,
|
|
3092
|
+
source: "aria-label"
|
|
3093
|
+
});
|
|
3094
|
+
}
|
|
3095
|
+
const appilotsId = getStringAttr(opening, "appilotsId");
|
|
3096
|
+
const dataTestId = getStringAttr(opening, "data-testid");
|
|
3097
|
+
const domId = getStringAttr(opening, "id");
|
|
3098
|
+
if (appilotsId) {
|
|
3099
|
+
action.id = appilotsId;
|
|
3100
|
+
action.locator = mergeLocator(action.locator, {
|
|
3101
|
+
id: appilotsId,
|
|
3102
|
+
appilotsId,
|
|
3103
|
+
source: "appilotsId"
|
|
3104
|
+
});
|
|
3105
|
+
} else if (dataTestId) {
|
|
3106
|
+
action.id = dataTestId;
|
|
3107
|
+
action.locator = mergeLocator(action.locator, {
|
|
3108
|
+
id: dataTestId,
|
|
3109
|
+
testID: dataTestId,
|
|
3110
|
+
source: "data-testid"
|
|
3111
|
+
});
|
|
3112
|
+
} else if (domId) {
|
|
3113
|
+
action.id = domId;
|
|
3114
|
+
action.locator = mergeLocator(action.locator, { id: domId, source: "id" });
|
|
3115
|
+
} else if (action.label) {
|
|
3116
|
+
action.id = slugify(action.label);
|
|
3117
|
+
}
|
|
3118
|
+
if (!action.id) return null;
|
|
3119
|
+
const to = routePathAttr(opening, "to") ?? routePathAttr(opening, "href");
|
|
3120
|
+
if (to && to.startsWith("/")) {
|
|
3121
|
+
action.type = "navigation";
|
|
3122
|
+
action.targetScreen = to;
|
|
3123
|
+
} else if (to && !to.startsWith("/") && !hasJsxAttribute(opening, "onClick")) {
|
|
3124
|
+
return null;
|
|
3125
|
+
}
|
|
3126
|
+
let handlerName;
|
|
3127
|
+
const onClickAttr = opening.attributes.find(
|
|
3128
|
+
(attr) => BabelTypes.isJSXAttribute(attr) && BabelTypes.isJSXIdentifier(attr.name) && attr.name.name === "onClick"
|
|
3129
|
+
);
|
|
3130
|
+
if (onClickAttr?.value && BabelTypes.isJSXExpressionContainer(onClickAttr.value)) {
|
|
3131
|
+
const expr = onClickAttr.value.expression;
|
|
3132
|
+
if (BabelTypes.isIdentifier(expr)) {
|
|
3133
|
+
handlerName = expr.name;
|
|
3134
|
+
action.handler = expr.name;
|
|
3135
|
+
} else if (!BabelTypes.isJSXEmptyExpression(expr)) {
|
|
3136
|
+
const inlineNavCalls = extractWebNavigationCalls(expr);
|
|
3137
|
+
const inlineNav = inlineNavCalls.find((call) => call.targetPath);
|
|
3138
|
+
if (inlineNav?.targetPath) {
|
|
3139
|
+
action.type = "navigation";
|
|
3140
|
+
action.targetScreen = inlineNav.targetPath;
|
|
3141
|
+
} else if (inlineNavCalls.some((call) => call.method === "goBack")) {
|
|
3142
|
+
action.type = "navigation";
|
|
3143
|
+
action.successSignal = {
|
|
3144
|
+
type: "goBack",
|
|
3145
|
+
description: "Action returns to the previous page"
|
|
3146
|
+
};
|
|
3147
|
+
action.appilotsInferred = {
|
|
3148
|
+
...action.appilotsInferred ?? {},
|
|
3149
|
+
expectedOutcome: "navigation"
|
|
3150
|
+
};
|
|
3151
|
+
}
|
|
3152
|
+
const inlineHandler = firstCalledFunctionName(expr);
|
|
3153
|
+
if (inlineHandler) {
|
|
3154
|
+
handlerName = inlineHandler;
|
|
3155
|
+
action.handler = inlineHandler;
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
if (handlerName) {
|
|
3160
|
+
const lower = handlerName.toLowerCase();
|
|
3161
|
+
if (lower.includes("submit")) action.type = "submit";
|
|
3162
|
+
else if (lower.includes("navigate") && action.type === "custom") action.type = "navigation";
|
|
3163
|
+
}
|
|
3164
|
+
if (componentName && this.isSubmitElement(componentName, opening)) {
|
|
3165
|
+
const formParent = nodePath.findParent(
|
|
3166
|
+
(p) => p.isJSXElement() && getJsxElementName(p.node.openingElement) === "form"
|
|
3167
|
+
);
|
|
3168
|
+
if (formParent) {
|
|
3169
|
+
action.type = "submit";
|
|
3170
|
+
if (!action.handler) {
|
|
3171
|
+
const formHandler = this.handlerNameFromAttr(formParent.node.openingElement, "onSubmit");
|
|
3172
|
+
if (formHandler && formHandler !== "anonymous") {
|
|
3173
|
+
action.handler = formHandler;
|
|
3174
|
+
handlerName = formHandler;
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
3179
|
+
if (hasJsxAttribute(opening, "destructive") || hasJsxAttribute(opening, "aria-destructive")) {
|
|
3180
|
+
const value = getStringAttr(opening, "destructive") ?? getStringAttr(opening, "aria-destructive");
|
|
3181
|
+
action.destructive = value !== "false";
|
|
3182
|
+
} else if (handlerName && DESTRUCTIVE_VERB2.test(handlerName) || DESTRUCTIVE_VERB2.test(action.id)) {
|
|
3183
|
+
action.destructive = true;
|
|
3184
|
+
}
|
|
3185
|
+
if (!action.locator && action.id) {
|
|
3186
|
+
action.locator = {
|
|
3187
|
+
id: action.id,
|
|
3188
|
+
label: action.label,
|
|
3189
|
+
source: action.label ? "label" : "inferred"
|
|
3190
|
+
};
|
|
3191
|
+
}
|
|
3192
|
+
return action;
|
|
3193
|
+
}
|
|
3194
|
+
mergeActionMetadata(target, source) {
|
|
3195
|
+
target.handler = target.handler ?? source.handler;
|
|
3196
|
+
target.targetScreen = target.targetScreen ?? source.targetScreen;
|
|
3197
|
+
target.description = target.description ?? source.description;
|
|
3198
|
+
target.locator = target.locator ?? source.locator;
|
|
3199
|
+
target.nativeConfirmationExpected = target.nativeConfirmationExpected || source.nativeConfirmationExpected || void 0;
|
|
3200
|
+
target.requiresConfirmation = target.requiresConfirmation || source.requiresConfirmation || void 0;
|
|
3201
|
+
target.destructive = target.destructive || source.destructive || void 0;
|
|
3202
|
+
target.effect = target.effect ?? source.effect;
|
|
3203
|
+
target.riskLevel = target.riskLevel ?? source.riskLevel;
|
|
3204
|
+
target.appilotsInferred = target.appilotsInferred ?? source.appilotsInferred;
|
|
3205
|
+
}
|
|
3206
|
+
enrichActionsFromHandlers(actions, behaviors) {
|
|
3207
|
+
for (const action of actions) {
|
|
3208
|
+
const behavior = action.handler ? behaviors.get(action.handler) : void 0;
|
|
3209
|
+
if (!behavior) continue;
|
|
3210
|
+
action.appilotsInferred = {
|
|
3211
|
+
...action.appilotsInferred ?? {},
|
|
3212
|
+
...behavior.base.appilotsInferred
|
|
3213
|
+
};
|
|
3214
|
+
if (behavior.nativeConfirmationExpected || behavior.base.nativeConfirmationExpected) {
|
|
3215
|
+
action.nativeConfirmationExpected = true;
|
|
3216
|
+
}
|
|
3217
|
+
if (behavior.targetPath && !action.targetScreen) {
|
|
3218
|
+
action.targetScreen = behavior.targetPath;
|
|
3219
|
+
if (action.type === "custom") action.type = "navigation";
|
|
3220
|
+
action.appilotsInferred = {
|
|
3221
|
+
...action.appilotsInferred ?? {},
|
|
3222
|
+
expectedOutcome: "navigation"
|
|
3223
|
+
};
|
|
3224
|
+
}
|
|
3225
|
+
if (behavior.base.successSignal && !action.successSignal) {
|
|
3226
|
+
action.successSignal = behavior.base.successSignal;
|
|
3227
|
+
}
|
|
3228
|
+
if (behavior.base.failureSignal && !action.failureSignal) {
|
|
3229
|
+
action.failureSignal = behavior.base.failureSignal;
|
|
3230
|
+
}
|
|
3231
|
+
if (behavior.base.opensModal && !action.opensModal) {
|
|
3232
|
+
action.opensModal = behavior.base.opensModal;
|
|
3233
|
+
}
|
|
3234
|
+
if (behavior.base.destructive || action.destructive === true || action.requiresConfirmation === true || action.effect === "destructive" || action.riskLevel === "high") {
|
|
3235
|
+
action.destructive = true;
|
|
3236
|
+
action.effect = action.effect ?? "destructive";
|
|
3237
|
+
action.riskLevel = action.riskLevel ?? "high";
|
|
3238
|
+
action.requiresConfirmation = action.requiresConfirmation ?? true;
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
}
|
|
3242
|
+
/**
|
|
3243
|
+
* Handler behavior via the shared, platform-neutral analyzer
|
|
3244
|
+
* (async/await, `.then`, state setters, toasts, destructive verbs)
|
|
3245
|
+
* plus the web-only signals: React Router navigation targets and
|
|
3246
|
+
* `window.confirm(...)` as the native confirmation dialog.
|
|
3247
|
+
*/
|
|
3248
|
+
collectWebHandlerBehaviors(ast) {
|
|
3249
|
+
const handlers = collectFunctions(ast);
|
|
3250
|
+
const out = /* @__PURE__ */ new Map();
|
|
3251
|
+
for (const [name, fn] of handlers) {
|
|
3252
|
+
const base = analyzeFunctionBehavior(name, fn, handlers);
|
|
3253
|
+
const navCalls = fn.body ? extractWebNavigationCalls(fn.body) : [];
|
|
3254
|
+
const firstNav = navCalls.find((call) => call.targetPath);
|
|
3255
|
+
const goesBack = navCalls.some((call) => call.method === "goBack");
|
|
3256
|
+
out.set(name, {
|
|
3257
|
+
base: {
|
|
3258
|
+
...base,
|
|
3259
|
+
...goesBack && !base.successSignal ? {
|
|
3260
|
+
successSignal: {
|
|
3261
|
+
type: "goBack",
|
|
3262
|
+
description: "Action returns to the previous page"
|
|
3263
|
+
}
|
|
3264
|
+
} : {}
|
|
3265
|
+
},
|
|
3266
|
+
targetPath: firstNav?.targetPath,
|
|
3267
|
+
nativeConfirmationExpected: fn.body ? containsWindowConfirm(fn.body) : false
|
|
3268
|
+
});
|
|
3269
|
+
}
|
|
3270
|
+
return out;
|
|
3271
|
+
}
|
|
3272
|
+
handlerNameFromAttr(opening, attrName) {
|
|
3273
|
+
const attr = opening.attributes.find(
|
|
3274
|
+
(candidate) => BabelTypes.isJSXAttribute(candidate) && BabelTypes.isJSXIdentifier(candidate.name) && candidate.name.name === attrName
|
|
3275
|
+
);
|
|
3276
|
+
if (!attr?.value || !BabelTypes.isJSXExpressionContainer(attr.value)) return void 0;
|
|
3277
|
+
const expr = attr.value.expression;
|
|
3278
|
+
if (BabelTypes.isIdentifier(expr)) return expr.name;
|
|
3279
|
+
if (BabelTypes.isArrowFunctionExpression(expr) || BabelTypes.isFunctionExpression(expr)) {
|
|
3280
|
+
return firstCalledFunctionName(expr) ?? "anonymous";
|
|
3281
|
+
}
|
|
3282
|
+
return void 0;
|
|
3283
|
+
}
|
|
3284
|
+
// ── Navigation targets (route paths — resolved by the orchestrator) ─
|
|
3285
|
+
extractNavigationTargets(ast) {
|
|
3286
|
+
const targets = /* @__PURE__ */ new Set();
|
|
3287
|
+
for (const call of extractWebNavigationCalls(ast)) {
|
|
3288
|
+
if (call.targetPath && call.targetPath.startsWith("/")) targets.add(call.targetPath);
|
|
3289
|
+
}
|
|
3290
|
+
traverse4(ast, {
|
|
3291
|
+
JSXOpeningElement: (nodePath) => {
|
|
3292
|
+
const element = nodePath.node;
|
|
3293
|
+
const name = getJsxElementName(element);
|
|
3294
|
+
if (name === "Link" || name === "NavLink" || name === "Navigate") {
|
|
3295
|
+
const to = routePathAttr(element, "to");
|
|
3296
|
+
if (to && to.startsWith("/")) targets.add(to);
|
|
3297
|
+
}
|
|
3298
|
+
if (name === "a") {
|
|
3299
|
+
const href = routePathAttr(element, "href");
|
|
3300
|
+
if (href && href.startsWith("/")) targets.add(href);
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
});
|
|
3304
|
+
return Array.from(targets).sort();
|
|
3305
|
+
}
|
|
3306
|
+
// ── Collections ───────────────────────────────────────────────────
|
|
3307
|
+
extractCollections(ast) {
|
|
3308
|
+
const collections = [];
|
|
3309
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3310
|
+
traverse4(ast, {
|
|
3311
|
+
JSXExpressionContainer: (nodePath) => {
|
|
3312
|
+
const expr = nodePath.node.expression;
|
|
3313
|
+
if (!BabelTypes.isCallExpression(expr) || !BabelTypes.isMemberExpression(expr.callee) || !BabelTypes.isIdentifier(expr.callee.object) || !BabelTypes.isIdentifier(expr.callee.property) || expr.callee.property.name !== "map") {
|
|
3314
|
+
return;
|
|
3315
|
+
}
|
|
3316
|
+
const callback = expr.arguments[0];
|
|
3317
|
+
if (!BabelTypes.isArrowFunctionExpression(callback) && !BabelTypes.isFunctionExpression(callback)) return;
|
|
3318
|
+
const enclosing = nodePath.findParent((p) => p.isJSXElement());
|
|
3319
|
+
const enclosingName = enclosing ? getJsxElementName(enclosing.node.openingElement) : null;
|
|
3320
|
+
const enclosingRole = enclosing && enclosingName ? classifyWebJsxComponent(enclosingName, enclosing.node.openingElement) : null;
|
|
3321
|
+
const returnsListItem = callbackReturnsTag(callback, /* @__PURE__ */ new Set(["li", "tr"]));
|
|
3322
|
+
if (enclosingName === "select") return;
|
|
3323
|
+
const isListContext = enclosingName !== null && LISTISH_TAGS.has(enclosingName) || enclosingRole === "list" || returnsListItem;
|
|
3324
|
+
if (!isListContext) return;
|
|
3325
|
+
const dataSource = expr.callee.object.name;
|
|
3326
|
+
if (seen.has(dataSource)) return;
|
|
3327
|
+
seen.add(dataSource);
|
|
3328
|
+
const itemNames = collectionItemNames(callback);
|
|
3329
|
+
const displayFields = collectionDisplayFields(callback, itemNames);
|
|
3330
|
+
const keyField = collectionKeyField(callback, itemNames);
|
|
3331
|
+
const rowAction = collectionRowAction(callback);
|
|
3332
|
+
const itemType = inferItemType(dataSource, displayFields);
|
|
3333
|
+
const identityFields = inferIdentityFields(keyField, displayFields);
|
|
3334
|
+
collections.push({
|
|
3335
|
+
id: dataSource,
|
|
3336
|
+
component: enclosingName ?? "list",
|
|
3337
|
+
...itemType ? { itemType } : {},
|
|
3338
|
+
dataSource,
|
|
3339
|
+
...keyField ? { keyField } : {},
|
|
3340
|
+
...displayFields.length > 0 ? { displayFields } : {},
|
|
3341
|
+
...rowAction ? { rowAction, rowActions: [rowAction] } : {},
|
|
3342
|
+
...identityFields.length > 0 ? { identityFields } : {}
|
|
3343
|
+
});
|
|
3344
|
+
}
|
|
3345
|
+
});
|
|
3346
|
+
return collections;
|
|
3347
|
+
}
|
|
3348
|
+
};
|
|
3349
|
+
function isRegisterScreenCallee(callee) {
|
|
3350
|
+
return BabelTypes.isIdentifier(callee) && callee.name === "registerScreen" || BabelTypes.isMemberExpression(callee) && BabelTypes.isIdentifier(callee.property) && callee.property.name === "registerScreen";
|
|
3351
|
+
}
|
|
3352
|
+
function literalToPlain(node) {
|
|
3353
|
+
if (BabelTypes.isStringLiteral(node) || BabelTypes.isNumericLiteral(node) || BabelTypes.isBooleanLiteral(node)) {
|
|
3354
|
+
return node.value;
|
|
3355
|
+
}
|
|
3356
|
+
if (BabelTypes.isNullLiteral(node)) return null;
|
|
3357
|
+
if (BabelTypes.isArrayExpression(node)) {
|
|
3358
|
+
return node.elements.filter((el) => el !== null && BabelTypes.isExpression(el)).map((el) => literalToPlain(el)).filter((value) => value !== void 0);
|
|
3359
|
+
}
|
|
3360
|
+
if (BabelTypes.isObjectExpression(node)) {
|
|
3361
|
+
const out = {};
|
|
3362
|
+
for (const prop of node.properties) {
|
|
3363
|
+
if (!BabelTypes.isObjectProperty(prop)) continue;
|
|
3364
|
+
const key = BabelTypes.isIdentifier(prop.key) ? prop.key.name : BabelTypes.isStringLiteral(prop.key) ? prop.key.value : void 0;
|
|
3365
|
+
if (!key || !BabelTypes.isExpression(prop.value)) continue;
|
|
3366
|
+
const value = literalToPlain(prop.value);
|
|
3367
|
+
if (value !== void 0) out[key] = value;
|
|
3368
|
+
}
|
|
3369
|
+
return out;
|
|
3370
|
+
}
|
|
3371
|
+
return void 0;
|
|
3372
|
+
}
|
|
3373
|
+
function jsxTextContent(element) {
|
|
3374
|
+
const parts = [];
|
|
3375
|
+
const walk = (children) => {
|
|
3376
|
+
for (const child of children) {
|
|
3377
|
+
if (BabelTypes.isJSXText(child)) {
|
|
3378
|
+
const trimmed = child.value.replace(/\s+/g, " ").trim();
|
|
3379
|
+
if (trimmed) parts.push(trimmed);
|
|
3380
|
+
} else if (BabelTypes.isJSXExpressionContainer(child) && BabelTypes.isStringLiteral(child.expression)) {
|
|
3381
|
+
parts.push(child.expression.value);
|
|
3382
|
+
} else if (BabelTypes.isJSXElement(child)) {
|
|
3383
|
+
walk(child.children);
|
|
3384
|
+
}
|
|
3385
|
+
}
|
|
3386
|
+
};
|
|
3387
|
+
walk(element.children);
|
|
3388
|
+
const text = parts.join(" ").trim();
|
|
3389
|
+
return text.length > 0 ? text : void 0;
|
|
3390
|
+
}
|
|
3391
|
+
function firstCalledFunctionName(node) {
|
|
3392
|
+
if (BabelTypes.isIdentifier(node)) return node.name;
|
|
3393
|
+
if (BabelTypes.isArrowFunctionExpression(node) || BabelTypes.isFunctionExpression(node)) {
|
|
3394
|
+
return firstCalledFunctionName(node.body);
|
|
3395
|
+
}
|
|
3396
|
+
if (BabelTypes.isBlockStatement(node)) {
|
|
3397
|
+
for (const statement of node.body) {
|
|
3398
|
+
const handler = firstCalledFunctionName(statement);
|
|
3399
|
+
if (handler) return handler;
|
|
3400
|
+
}
|
|
3401
|
+
return void 0;
|
|
3402
|
+
}
|
|
3403
|
+
if (BabelTypes.isExpressionStatement(node)) return firstCalledFunctionName(node.expression);
|
|
3404
|
+
if (BabelTypes.isReturnStatement(node)) {
|
|
3405
|
+
return node.argument ? firstCalledFunctionName(node.argument) : void 0;
|
|
3406
|
+
}
|
|
3407
|
+
if (BabelTypes.isAwaitExpression(node) || BabelTypes.isUnaryExpression(node)) {
|
|
3408
|
+
return firstCalledFunctionName(node.argument);
|
|
3409
|
+
}
|
|
3410
|
+
if (BabelTypes.isCallExpression(node)) {
|
|
3411
|
+
if (BabelTypes.isIdentifier(node.callee) && !/^(navigate|confirm|alert)$/.test(node.callee.name)) {
|
|
3412
|
+
return node.callee.name;
|
|
3413
|
+
}
|
|
3414
|
+
return void 0;
|
|
3415
|
+
}
|
|
3416
|
+
return void 0;
|
|
3417
|
+
}
|
|
3418
|
+
function containsWindowConfirm(body) {
|
|
3419
|
+
let found = false;
|
|
3420
|
+
traverse4(body, {
|
|
3421
|
+
noScope: true,
|
|
3422
|
+
CallExpression: (nodePath) => {
|
|
3423
|
+
const callee = nodePath.node.callee;
|
|
3424
|
+
if (BabelTypes.isIdentifier(callee) && callee.name === "confirm") found = true;
|
|
3425
|
+
if (BabelTypes.isMemberExpression(callee) && BabelTypes.isIdentifier(callee.object) && callee.object.name === "window" && BabelTypes.isIdentifier(callee.property) && callee.property.name === "confirm") {
|
|
3426
|
+
found = true;
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
});
|
|
3430
|
+
return found;
|
|
3431
|
+
}
|
|
3432
|
+
function mergeLocator(current, next) {
|
|
3433
|
+
return { ...current ?? {}, ...next };
|
|
3434
|
+
}
|
|
3435
|
+
function routePathAttr(opening, attrName) {
|
|
3436
|
+
const literal = getStringAttr(opening, attrName);
|
|
3437
|
+
if (literal) return literal;
|
|
3438
|
+
const attr = opening.attributes.find(
|
|
3439
|
+
(candidate) => BabelTypes.isJSXAttribute(candidate) && BabelTypes.isJSXIdentifier(candidate.name) && candidate.name.name === attrName
|
|
3440
|
+
);
|
|
3441
|
+
if (!attr?.value || !BabelTypes.isJSXExpressionContainer(attr.value)) return void 0;
|
|
3442
|
+
return staticRoutePath(attr.value.expression);
|
|
3443
|
+
}
|
|
3444
|
+
function slugify(label) {
|
|
3445
|
+
return label.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3446
|
+
}
|
|
3447
|
+
function normalizeLabel(label) {
|
|
3448
|
+
return label.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
3449
|
+
}
|
|
3450
|
+
function isWeakInferredFieldName(name) {
|
|
3451
|
+
return /^(text|value|input|query|search|selected|checked)$/i.test(name);
|
|
3452
|
+
}
|
|
3453
|
+
function collectionItemNames(callback) {
|
|
3454
|
+
const names = /* @__PURE__ */ new Set(["item"]);
|
|
3455
|
+
const firstParam = callback.params[0];
|
|
3456
|
+
if (BabelTypes.isIdentifier(firstParam)) names.add(firstParam.name);
|
|
3457
|
+
if (BabelTypes.isObjectPattern(firstParam)) {
|
|
3458
|
+
for (const prop of firstParam.properties) {
|
|
3459
|
+
if (BabelTypes.isObjectProperty(prop) && BabelTypes.isIdentifier(prop.key) && BabelTypes.isIdentifier(prop.value)) {
|
|
3460
|
+
names.add(prop.value.name);
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
3463
|
+
}
|
|
3464
|
+
return names;
|
|
3465
|
+
}
|
|
3466
|
+
function collectionDisplayFields(callback, itemNames) {
|
|
3467
|
+
const fields = /* @__PURE__ */ new Set();
|
|
3468
|
+
if (!callback.body) return [];
|
|
3469
|
+
traverse4(callback.body, {
|
|
3470
|
+
noScope: true,
|
|
3471
|
+
MemberExpression: (nodePath) => {
|
|
3472
|
+
const node = nodePath.node;
|
|
3473
|
+
if (BabelTypes.isIdentifier(node.object) && itemNames.has(node.object.name) && BabelTypes.isIdentifier(node.property)) {
|
|
3474
|
+
fields.add(node.property.name);
|
|
3475
|
+
}
|
|
3476
|
+
}
|
|
3477
|
+
});
|
|
3478
|
+
return Array.from(fields).sort();
|
|
3479
|
+
}
|
|
3480
|
+
function collectionKeyField(callback, itemNames) {
|
|
3481
|
+
let keyField;
|
|
3482
|
+
if (!callback.body) return void 0;
|
|
3483
|
+
traverse4(callback.body, {
|
|
3484
|
+
noScope: true,
|
|
3485
|
+
JSXAttribute: (nodePath) => {
|
|
3486
|
+
const attr = nodePath.node;
|
|
3487
|
+
if (!BabelTypes.isJSXIdentifier(attr.name) || attr.name.name !== "key") return;
|
|
3488
|
+
if (!attr.value || !BabelTypes.isJSXExpressionContainer(attr.value)) return;
|
|
3489
|
+
const expr = attr.value.expression;
|
|
3490
|
+
if (BabelTypes.isMemberExpression(expr) && BabelTypes.isIdentifier(expr.object) && itemNames.has(expr.object.name) && BabelTypes.isIdentifier(expr.property)) {
|
|
3491
|
+
keyField = keyField ?? expr.property.name;
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
3494
|
+
});
|
|
3495
|
+
return keyField;
|
|
3496
|
+
}
|
|
3497
|
+
function collectionRowAction(callback) {
|
|
3498
|
+
if (!callback.body) return void 0;
|
|
3499
|
+
const navCall = extractWebNavigationCalls(callback.body).find((call) => call.targetPath);
|
|
3500
|
+
if (!navCall?.targetPath) return void 0;
|
|
3501
|
+
return {
|
|
3502
|
+
type: "navigation",
|
|
3503
|
+
// Route path — the orchestrator resolves it to a screen name.
|
|
3504
|
+
targetScreen: navCall.targetPath,
|
|
3505
|
+
description: `Clicking a row opens ${navCall.targetPath}`
|
|
3506
|
+
};
|
|
3507
|
+
}
|
|
3508
|
+
function callbackReturnsTag(callback, tags) {
|
|
3509
|
+
let found = false;
|
|
3510
|
+
const inspect = (node) => {
|
|
3511
|
+
if (!node || found) return;
|
|
3512
|
+
if (BabelTypes.isJSXElement(node)) {
|
|
3513
|
+
const name = getJsxElementName(node.openingElement);
|
|
3514
|
+
if (name && tags.has(name)) found = true;
|
|
3515
|
+
return;
|
|
3516
|
+
}
|
|
3517
|
+
if (BabelTypes.isBlockStatement(node)) {
|
|
3518
|
+
for (const statement of node.body) {
|
|
3519
|
+
if (BabelTypes.isReturnStatement(statement)) inspect(statement.argument);
|
|
3520
|
+
}
|
|
3521
|
+
}
|
|
3522
|
+
if (BabelTypes.isParenthesizedExpression(node)) inspect(node.expression);
|
|
3523
|
+
if (BabelTypes.isConditionalExpression(node)) {
|
|
3524
|
+
inspect(node.consequent);
|
|
3525
|
+
inspect(node.alternate);
|
|
3526
|
+
}
|
|
3527
|
+
};
|
|
3528
|
+
inspect(callback.body);
|
|
3529
|
+
return found;
|
|
3530
|
+
}
|
|
3531
|
+
function inferItemType(dataSource, displayFields) {
|
|
3532
|
+
const singular = dataSource.replace(/^render/i, "").replace(/(List|Items|Data|Rows|Sections)$/i, "").replace(/s$/i, "");
|
|
3533
|
+
const candidate = singular.charAt(0).toUpperCase() + singular.slice(1);
|
|
3534
|
+
if (candidate.length > 1) return candidate;
|
|
3535
|
+
if (displayFields.length > 0) return "Item";
|
|
3536
|
+
return void 0;
|
|
3537
|
+
}
|
|
3538
|
+
function inferIdentityFields(keyField, displayFields) {
|
|
3539
|
+
const out = /* @__PURE__ */ new Set();
|
|
3540
|
+
if (keyField) out.add(keyField);
|
|
3541
|
+
for (const field of displayFields) {
|
|
3542
|
+
if (/^(id|uuid|key|name|title|plate|email|slug)$/i.test(field)) out.add(field);
|
|
3543
|
+
}
|
|
3544
|
+
return Array.from(out);
|
|
3545
|
+
}
|
|
3546
|
+
var WEB_NAVIGATOR_TYPE = "route";
|
|
3547
|
+
var WebNavigationAnalyzer = class {
|
|
3548
|
+
config;
|
|
3549
|
+
navigationInclude;
|
|
3550
|
+
navigationExclude;
|
|
3551
|
+
constructor(config, options) {
|
|
3552
|
+
this.config = config;
|
|
3553
|
+
this.navigationInclude = options?.navigationInclude ?? [];
|
|
3554
|
+
this.navigationExclude = options?.navigationExclude ?? [];
|
|
3555
|
+
}
|
|
3556
|
+
async analyze() {
|
|
3557
|
+
const files = await this.findRouteFiles();
|
|
3558
|
+
const routes = [];
|
|
3559
|
+
for (const filePath of files) {
|
|
3560
|
+
try {
|
|
3561
|
+
const content = await promises.readFile(filePath, "utf-8");
|
|
3562
|
+
if (!/createBrowserRouter|createHashRouter|createMemoryRouter|useRoutes|<Route[\s>]/.test(content)) {
|
|
3563
|
+
continue;
|
|
3564
|
+
}
|
|
3565
|
+
const ast = parseSource(content, this.config.parserPlugins);
|
|
3566
|
+
routes.push(...this.extractJsxRoutes(ast));
|
|
3567
|
+
routes.push(...this.extractObjectRoutes(ast));
|
|
3568
|
+
} catch (error) {
|
|
3569
|
+
console.warn(`[WebNavigationAnalyzer] Failed to parse ${filePath}:`, error);
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
const deduped = this.dedupeRoutes(routes);
|
|
3573
|
+
return { graph: this.buildGraph(deduped), routes: deduped };
|
|
3574
|
+
}
|
|
3575
|
+
/** Files likely to contain route configuration. */
|
|
3576
|
+
async findRouteFiles() {
|
|
3577
|
+
const patterns = [
|
|
3578
|
+
"**/*{router,routes,Router,Routes}*.{ts,tsx,js,jsx}",
|
|
3579
|
+
"**/App.{ts,tsx,js,jsx}",
|
|
3580
|
+
"**/app.{ts,tsx,js,jsx}",
|
|
3581
|
+
"**/main.{ts,tsx,js,jsx}",
|
|
3582
|
+
"**/index.{ts,tsx,js,jsx}",
|
|
3583
|
+
...this.navigationInclude
|
|
3584
|
+
];
|
|
3585
|
+
const ignore = [
|
|
3586
|
+
"**/node_modules/**",
|
|
3587
|
+
"**/dist/**",
|
|
3588
|
+
"**/build/**",
|
|
3589
|
+
...this.config.exclude || [],
|
|
3590
|
+
...this.navigationExclude
|
|
3591
|
+
];
|
|
3592
|
+
const files = await glob(patterns, { cwd: this.config.rootDir, ignore });
|
|
3593
|
+
return files.map((file) => path8__default.join(this.config.rootDir, file));
|
|
3594
|
+
}
|
|
3595
|
+
// ── JSX <Route> style ────────────────────────────────────────────
|
|
3596
|
+
extractJsxRoutes(ast) {
|
|
3597
|
+
const routes = [];
|
|
3598
|
+
const visitRoute = (element, parentPath) => {
|
|
3599
|
+
const opening = element.openingElement;
|
|
3600
|
+
const name = getJsxElementName(opening);
|
|
3601
|
+
if (name !== "Route") {
|
|
3602
|
+
for (const child of element.children) {
|
|
3603
|
+
if (BabelTypes.isJSXElement(child)) visitRoute(child, parentPath);
|
|
3604
|
+
}
|
|
3605
|
+
return;
|
|
3606
|
+
}
|
|
3607
|
+
const segment = getStringAttr(opening, "path");
|
|
3608
|
+
const isIndex = hasJsxAttribute(opening, "index") && !segment;
|
|
3609
|
+
const fullPath = this.joinPaths(parentPath, segment, isIndex);
|
|
3610
|
+
const componentName = this.componentNameFromElementAttr(opening) ?? void 0;
|
|
3611
|
+
const isLeaf = !element.children.some(
|
|
3612
|
+
(child) => BabelTypes.isJSXElement(child) && getJsxElementName(child.openingElement) === "Route"
|
|
3613
|
+
);
|
|
3614
|
+
if ((segment || isIndex) && (componentName || isLeaf)) {
|
|
3615
|
+
routes.push(this.buildRoute(fullPath, componentName, isIndex, !isLeaf));
|
|
3616
|
+
}
|
|
3617
|
+
for (const child of element.children) {
|
|
3618
|
+
if (BabelTypes.isJSXElement(child)) visitRoute(child, fullPath);
|
|
3619
|
+
}
|
|
3620
|
+
};
|
|
3621
|
+
traverse4(ast, {
|
|
3622
|
+
JSXElement: (nodePath) => {
|
|
3623
|
+
const name = getJsxElementName(nodePath.node.openingElement);
|
|
3624
|
+
if (name !== "Routes" && name !== "Route") return;
|
|
3625
|
+
if (nodePath.findParent((p) => {
|
|
3626
|
+
if (!p.isJSXElement()) return false;
|
|
3627
|
+
const parentName = getJsxElementName(p.node.openingElement);
|
|
3628
|
+
return parentName === "Routes" || parentName === "Route";
|
|
3629
|
+
})) {
|
|
3630
|
+
return;
|
|
3631
|
+
}
|
|
3632
|
+
visitRoute(nodePath.node, "");
|
|
3633
|
+
}
|
|
3634
|
+
});
|
|
3635
|
+
return routes;
|
|
3636
|
+
}
|
|
3637
|
+
/** `element={<VehicleList/>}` or `Component={VehicleList}`. */
|
|
3638
|
+
componentNameFromElementAttr(opening) {
|
|
3639
|
+
for (const attr of opening.attributes) {
|
|
3640
|
+
if (!BabelTypes.isJSXAttribute(attr) || !BabelTypes.isJSXIdentifier(attr.name)) continue;
|
|
3641
|
+
if (attr.name.name === "element" && BabelTypes.isJSXExpressionContainer(attr.value)) {
|
|
3642
|
+
const expr = attr.value.expression;
|
|
3643
|
+
if (BabelTypes.isJSXElement(expr)) return getJsxElementName(expr.openingElement);
|
|
3644
|
+
}
|
|
3645
|
+
if (attr.name.name === "Component" && BabelTypes.isJSXExpressionContainer(attr.value)) {
|
|
3646
|
+
if (BabelTypes.isIdentifier(attr.value.expression)) return attr.value.expression.name;
|
|
3647
|
+
}
|
|
3648
|
+
}
|
|
3649
|
+
return null;
|
|
3650
|
+
}
|
|
3651
|
+
// ── createBrowserRouter([...]) / useRoutes([...]) style ──────────
|
|
3652
|
+
extractObjectRoutes(ast) {
|
|
3653
|
+
const routes = [];
|
|
3654
|
+
const ROUTER_FACTORIES = /* @__PURE__ */ new Set([
|
|
3655
|
+
"createBrowserRouter",
|
|
3656
|
+
"createHashRouter",
|
|
3657
|
+
"createMemoryRouter",
|
|
3658
|
+
"useRoutes"
|
|
3659
|
+
]);
|
|
3660
|
+
traverse4(ast, {
|
|
3661
|
+
CallExpression: (nodePath) => {
|
|
3662
|
+
const callee = nodePath.node.callee;
|
|
3663
|
+
if (!BabelTypes.isIdentifier(callee) || !ROUTER_FACTORIES.has(callee.name)) return;
|
|
3664
|
+
const first = nodePath.node.arguments[0];
|
|
3665
|
+
if (!BabelTypes.isArrayExpression(first)) return;
|
|
3666
|
+
this.visitRouteObjects(first, "", routes);
|
|
3667
|
+
}
|
|
3668
|
+
});
|
|
3669
|
+
return routes;
|
|
3670
|
+
}
|
|
3671
|
+
visitRouteObjects(arr, parentPath, out) {
|
|
3672
|
+
for (const element of arr.elements) {
|
|
3673
|
+
if (!BabelTypes.isObjectExpression(element)) continue;
|
|
3674
|
+
let segment;
|
|
3675
|
+
let isIndex = false;
|
|
3676
|
+
let componentName;
|
|
3677
|
+
let children;
|
|
3678
|
+
for (const prop of element.properties) {
|
|
3679
|
+
if (!BabelTypes.isObjectProperty(prop) || !BabelTypes.isIdentifier(prop.key)) continue;
|
|
3680
|
+
const key = prop.key.name;
|
|
3681
|
+
if (key === "path" && BabelTypes.isStringLiteral(prop.value)) segment = prop.value.value;
|
|
3682
|
+
if (key === "index" && BabelTypes.isBooleanLiteral(prop.value)) isIndex = prop.value.value;
|
|
3683
|
+
if (key === "element" && BabelTypes.isJSXElement(prop.value)) {
|
|
3684
|
+
componentName = getJsxElementName(prop.value.openingElement) ?? void 0;
|
|
3685
|
+
}
|
|
3686
|
+
if (key === "Component" && BabelTypes.isIdentifier(prop.value)) componentName = prop.value.name;
|
|
3687
|
+
if (key === "children" && BabelTypes.isArrayExpression(prop.value)) children = prop.value;
|
|
3688
|
+
}
|
|
3689
|
+
const fullPath = this.joinPaths(parentPath, segment, isIndex);
|
|
3690
|
+
if ((segment !== void 0 || isIndex) && (componentName || !children)) {
|
|
3691
|
+
out.push(this.buildRoute(fullPath, componentName, isIndex, Boolean(children)));
|
|
3692
|
+
}
|
|
3693
|
+
if (children) this.visitRouteObjects(children, fullPath, out);
|
|
3694
|
+
}
|
|
3695
|
+
}
|
|
3696
|
+
// ── Shared route building ────────────────────────────────────────
|
|
3697
|
+
joinPaths(parent, segment, isIndex) {
|
|
3698
|
+
if (isIndex || segment === void 0) return parent || "/";
|
|
3699
|
+
if (segment.startsWith("/")) return this.normalizePath(segment);
|
|
3700
|
+
return this.normalizePath(`${parent === "/" ? "" : parent}/${segment}`);
|
|
3701
|
+
}
|
|
3702
|
+
normalizePath(p) {
|
|
3703
|
+
const cleaned = `/${p}`.replace(/\/+/g, "/");
|
|
3704
|
+
return cleaned.length > 1 ? cleaned.replace(/\/$/, "") : cleaned;
|
|
3705
|
+
}
|
|
3706
|
+
buildRoute(fullPath, componentName, isIndex, isLayout) {
|
|
3707
|
+
const params = this.paramsFromPath(fullPath);
|
|
3708
|
+
return {
|
|
3709
|
+
path: fullPath,
|
|
3710
|
+
screenName: componentName ?? screenNameFromPath(fullPath),
|
|
3711
|
+
...params.length > 0 ? { params } : {},
|
|
3712
|
+
...isIndex ? { index: true } : {},
|
|
3713
|
+
...isLayout ? { layout: true } : {}
|
|
3714
|
+
};
|
|
3715
|
+
}
|
|
3716
|
+
paramsFromPath(routePath) {
|
|
3717
|
+
const params = [];
|
|
3718
|
+
for (const segment of routePath.split("/")) {
|
|
3719
|
+
if (!segment.startsWith(":")) continue;
|
|
3720
|
+
const optional = segment.endsWith("?");
|
|
3721
|
+
const name = segment.slice(1, optional ? -1 : void 0);
|
|
3722
|
+
if (name) params.push({ name, type: "string", required: !optional });
|
|
3723
|
+
}
|
|
3724
|
+
return params;
|
|
3725
|
+
}
|
|
3726
|
+
/**
|
|
3727
|
+
* One entry per path. When several declarations resolve to the same
|
|
3728
|
+
* path, keep the one that best describes what the user lands on: a
|
|
3729
|
+
* page beats a layout wrapper (an `index` child and its parent layout
|
|
3730
|
+
* share a path), and a resolved component name beats a name derived
|
|
3731
|
+
* from the path.
|
|
3732
|
+
*/
|
|
3733
|
+
dedupeRoutes(routes) {
|
|
3734
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
3735
|
+
for (const route of routes) {
|
|
3736
|
+
const existing = byPath.get(route.path);
|
|
3737
|
+
if (!existing || routeScore(route) > routeScore(existing)) {
|
|
3738
|
+
byPath.set(route.path, route);
|
|
3739
|
+
}
|
|
3740
|
+
}
|
|
3741
|
+
return Array.from(byPath.values());
|
|
3742
|
+
}
|
|
3743
|
+
buildGraph(routes) {
|
|
3744
|
+
const screens = {};
|
|
3745
|
+
const navigatorName = "router";
|
|
3746
|
+
const screenNames = routes.map((r) => r.screenName);
|
|
3747
|
+
for (const route of routes) {
|
|
3748
|
+
const others = screenNames.filter((name) => name !== route.screenName);
|
|
3749
|
+
screens[route.screenName] = {
|
|
3750
|
+
screenName: route.screenName,
|
|
3751
|
+
// Open-union value — web routes, not a RN stack/tab/drawer.
|
|
3752
|
+
navigatorType: WEB_NAVIGATOR_TYPE,
|
|
3753
|
+
parentNavigator: navigatorName,
|
|
3754
|
+
// Any route is one URL away from any other — both directions,
|
|
3755
|
+
// like the RN analyzer models tab navigators.
|
|
3756
|
+
reachableFrom: others,
|
|
3757
|
+
reachableTo: others,
|
|
3758
|
+
...route.params ? { params: route.params } : {}
|
|
3759
|
+
};
|
|
3760
|
+
}
|
|
3761
|
+
const initialRoute = routes.find((r) => r.path === "/") ?? routes.find((r) => r.index) ?? routes[0];
|
|
3762
|
+
return {
|
|
3763
|
+
screens,
|
|
3764
|
+
initialScreen: initialRoute?.screenName ?? "",
|
|
3765
|
+
navigators: routes.length > 0 ? [{
|
|
3766
|
+
name: navigatorName,
|
|
3767
|
+
type: WEB_NAVIGATOR_TYPE,
|
|
3768
|
+
screens: screenNames
|
|
3769
|
+
}] : []
|
|
3770
|
+
};
|
|
3771
|
+
}
|
|
3772
|
+
};
|
|
3773
|
+
function screenNameFromPath(routePath) {
|
|
3774
|
+
if (routePath === "/" || routePath === "") return "Home";
|
|
3775
|
+
return routePath.split("/").filter(Boolean).map((segment) => segment.replace(/^:/, "").replace(/\?$/, "")).map(
|
|
3776
|
+
(segment) => segment.split(/[-_.]/).filter(Boolean).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("")
|
|
3777
|
+
).join("");
|
|
3778
|
+
}
|
|
3779
|
+
function routeScore(route) {
|
|
3780
|
+
const isPage = route.layout ? 0 : 2;
|
|
3781
|
+
const hasRealName = route.screenName === screenNameFromPath(route.path) ? 0 : 1;
|
|
3782
|
+
return isPage + hasRealName;
|
|
3783
|
+
}
|
|
3784
|
+
function resolvePathToScreen(routes, target) {
|
|
3785
|
+
const normalized = `/${target}`.replace(/\/+/g, "/").replace(/\?.*$/, "").replace(/#.*$/, "");
|
|
3786
|
+
const cleaned = normalized.length > 1 ? normalized.replace(/\/$/, "") : normalized;
|
|
3787
|
+
const exact = routes.find((r) => r.path === cleaned);
|
|
3788
|
+
if (exact) return exact.screenName;
|
|
3789
|
+
const targetSegments = cleaned.split("/").filter(Boolean);
|
|
3790
|
+
for (const route of routes) {
|
|
3791
|
+
const routeSegments = route.path.split("/").filter(Boolean);
|
|
3792
|
+
if (routeSegments.length !== targetSegments.length) continue;
|
|
3793
|
+
const matches = routeSegments.every(
|
|
3794
|
+
(seg, i) => seg.startsWith(":") || seg === "*" || seg === targetSegments[i]
|
|
3795
|
+
);
|
|
3796
|
+
if (matches) return route.screenName;
|
|
3797
|
+
}
|
|
3798
|
+
return void 0;
|
|
3799
|
+
}
|
|
3800
|
+
|
|
3801
|
+
// src/analyzers/web/ReactWebPlatformAnalyzer.ts
|
|
3802
|
+
var ReactWebPlatformAnalyzer = class {
|
|
3803
|
+
platform = "web";
|
|
3804
|
+
async analyze(config, options) {
|
|
3805
|
+
const screenAnalyzer = new WebScreenAnalyzer(config, {
|
|
3806
|
+
screenPatterns: options.screenPatterns
|
|
3807
|
+
});
|
|
3808
|
+
const navigationAnalyzer = new WebNavigationAnalyzer(config, {
|
|
3809
|
+
navigationInclude: options.navigationInclude,
|
|
3810
|
+
navigationExclude: options.navigationExclude
|
|
3811
|
+
});
|
|
3812
|
+
console.log("[ReactWebPlatformAnalyzer] Running analyzers...");
|
|
3813
|
+
const [screenAnalysis, navigationResult] = await Promise.all([
|
|
3814
|
+
screenAnalyzer.analyze(),
|
|
3815
|
+
navigationAnalyzer.analyze()
|
|
3816
|
+
]);
|
|
3817
|
+
const routeScreenNames = new Set(navigationResult.routes.map((route) => route.screenName));
|
|
3818
|
+
const strictScreens = options.strictScreens ?? true;
|
|
3819
|
+
let screensFilteredOut = 0;
|
|
3820
|
+
const included = [];
|
|
3821
|
+
for (const candidate of screenAnalysis.candidates) {
|
|
3822
|
+
if (!strictScreens || this.isScreen(candidate, routeScreenNames)) {
|
|
3823
|
+
included.push(candidate);
|
|
3824
|
+
} else {
|
|
3825
|
+
screensFilteredOut++;
|
|
3826
|
+
}
|
|
3827
|
+
}
|
|
3828
|
+
const screens = included.map(
|
|
3829
|
+
(candidate) => this.resolveRoutePaths(candidate.descriptor, navigationResult.routes)
|
|
3830
|
+
);
|
|
3831
|
+
console.log(
|
|
3832
|
+
`[ReactWebPlatformAnalyzer] Screen and navigation analysis complete. Found ${screens.length} screens, ${navigationResult.routes.length} routes`
|
|
3833
|
+
);
|
|
3834
|
+
return {
|
|
3835
|
+
screens,
|
|
3836
|
+
navigation: navigationResult.graph,
|
|
3837
|
+
analyzedFiles: screenAnalysis.analyzedFiles,
|
|
3838
|
+
...screensFilteredOut > 0 ? { screensFilteredOut } : {}
|
|
3839
|
+
};
|
|
3840
|
+
}
|
|
3841
|
+
isScreen(candidate, routeScreenNames) {
|
|
3842
|
+
return candidate.hasRegisterScreen || candidate.matchesScreenPattern || routeScreenNames.has(candidate.descriptor.name);
|
|
3843
|
+
}
|
|
3844
|
+
/** Replace route-path references with screen names where the route table resolves them. */
|
|
3845
|
+
resolveRoutePaths(screen, routes) {
|
|
3846
|
+
const resolve2 = (target) => {
|
|
3847
|
+
if (!target || !target.startsWith("/")) return target;
|
|
3848
|
+
return resolvePathToScreen(routes, target) ?? target;
|
|
3849
|
+
};
|
|
3850
|
+
const navigationTargets = Array.from(
|
|
3851
|
+
new Set(
|
|
3852
|
+
screen.navigationTargets.map((target) => resolve2(target)).filter((target) => target !== screen.name)
|
|
3853
|
+
)
|
|
3854
|
+
).sort();
|
|
3855
|
+
const actions = screen.actions.map((action) => {
|
|
3856
|
+
const resolved = resolve2(action.targetScreen);
|
|
3857
|
+
return resolved === action.targetScreen ? action : { ...action, targetScreen: resolved };
|
|
3858
|
+
});
|
|
3859
|
+
const collections = screen.collections?.map((collection) => {
|
|
3860
|
+
const resolveRow = (row) => {
|
|
3861
|
+
if (!row?.targetScreen) return row;
|
|
3862
|
+
const resolved = resolve2(row.targetScreen);
|
|
3863
|
+
if (resolved === row.targetScreen) return row;
|
|
3864
|
+
return {
|
|
3865
|
+
...row,
|
|
3866
|
+
targetScreen: resolved,
|
|
3867
|
+
...row.description ? { description: `Clicking a row opens ${resolved}` } : {}
|
|
3868
|
+
};
|
|
3869
|
+
};
|
|
3870
|
+
const rowAction = resolveRow(collection.rowAction);
|
|
3871
|
+
return {
|
|
3872
|
+
...collection,
|
|
3873
|
+
...rowAction ? { rowAction } : {},
|
|
3874
|
+
...collection.rowActions ? { rowActions: collection.rowActions.map((row) => resolveRow(row)) } : {}
|
|
3875
|
+
};
|
|
3876
|
+
});
|
|
3877
|
+
return {
|
|
3878
|
+
...screen,
|
|
3879
|
+
navigationTargets,
|
|
3880
|
+
actions,
|
|
3881
|
+
...collections ? { collections } : {}
|
|
3882
|
+
};
|
|
3883
|
+
}
|
|
3884
|
+
};
|
|
3885
|
+
|
|
2412
3886
|
// ../../node_modules/zod/v3/external.js
|
|
2413
3887
|
var external_exports = {};
|
|
2414
3888
|
__export(external_exports, {
|
|
@@ -2614,8 +4088,8 @@ var ZodParsedType = util.arrayToEnum([
|
|
|
2614
4088
|
"set"
|
|
2615
4089
|
]);
|
|
2616
4090
|
var getParsedType = (data) => {
|
|
2617
|
-
const
|
|
2618
|
-
switch (
|
|
4091
|
+
const t12 = typeof data;
|
|
4092
|
+
switch (t12) {
|
|
2619
4093
|
case "undefined":
|
|
2620
4094
|
return ZodParsedType.undefined;
|
|
2621
4095
|
case "string":
|
|
@@ -2887,8 +4361,8 @@ function getErrorMap() {
|
|
|
2887
4361
|
|
|
2888
4362
|
// ../../node_modules/zod/v3/helpers/parseUtil.js
|
|
2889
4363
|
var makeIssue = (params) => {
|
|
2890
|
-
const { data, path:
|
|
2891
|
-
const fullPath = [...
|
|
4364
|
+
const { data, path: path9, errorMaps, issueData } = params;
|
|
4365
|
+
const fullPath = [...path9, ...issueData.path || []];
|
|
2892
4366
|
const fullIssue = {
|
|
2893
4367
|
...issueData,
|
|
2894
4368
|
path: fullPath
|
|
@@ -3004,11 +4478,11 @@ var errorUtil;
|
|
|
3004
4478
|
|
|
3005
4479
|
// ../../node_modules/zod/v3/types.js
|
|
3006
4480
|
var ParseInputLazyPath = class {
|
|
3007
|
-
constructor(parent, value,
|
|
4481
|
+
constructor(parent, value, path9, key) {
|
|
3008
4482
|
this._cachedPath = [];
|
|
3009
4483
|
this.parent = parent;
|
|
3010
4484
|
this.data = value;
|
|
3011
|
-
this._path =
|
|
4485
|
+
this._path = path9;
|
|
3012
4486
|
this._key = key;
|
|
3013
4487
|
}
|
|
3014
4488
|
get path() {
|
|
@@ -6449,7 +7923,7 @@ var coerce = {
|
|
|
6449
7923
|
};
|
|
6450
7924
|
var NEVER = INVALID;
|
|
6451
7925
|
|
|
6452
|
-
// ../shared/dist/chunk-
|
|
7926
|
+
// ../shared/dist/chunk-MSOZJNXB.mjs
|
|
6453
7927
|
var locatorSourceSchema = external_exports.enum([
|
|
6454
7928
|
"appilotsId",
|
|
6455
7929
|
"testID",
|
|
@@ -6634,7 +8108,19 @@ var navigationNodeSchema = external_exports.object({
|
|
|
6634
8108
|
parentNavigator: external_exports.string().optional(),
|
|
6635
8109
|
reachableFrom: external_exports.array(external_exports.string()).default([]),
|
|
6636
8110
|
reachableTo: external_exports.array(external_exports.string()).default([]),
|
|
6637
|
-
params: external_exports.array(paramDescriptorSchema).optional()
|
|
8111
|
+
params: external_exports.array(paramDescriptorSchema).optional(),
|
|
8112
|
+
/**
|
|
8113
|
+
* Web clients only (documented convention, additive — previously
|
|
8114
|
+
* round-tripped via `.passthrough()`): the screen's URL route
|
|
8115
|
+
* template, e.g. `/users/:id`. Param segments use `:name` (the
|
|
8116
|
+
* relay also accepts `[name]` / `{name}`). When present and the
|
|
8117
|
+
* request's `context.platform` is `'web'`, the relay resolves
|
|
8118
|
+
* `navigate` targets against these templates and injects the
|
|
8119
|
+
* concrete URL segments as the navigate payload's `path` — the web
|
|
8120
|
+
* equivalent of RN's nested-navigation path injection. See
|
|
8121
|
+
* docs/agent-contract.md ("Web clients" section).
|
|
8122
|
+
*/
|
|
8123
|
+
path: external_exports.string().max(500).optional()
|
|
6638
8124
|
}).passthrough();
|
|
6639
8125
|
var navigatorDescriptorSchema = external_exports.object({
|
|
6640
8126
|
name: external_exports.string(),
|
|
@@ -6681,6 +8167,41 @@ var loginSchema = external_exports.object({
|
|
|
6681
8167
|
loginSchema.extend({
|
|
6682
8168
|
name: external_exports.string().min(2, "Name must be at least 2 characters").max(100)
|
|
6683
8169
|
});
|
|
8170
|
+
external_exports.object({
|
|
8171
|
+
name: external_exports.string().min(2, "Name must be at least 2 characters").max(100).optional(),
|
|
8172
|
+
avatarUrl: external_exports.string().url("Invalid URL").max(2048).nullable().optional()
|
|
8173
|
+
}).refine((v) => v.name !== void 0 || v.avatarUrl !== void 0, {
|
|
8174
|
+
message: "At least one field must be provided"
|
|
8175
|
+
});
|
|
8176
|
+
external_exports.object({
|
|
8177
|
+
currentPassword: external_exports.string().min(1, "Current password is required"),
|
|
8178
|
+
newPassword: external_exports.string().min(8, "Password must be at least 8 characters")
|
|
8179
|
+
});
|
|
8180
|
+
var totpCodeSchema = external_exports.string().transform((v) => v.replace(/\s/g, "")).pipe(external_exports.string().regex(/^\d{6}$/, "Code must be 6 digits"));
|
|
8181
|
+
var recoveryCodeSchema = external_exports.string().transform((v) => v.toUpperCase().replace(/[^A-Z0-9]/g, "")).pipe(external_exports.string().regex(/^[23456789BCDFGHJKMNPQRSTVWXYZ]{10}$/, "Invalid recovery code"));
|
|
8182
|
+
external_exports.object({
|
|
8183
|
+
code: totpCodeSchema
|
|
8184
|
+
});
|
|
8185
|
+
external_exports.object({
|
|
8186
|
+
challengeToken: external_exports.string().min(1, "Challenge token is required"),
|
|
8187
|
+
code: totpCodeSchema.optional(),
|
|
8188
|
+
recoveryCode: recoveryCodeSchema.optional()
|
|
8189
|
+
}).refine((v) => Boolean(v.code) !== Boolean(v.recoveryCode), {
|
|
8190
|
+
message: "Provide either a TOTP code or a recovery code",
|
|
8191
|
+
path: ["code"]
|
|
8192
|
+
});
|
|
8193
|
+
external_exports.object({
|
|
8194
|
+
password: external_exports.string().min(1, "Password is required"),
|
|
8195
|
+
code: totpCodeSchema.optional(),
|
|
8196
|
+
recoveryCode: recoveryCodeSchema.optional()
|
|
8197
|
+
}).refine((v) => Boolean(v.code) !== Boolean(v.recoveryCode), {
|
|
8198
|
+
message: "Provide either a TOTP code or a recovery code",
|
|
8199
|
+
path: ["code"]
|
|
8200
|
+
});
|
|
8201
|
+
external_exports.object({
|
|
8202
|
+
password: external_exports.string().min(1, "Password is required"),
|
|
8203
|
+
code: totpCodeSchema
|
|
8204
|
+
});
|
|
6684
8205
|
var createProjectSchema = external_exports.object({
|
|
6685
8206
|
name: external_exports.string().min(1).max(100),
|
|
6686
8207
|
description: external_exports.string().max(500).optional(),
|
|
@@ -7344,6 +8865,18 @@ external_exports.object({
|
|
|
7344
8865
|
*/
|
|
7345
8866
|
isRecoveryHop: external_exports.boolean().optional()
|
|
7346
8867
|
}).strict();
|
|
8868
|
+
var relayDiagnosticsSchema = external_exports.object({
|
|
8869
|
+
unverifiedTargets: external_exports.array(external_exports.string()),
|
|
8870
|
+
guardsFired: external_exports.array(
|
|
8871
|
+
external_exports.object({ guard: external_exports.string(), detail: external_exports.string().optional() })
|
|
8872
|
+
),
|
|
8873
|
+
repairMode: external_exports.enum(["repair", "strict", "disabled"]),
|
|
8874
|
+
promptVersion: external_exports.string(),
|
|
8875
|
+
// Defaulted for producers that predate the field: before it existed
|
|
8876
|
+
// every response had reached the model, so `true` is the correct
|
|
8877
|
+
// backfill.
|
|
8878
|
+
modelInvoked: external_exports.boolean().default(true)
|
|
8879
|
+
});
|
|
7347
8880
|
var sandboxTraceHopSchema = external_exports.object({
|
|
7348
8881
|
hop: external_exports.number().int().nonnegative(),
|
|
7349
8882
|
inputPreview: external_exports.string(),
|
|
@@ -7382,7 +8915,16 @@ external_exports.object({
|
|
|
7382
8915
|
/** Hop-by-hop trace. Always at least one entry. */
|
|
7383
8916
|
trace: external_exports.array(sandboxTraceHopSchema),
|
|
7384
8917
|
/** True when the run was a no-op (e.g. project has no MCP yet). */
|
|
7385
|
-
warning: external_exports.string().optional()
|
|
8918
|
+
warning: external_exports.string().optional(),
|
|
8919
|
+
/**
|
|
8920
|
+
* SPEC-046 — guard / hallucination telemetry for the run.
|
|
8921
|
+
*
|
|
8922
|
+
* ADDITIVE AND OPTIONAL, permanently: SDK and dashboard consumers
|
|
8923
|
+
* predate it, and older API deployments will not send it. Consumers
|
|
8924
|
+
* must treat an absent block as "no information", never as "no guards
|
|
8925
|
+
* fired".
|
|
8926
|
+
*/
|
|
8927
|
+
diagnostics: relayDiagnosticsSchema.optional()
|
|
7386
8928
|
});
|
|
7387
8929
|
external_exports.object({
|
|
7388
8930
|
name: external_exports.string().min(1).max(120),
|
|
@@ -7601,7 +9143,7 @@ var manifestSchema = external_exports.object({
|
|
|
7601
9143
|
navigation: navigationGraphSchema.partial().optional()
|
|
7602
9144
|
}).passthrough();
|
|
7603
9145
|
async function loadManifest(rootDir, manifestPath) {
|
|
7604
|
-
const resolvedPath =
|
|
9146
|
+
const resolvedPath = path8__default.resolve(rootDir, manifestPath || DEFAULT_MANIFEST_FILENAME);
|
|
7605
9147
|
let raw;
|
|
7606
9148
|
try {
|
|
7607
9149
|
raw = await readFile(resolvedPath, "utf-8");
|
|
@@ -7886,12 +9428,15 @@ var MCPGenerator = class _MCPGenerator {
|
|
|
7886
9428
|
/**
|
|
7887
9429
|
* Select the `PlatformAnalyzer` implementation for a `.appilotsrc`
|
|
7888
9430
|
* `platform` value. `'react-native'` (or unset — the existing default)
|
|
7889
|
-
* gets the
|
|
7890
|
-
*
|
|
9431
|
+
* gets the RN Babel/JSX pipeline; `'web'` gets the React web (DOM +
|
|
9432
|
+
* React Router) pipeline; anything else gets the no-op generic
|
|
9433
|
+
* analyzer, relying entirely on a declared manifest. The manifest
|
|
9434
|
+
* still merges on top of every analyzer's output either way.
|
|
7891
9435
|
*/
|
|
7892
9436
|
static createPlatformAnalyzer(platform) {
|
|
7893
9437
|
const resolved = platform ?? "react-native";
|
|
7894
9438
|
if (resolved === "react-native") return new ReactNativePlatformAnalyzer();
|
|
9439
|
+
if (resolved === "web") return new ReactWebPlatformAnalyzer();
|
|
7895
9440
|
return new GenericPlatformAnalyzer(resolved);
|
|
7896
9441
|
}
|
|
7897
9442
|
/** Generate MCP documents from the project */
|
|
@@ -7944,13 +9489,13 @@ var MCPGenerator = class _MCPGenerator {
|
|
|
7944
9489
|
};
|
|
7945
9490
|
const serialized = JSON.stringify(document, null, 2);
|
|
7946
9491
|
const checksum = this.calculateChecksum(serialized);
|
|
7947
|
-
const filePath =
|
|
9492
|
+
const filePath = path8__default.resolve(
|
|
7948
9493
|
outputDir,
|
|
7949
9494
|
`mcp-document.${this.options.format}`
|
|
7950
9495
|
);
|
|
7951
9496
|
await writeFile(filePath, serialized, "utf-8");
|
|
7952
9497
|
console.log(`[MCPGenerator] Document written to: ${filePath}`);
|
|
7953
|
-
const checksumFilePath =
|
|
9498
|
+
const checksumFilePath = path8__default.resolve(outputDir, ".appilots-checksum");
|
|
7954
9499
|
await writeFile(checksumFilePath, checksum, "utf-8");
|
|
7955
9500
|
console.log(`[MCPGenerator] Checksum written to: ${checksumFilePath}`);
|
|
7956
9501
|
console.log("[MCPGenerator] Generation complete!");
|
|
@@ -7970,7 +9515,7 @@ var MCPGenerator = class _MCPGenerator {
|
|
|
7970
9515
|
* after calling `generate()`.
|
|
7971
9516
|
*/
|
|
7972
9517
|
static async readPreviousChecksum(outputDir) {
|
|
7973
|
-
const checksumFilePath =
|
|
9518
|
+
const checksumFilePath = path8__default.resolve(outputDir, ".appilots-checksum");
|
|
7974
9519
|
try {
|
|
7975
9520
|
const content = await readFile(checksumFilePath, "utf-8");
|
|
7976
9521
|
return content.trim() || null;
|
|
@@ -7989,7 +9534,7 @@ var MCPGenerator = class _MCPGenerator {
|
|
|
7989
9534
|
*/
|
|
7990
9535
|
async getProjectInfo() {
|
|
7991
9536
|
try {
|
|
7992
|
-
const packageJsonPath =
|
|
9537
|
+
const packageJsonPath = path8__default.resolve(this.analyzerConfig.rootDir, "package.json");
|
|
7993
9538
|
const packageJsonContent = await readFile(packageJsonPath, "utf-8");
|
|
7994
9539
|
const packageJson = JSON.parse(packageJsonContent);
|
|
7995
9540
|
return {
|
|
@@ -8375,6 +9920,6 @@ var AppilotsAPIClient = class {
|
|
|
8375
9920
|
}
|
|
8376
9921
|
};
|
|
8377
9922
|
|
|
8378
|
-
export { AppilotsAPIClient, ComponentAnalyzer, DEFAULT_MANIFEST_FILENAME, FormAnalyzer, GenericPlatformAnalyzer, MCPGenerator, NavigationAnalyzer, ReactNativePlatformAnalyzer, ScreenAnalyzer, formatMetadataWarnings, getConfigPath, getEnvOverrides, lintActionMetadata, loadConfig, loadManifest, mergeManifestNavigation, mergeManifestScreens, saveConfig, validateConfig };
|
|
9923
|
+
export { AppilotsAPIClient, ComponentAnalyzer, DEFAULT_MANIFEST_FILENAME, DEFAULT_WEB_SCREEN_PATTERNS, FormAnalyzer, GenericPlatformAnalyzer, MCPGenerator, NavigationAnalyzer, ReactNativePlatformAnalyzer, ReactWebPlatformAnalyzer, ScreenAnalyzer, WebNavigationAnalyzer, WebScreenAnalyzer, formatMetadataWarnings, getConfigPath, getEnvOverrides, lintActionMetadata, loadConfig, loadManifest, mergeManifestNavigation, mergeManifestScreens, resolvePathToScreen, saveConfig, screenNameFromPath, validateConfig };
|
|
8379
9924
|
//# sourceMappingURL=index.mjs.map
|
|
8380
9925
|
//# sourceMappingURL=index.mjs.map
|