Files
purewhiter--mobilegym/scripts/generate_action_tasks_from_nav_graph.mjs
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

634 lines
22 KiB
JavaScript

#!/usr/bin/env node
/**
* Generate action tasks from a nav_graph JSON that includes node.actions,
* and extract ALL reachable trajectories from entry node(s) (home) to each action node.
*
* Input: nav_graph JSON produced by scripts/navigation_declaration_analyzer.mjs
* (with actions enabled), e.g.
* public/wechatreading_nav_graph_20260112_with_actions.json
*
* Output: JSONL file where each line is a task:
* {
* "app": "wechat_reading",
* "taskId": "wechat_reading:settings.reader.autoLock.toggle",
* "start": { "nodeId": "/" },
* "target": { "nodeId": "/settings", "routePath": "/settings", "uiStateId": "settings.base" },
* "trajectories": [
* { "nodes": [...], "transitions": [...], "length": N },
* ...
* ],
* "trajectoryCount": N,
* "shortestLength": N,
* "action": { "id": "...", "label": "...", "behavior": "...", "scope": "...", "paramsSchema": {...} }
* }
*/
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import ts from 'typescript';
function usage() {
console.log(`
Usage:
node scripts/generate_action_tasks_from_nav_graph.mjs --graph <graph.json> --out <tasks.jsonl>
Options:
--graph <file> Nav graph JSON path (must contain nodes[].actions)
--out <file> Output JSONL file path
--limit <n> Limit number of tasks written (default: no limit)
--start-node <id> Override start nodeId (default: entryPoint nodes)
--app <AppName> Scan apps/<AppName> to infer action gesture/ui usage (optional)
--include-unreachable Also output tasks that are unreachable from start (trajectories=[])
--max-depth <n> Max path length to search (default: 20)
--max-paths <n> Max number of paths per target node (default: 10)
--all-paths Enumerate non-shortest paths too (default: shortest paths only)
`);
}
function parseArgs(argv) {
const args = argv.slice(2);
const get = (name) => {
const idx = args.indexOf(name);
if (idx === -1) return null;
return args[idx + 1] ?? null;
};
const has = (name) => args.includes(name);
const graph = get('--graph');
const out = get('--out');
const limitRaw = get('--limit');
const startNode = get('--start-node');
const app = get('--app');
const includeUnreachable = has('--include-unreachable');
const allPaths = has('--all-paths');
const limit = limitRaw ? Number(limitRaw) : null;
const maxDepth = get('--max-depth') ? Number(get('--max-depth')) : 20;
const maxPaths = get('--max-paths') ? Number(get('--max-paths')) : 10;
return { graph, out, limit, startNode, app, includeUnreachable, allPaths, maxDepth, maxPaths };
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function isNodeId(id) {
return typeof id === 'string' && id.startsWith('/');
}
function buildAdjacency(graph) {
/** @type {Map<string, Array<any>>} */
const out = new Map();
const globalEdges = [];
for (const e of graph.edges ?? []) {
if (e.source === '*') {
globalEdges.push(e);
continue;
}
if (!isNodeId(e.source)) continue;
const list = out.get(e.source) ?? [];
list.push(e);
out.set(e.source, list);
}
return { out, globalEdges };
}
/**
* Find ALL paths from start nodes to ALL reachable nodes using BFS.
* BFS guarantees paths are discovered in order of length (shortest first).
* Returns a Map<targetNodeId, Array<{nodes, transitions, edges}>>
*/
function findAllPaths(graph, startNodeIds, maxDepth, maxPathsPerTarget, { allPaths = false } = {}) {
const { out, globalEdges } = buildAdjacency(graph);
/** @type {Map<string, Array<{nodes: string[], transitions: string[], edges: any[]}>>} */
const pathsByNode = new Map();
// For shortest-only mode: track best (shortest) length discovered for each node.
/** @type {Map<string, number>} */
const bestLenByNode = new Map();
// Initialize: start nodes have a trivial path (just themselves)
for (const s of startNodeIds) {
if (!isNodeId(s)) continue;
pathsByNode.set(s, [{ nodes: [s], transitions: [], edges: [] }]);
bestLenByNode.set(s, 0);
}
// BFS queue: each entry is { currentNode, path: {nodes, transitions, edges}, visitedSet }
const queue = [];
// Initialize queue with start nodes
for (const startNode of startNodeIds) {
if (!isNodeId(startNode)) continue;
queue.push({
currentNode: startNode,
path: { nodes: [startNode], transitions: [], edges: [] },
visitedSet: new Set([startNode]),
});
}
// BFS to find all paths (shortest paths first due to BFS nature)
while (queue.length > 0) {
const { currentNode, path, visitedSet } = queue.shift();
// Check depth limit
if (path.nodes.length >= maxDepth) continue;
const nextEdges = [
...(out.get(currentNode) ?? []),
...globalEdges,
];
for (const e of nextEdges) {
const nextNode = e?.target;
if (!isNodeId(nextNode)) continue;
// Avoid cycles within this path
if (visitedSet.has(nextNode)) continue;
const newPath = {
nodes: [...path.nodes, nextNode],
transitions: [...path.transitions, e.id],
edges: [...path.edges, e],
};
const newLen = newPath.transitions.length;
// Shortest-only mode: keep only shortest paths for each target node (and all ties).
if (!allPaths) {
const best = bestLenByNode.get(nextNode);
if (best === undefined || newLen < best) {
bestLenByNode.set(nextNode, newLen);
pathsByNode.set(nextNode, [newPath]);
} else if (newLen === best) {
const existing = pathsByNode.get(nextNode) ?? [];
if (existing.length < maxPathsPerTarget) {
existing.push(newPath);
pathsByNode.set(nextNode, existing);
} else {
// cap reached for this node
}
} else {
// longer than best, skip
continue;
}
// Continue BFS from this path if within cap (even for equal-best, to discover other shortest routes).
const existingPaths = pathsByNode.get(nextNode) ?? [];
if (existingPaths.length <= maxPathsPerTarget) {
const newVisitedSet = new Set(visitedSet);
newVisitedSet.add(nextNode);
queue.push({
currentNode: nextNode,
path: newPath,
visitedSet: newVisitedSet,
});
}
continue;
}
// all-paths mode: Store many paths (up to cap), including non-shortest
const existingPaths = pathsByNode.get(nextNode) ?? [];
if (existingPaths.length < maxPathsPerTarget) {
existingPaths.push(newPath);
pathsByNode.set(nextNode, existingPaths);
const newVisitedSet = new Set(visitedSet);
newVisitedSet.add(nextNode);
queue.push({
currentNode: nextNode,
path: newPath,
visitedSet: newVisitedSet,
});
}
}
}
return pathsByNode;
}
/**
* Get all trajectories for a target node.
* Returns array of {nodes, transitions, edges, length} sorted by length.
*/
function getAllTrajectories(allPathsMap, targetNodeId) {
const paths = allPathsMap.get(targetNodeId);
if (!paths || paths.length === 0) return [];
return paths
.map(p => ({
nodes: p.nodes,
transitions: p.transitions,
edges: p.edges,
length: p.transitions.length,
}))
.sort((a, b) => a.length - b.length);
}
// ============================================================================
// Best-effort: infer action gesture usages from app source code
// ============================================================================
function listFilesRecursive(dir, { exts, ignoreDirs }) {
const out = [];
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (ignoreDirs.has(entry.name)) continue;
out.push(...listFilesRecursive(full, { exts, ignoreDirs }));
} else {
const ext = path.extname(entry.name);
if (exts.has(ext)) out.push(full);
}
}
return out;
}
function createSourceFile(filePath) {
const text = fs.readFileSync(filePath, 'utf8');
const ext = path.extname(filePath);
const kind = ext === '.tsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
return ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, kind);
}
function getStringLiteralValue(node) {
if (!node) return null;
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
if (ts.isParenthesizedExpression(node)) return getStringLiteralValue(node.expression);
if (ts.isAsExpression(node)) return getStringLiteralValue(node.expression);
return null;
}
function getProp(objLit, name) {
if (!objLit || !objLit.properties) return null;
for (const prop of objLit.properties) {
if (!ts.isPropertyAssignment(prop)) continue;
const key = prop.name;
if (ts.isIdentifier(key) && key.text === name) return prop.initializer;
if (ts.isStringLiteral(key) && key.text === name) return prop.initializer;
}
return null;
}
function inferActionUiUsages(appDir, declaredActionIds) {
if (!appDir) return new Map();
const absDir = path.resolve(appDir);
if (!fs.existsSync(absDir)) return new Map();
const ignoreDirs = new Set(['node_modules', 'dist', 'build']);
const exts = new Set(['.ts', '.tsx']);
const files = listFilesRecursive(absDir, { exts, ignoreDirs });
/** @type {Map<string, Array<{gestureType:string, kind:string, file:string, line:number, col:number}>>} */
const out = new Map();
const record = (actionId, entry) => {
if (!declaredActionIds.has(actionId)) return;
const list = out.get(actionId) ?? [];
list.push(entry);
out.set(actionId, list);
};
for (const filePath of files) {
// Never treat the declaration itself as a "usage" source.
if (filePath.endsWith(`${path.sep}navigation.declaration.ts`)) continue;
const sf = createSourceFile(filePath);
const rel = path.relative(process.cwd(), filePath);
const locOf = (node) => {
const lc = sf.getLineAndCharacterOfPosition(node.getStart(sf));
return { file: rel, line: lc.line + 1, col: lc.character + 1 };
};
const extractActionSpecId = (objLit) => {
if (!objLit || !ts.isObjectLiteralExpression(objLit)) return null;
const kindVal = getStringLiteralValue(getProp(objLit, 'kind'));
if (kindVal !== 'action') return null;
return getStringLiteralValue(getProp(objLit, 'id'));
};
function visit(node) {
// bindTap/bindLongPress/bindDoubleTap({kind:'action', id:'x'})
if (ts.isCallExpression(node)) {
const expr = node.expression;
const callee =
ts.isIdentifier(expr) ? expr.text :
ts.isPropertyAccessExpression(expr) ? expr.name.text :
null;
if (callee === 'bindTap' || callee === 'bindLongPress' || callee === 'bindDoubleTap') {
const first = node.arguments[0];
if (first && ts.isObjectLiteralExpression(first)) {
const id = extractActionSpecId(first);
if (id) {
const gestureType =
callee === 'bindTap' ? 'tap' :
callee === 'bindLongPress' ? 'longPress' :
'doubleTap';
record(id, { kind: callee, gestureType, ...locOf(first) });
}
}
}
}
// Best-effort: indirect bindings via shared components (e.g., TopBar right button)
// We detect any call that passes an object with `id: '<actionId>'`.
if (ts.isCallExpression(node)) {
const expr = node.expression;
const calleeName =
ts.isIdentifier(expr) ? expr.text :
ts.isPropertyAccessExpression(expr) ? expr.name.text :
'call';
// Avoid double counting for bind* calls: they are already handled above with accurate gesture type.
if (calleeName === 'bindTap' || calleeName === 'bindLongPress' || calleeName === 'bindDoubleTap') {
ts.forEachChild(node, visit);
return;
}
for (const arg of node.arguments ?? []) {
if (!arg || !ts.isObjectLiteralExpression(arg)) continue;
const idInit = getProp(arg, 'id');
const id = getStringLiteralValue(idInit);
if (id && declaredActionIds.has(id)) {
// Gesture type is unknown at analysis time; default to tap for task generation.
record(id, { kind: calleeName, gestureType: 'tap', ...locOf(idInit ?? arg) });
}
}
}
// JSX: data-action="x", plus data-action-type if present (input)
if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) {
const attrs = node.attributes?.properties ?? [];
let dataAction = null;
let dataActionType = null;
for (const a of attrs) {
if (!ts.isJsxAttribute(a)) continue;
const name = a.name?.text;
if (name === 'data-action') {
const init = a.initializer;
if (init && ts.isStringLiteral(init)) dataAction = init.text;
}
if (name === 'data-action-type') {
const init = a.initializer;
if (init && ts.isStringLiteral(init)) dataActionType = init.text;
}
}
if (dataAction && declaredActionIds.has(dataAction)) {
record(dataAction, { kind: 'data-action', gestureType: dataActionType ?? 'unknown', ...locOf(node) });
}
}
ts.forEachChild(node, visit);
}
visit(sf);
}
// de-dup per actionId
for (const [id, list] of out.entries()) {
const seen = new Set();
const uniq = [];
for (const x of list) {
const k = `${x.kind}:${x.gestureType}:${x.file}:${x.line}:${x.col}`;
if (seen.has(k)) continue;
seen.add(k);
uniq.push(x);
}
out.set(id, uniq);
}
return out;
}
function pickNodeDetail(node) {
if (!node) return null;
return {
nodeId: node.id,
routePath: node.routePath,
uiStateId: node.uiStateId,
component: node.component,
description: node.description,
entryPoint: Boolean(node.entryPoint),
search: node.search ?? {},
queryParams: node.queryParams ?? {},
params: node.params ?? {},
scrollContainers: node.scrollContainers ?? [],
stateCondition: node.stateCondition ?? null,
actionsCount: Array.isArray(node.actions) ? node.actions.length : 0,
};
}
function pickEdgeDetail(edge) {
if (!edge) return null;
return {
transitionId: edge.id,
label: edge.label ?? '',
type: edge.type ?? null,
mode: edge.mode ?? null,
source: edge.source,
target: edge.target,
uiMeta: edge.uiMeta ?? null,
uiCondition: edge.uiCondition ?? null,
fromConstraint: edge.fromConstraint ?? null,
search: edge.search ?? {},
searchParams: edge.searchParams ?? {},
params: edge.params ?? {},
preserveParams: edge.preserveParams ?? [],
binding: edge.binding ?? null,
expandedFrom: edge.expandedFrom ?? null,
dataSourceRef: edge.dataSourceRef ?? null,
};
}
function buildDetailedSteps(traj, nodeMap) {
const detailedSteps = [];
for (let i = 0; i < traj.edges.length; i++) {
const fromId = traj.nodes[i];
const toId = traj.nodes[i + 1];
const edge = traj.edges[i];
detailedSteps.push({
index: i,
kind: 'transition',
from: pickNodeDetail(nodeMap.get(fromId)),
edge: pickEdgeDetail(edge),
to: pickNodeDetail(nodeMap.get(toId)),
ui: {
trigger: {
dataTrigger: edge?.id ?? null,
dataTriggerType: edge?.uiMeta?.gesture ?? null,
paramsSchema: edge?.params ?? {},
searchParamsSchema: edge?.searchParams ?? {},
},
placement: edge?.uiMeta?.placement ?? null,
icon: edge?.uiMeta?.icon ?? null,
gesture: edge?.uiMeta?.gesture ?? null,
},
});
}
return detailedSteps;
}
function main() {
const { graph: graphPath, out: outPath, limit, startNode, app, includeUnreachable, allPaths, maxDepth, maxPaths } = parseArgs(process.argv);
if (!graphPath || !outPath) {
usage();
process.exit(2);
}
const absGraph = path.resolve(graphPath);
const absOut = path.resolve(outPath);
const graph = readJson(absGraph);
const nodes = Array.isArray(graph.nodes) ? graph.nodes : [];
const entryNodes = nodes.filter(n => n?.entryPoint === true).map(n => n.id).filter(isNodeId);
const startNodeIds = startNode ? [startNode] : entryNodes;
if (startNodeIds.length === 0) {
throw new Error(`No start nodes found. Provide --start-node, or ensure nodes[].entryPoint=true exists in graph.`);
}
console.log(`[ActionTasks] Finding paths (mode=${allPaths ? 'all' : 'shortest'}, maxDepth=${maxDepth}, maxPaths=${maxPaths})...`);
const allPathsMap = findAllPaths(graph, startNodeIds, maxDepth, maxPaths, { allPaths });
// Optional: infer action UI usage from source
const declaredActionIds = new Set();
for (const n of nodes) {
for (const a of (Array.isArray(n?.actions) ? n.actions : [])) {
if (a?.id && typeof a.id === 'string') declaredActionIds.add(a.id);
}
}
const inferredAppDir = app ? path.join('apps', app) : (graph.appDir ? graph.appDir : null);
const actionUiUsages = inferActionUiUsages(inferredAppDir, declaredActionIds);
const nodeMap = new Map(nodes.map(n => [n.id, n]));
const tasks = [];
let totalTrajectoriesByTargetNode = 0;
let uniqueActionNodes = 0;
for (const n of nodes) {
const actions = Array.isArray(n?.actions) ? n.actions : [];
if (actions.length === 0) continue;
const nodeId = n.id;
const trajectories = getAllTrajectories(allPathsMap, nodeId);
if (trajectories.length === 0 && !includeUnreachable) continue;
uniqueActionNodes += 1;
totalTrajectoriesByTargetNode += trajectories.length;
// Build detailed steps for each trajectory
const trajectoriesDetailed = trajectories.map(traj => {
const detailedSteps = buildDetailedSteps(traj, nodeMap);
return {
nodes: traj.nodes,
transitions: traj.transitions,
length: traj.length,
steps: detailedSteps,
};
});
for (const a of actions) {
if (!a?.id || typeof a.id !== 'string') continue;
const actionUsages = actionUiUsages.get(a.id) ?? [];
const actionGestureTypes = Array.from(new Set(actionUsages.map(x => x.gestureType))).filter(Boolean);
const preferredActionGestureType = (() => {
const known = actionGestureTypes.filter(t => t && t !== 'unknown');
if (known.includes('tap')) return 'tap';
if (known.includes('longPress')) return 'longPress';
if (known.includes('doubleTap')) return 'doubleTap';
if (known.includes('back')) return 'back';
return 'tap';
})();
const actionStep = {
kind: 'action',
at: pickNodeDetail(n),
ui: {
trigger: {
dataAction: a.id,
dataActionType: preferredActionGestureType,
paramsSchema: a.paramsSchema ?? null,
},
},
action: {
id: a.id,
label: a.label ?? null,
description: a.description ?? null,
behavior: a.behavior ?? null,
scope: a.scope ?? null,
paramsSchema: a.paramsSchema ?? null,
},
codeUsages: actionUsages.slice(0, 10),
};
// Append action step to each trajectory's steps
const trajectoriesWithAction = trajectoriesDetailed.map(td => ({
...td,
steps: [
...td.steps,
{ ...actionStep, index: td.steps.length },
],
}));
const shortestLength = trajectories.length > 0 ? trajectories[0].length : null;
tasks.push({
app: graph.app ?? null,
graphFile: path.relative(process.cwd(), absGraph),
taskId: `${graph.app ?? 'app'}:${a.id}`,
start: { nodeId: startNodeIds[0] },
target: { nodeId, routePath: n.routePath, uiStateId: n.uiStateId },
trajectoryCount: trajectories.length,
shortestLength,
trajectories: trajectories.map(t => ({ nodes: t.nodes, transitions: t.transitions, length: t.length })),
trajectoriesDetailed: trajectoriesWithAction,
action: {
id: a.id,
label: a.label ?? null,
description: a.description ?? null,
behavior: a.behavior ?? null,
scope: a.scope ?? null,
paramsSchema: a.paramsSchema ?? null,
},
});
}
}
// Stable order: reachable first (shorter first), then by taskId.
tasks.sort((x, y) => {
const ax = x.shortestLength ?? 1e9;
const ay = y.shortestLength ?? 1e9;
if (ax !== ay) return ax - ay;
return String(x.taskId).localeCompare(String(y.taskId));
});
const finalTasks = typeof limit === 'number' && Number.isFinite(limit) ? tasks.slice(0, limit) : tasks;
fs.mkdirSync(path.dirname(absOut), { recursive: true });
// Use JSON format (pretty) for .json files, JSONL for .jsonl files
if (absOut.endsWith('.json')) {
fs.writeFileSync(absOut, JSON.stringify(finalTasks, null, 2), 'utf8');
} else {
// JSONL format: one JSON object per line
const lines = finalTasks.map(t => JSON.stringify(t));
fs.writeFileSync(absOut, lines.join('\n') + (lines.length ? '\n' : ''), 'utf8');
}
const reachable = finalTasks.filter(t => t.trajectoryCount > 0).length;
const unreachable = finalTasks.length - reachable;
const totalTrajectoriesByTask = finalTasks.reduce((sum, t) => sum + (t.trajectoryCount ?? 0), 0);
console.log(`[ActionTasks] graph=${path.relative(process.cwd(), absGraph)}`);
console.log(`[ActionTasks] startNodes=${startNodeIds.join(', ')}`);
console.log(`[ActionTasks] tasks=${finalTasks.length} reachable=${reachable} unreachable=${unreachable}`);
console.log(`[ActionTasks] uniqueActionNodes=${uniqueActionNodes}`);
console.log(`[ActionTasks] totalTrajectoriesByTargetNode=${totalTrajectoriesByTargetNode} (sum of trajectories per target node with actions)`);
console.log(`[ActionTasks] totalTrajectoriesByTask=${totalTrajectoriesByTask} (sum of trajectoryCount across tasks)`);
console.log(`[ActionTasks] wrote ${path.relative(process.cwd(), absOut)}`);
}
main();