visor-ai 0.2.6 → 0.2.8

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/appMap.js ADDED
@@ -0,0 +1,721 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { resolveTapMode } from './adapters.js';
6
+ import { canonicalJson, ensureDir, signatureFor, utcNowIso } from './utils.js';
7
+ export const APP_MAP_SCHEMA_VERSION = 1;
8
+ const RISKY_WORDS = [
9
+ 'delete',
10
+ 'remove',
11
+ 'logout',
12
+ 'log out',
13
+ 'sign out',
14
+ 'submit',
15
+ 'send',
16
+ 'pay',
17
+ 'purchase',
18
+ 'buy',
19
+ 'confirm',
20
+ 'destroy',
21
+ 'archive'
22
+ ];
23
+ const INPUT_WORDS = ['password', 'email', 'username', 'search', 'input', 'text field', 'textfield'];
24
+ const AUTH_WORDS = ['login', 'log in', 'sign in', 'password', 'username', 'auth', 'authentication'];
25
+ function envNoMap() {
26
+ const raw = process.env.VISOR_NO_MAP ?? process.env.VISOR_DISABLE_MAP;
27
+ return ['1', 'true', 'yes', 'on'].includes(String(raw ?? '').trim().toLowerCase());
28
+ }
29
+ function resolveOptions(platform, options) {
30
+ const enabled = options?.enabled !== false && !envNoMap();
31
+ return {
32
+ enabled,
33
+ rootDir: options?.rootDir ?? process.env.VISOR_APP_MAP_DIR ?? path.join(process.cwd(), '.visor', 'maps'),
34
+ appId: options?.appId ?? `default-${platform}`,
35
+ repairDepth: options?.repairDepth ?? 2,
36
+ repairTimeoutMs: options?.repairTimeoutMs ?? 30000
37
+ };
38
+ }
39
+ function mapIdentity(platform, appId) {
40
+ return `${platform}:${appId}`;
41
+ }
42
+ function mapFilePath(rootDir, identity) {
43
+ const digest = createHash('sha256').update(identity, 'utf8').digest('hex').slice(0, 16);
44
+ return path.join(ensureDir(rootDir), `${digest}.json`);
45
+ }
46
+ function newMap(platform, appId) {
47
+ const now = utcNowIso();
48
+ return {
49
+ schema_version: APP_MAP_SCHEMA_VERSION,
50
+ identity: mapIdentity(platform, appId),
51
+ app_id: appId,
52
+ platform,
53
+ created_at: now,
54
+ updated_at: now,
55
+ screens: [],
56
+ variants: [],
57
+ edges: []
58
+ };
59
+ }
60
+ function readMap(filePath, platform, appId) {
61
+ if (!fs.existsSync(filePath)) {
62
+ return { map: newMap(platform, appId), scrubbed: false };
63
+ }
64
+ try {
65
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
66
+ if (parsed.schema_version !== APP_MAP_SCHEMA_VERSION || parsed.identity !== mapIdentity(platform, appId)) {
67
+ return { map: newMap(platform, appId), scrubbed: false };
68
+ }
69
+ const originalEdgeCount = parsed.edges.length;
70
+ parsed.edges = parsed.edges.filter((edge) => !isValueBearingAction(edge.command, edge.args));
71
+ return { map: parsed, scrubbed: parsed.edges.length !== originalEdgeCount };
72
+ }
73
+ catch {
74
+ return { map: newMap(platform, appId), scrubbed: false };
75
+ }
76
+ }
77
+ function writeMap(filePath, map) {
78
+ ensureDir(path.dirname(filePath));
79
+ const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
80
+ fs.writeFileSync(tempPath, `${JSON.stringify(map, null, 2)}\n`, 'utf8');
81
+ fs.renameSync(tempPath, filePath);
82
+ }
83
+ export function createMapSummary(adapter, options) {
84
+ const platform = adapter.capability().platform;
85
+ const resolved = resolveOptions(platform, options);
86
+ const identity = mapIdentity(platform, resolved.appId);
87
+ return {
88
+ enabled: resolved.enabled,
89
+ used: false,
90
+ updated: false,
91
+ repaired: false,
92
+ repairs: 0,
93
+ schema_version: APP_MAP_SCHEMA_VERSION,
94
+ path: resolved.enabled ? mapFilePath(resolved.rootDir, identity) : undefined,
95
+ identity
96
+ };
97
+ }
98
+ export function createAppMapContext(adapter, options) {
99
+ const platform = adapter.capability().platform;
100
+ const resolved = resolveOptions(platform, options);
101
+ if (!resolved.enabled) {
102
+ return null;
103
+ }
104
+ const identity = mapIdentity(platform, resolved.appId);
105
+ const filePath = mapFilePath(resolved.rootDir, identity);
106
+ const summary = createMapSummary(adapter, options);
107
+ const loaded = readMap(filePath, platform, resolved.appId);
108
+ return {
109
+ adapter,
110
+ map: loaded.map,
111
+ filePath,
112
+ options: resolved,
113
+ summary,
114
+ changed: loaded.scrubbed
115
+ };
116
+ }
117
+ export function persistAppMapContext(context) {
118
+ if (!context) {
119
+ return undefined;
120
+ }
121
+ context.summary.updated = context.summary.updated || context.changed;
122
+ if (context.changed) {
123
+ context.map.updated_at = utcNowIso();
124
+ writeMap(context.filePath, context.map);
125
+ context.changed = false;
126
+ }
127
+ return context.summary;
128
+ }
129
+ function attributeMap(input) {
130
+ const attrs = {};
131
+ const pattern = /([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)')/g;
132
+ for (const match of input.matchAll(pattern)) {
133
+ attrs[match[1]] = match[3] ?? match[4] ?? '';
134
+ }
135
+ return attrs;
136
+ }
137
+ function normalizeLabel(value) {
138
+ return value
139
+ .replace(/[0-9a-f]{8,}/gi, '<id>')
140
+ .replace(/\b\d{1,2}:\d{2}(?::\d{2})?\b/g, '<time>')
141
+ .replace(/\b\d+\b/g, '<number>')
142
+ .replace(/\s+/g, ' ')
143
+ .trim()
144
+ .toLowerCase();
145
+ }
146
+ function unique(values) {
147
+ return Array.from(new Set(values.filter((value) => value.trim() !== '')));
148
+ }
149
+ function isLikelyFormControl(tag, attrs) {
150
+ const haystack = [
151
+ tag,
152
+ attrs.type ?? '',
153
+ attrs.class ?? '',
154
+ attrs.name ?? '',
155
+ attrs.label ?? '',
156
+ attrs.placeholder ?? ''
157
+ ]
158
+ .join(' ')
159
+ .toLowerCase();
160
+ return [
161
+ 'input',
162
+ 'textfield',
163
+ 'text field',
164
+ 'edittext',
165
+ 'secure',
166
+ 'password',
167
+ 'email',
168
+ 'username',
169
+ 'search',
170
+ 'phone',
171
+ 'credit',
172
+ 'card'
173
+ ].some((word) => haystack.includes(word));
174
+ }
175
+ function redactLabel(value) {
176
+ return value
177
+ .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '<redacted-email>')
178
+ .replace(/\b(?:\+?\d[\d .()-]{7,}\d)\b/g, '<redacted-phone>')
179
+ .replace(/\b(?:\d[ -]*?){13,19}\b/g, '<redacted-card>');
180
+ }
181
+ function classifyControl(tag, labels, attrs) {
182
+ const haystack = [...labels, tag, attrs.type ?? '', attrs.class ?? ''].join(' ').toLowerCase();
183
+ if (RISKY_WORDS.some((word) => haystack.includes(word))) {
184
+ return 'risky';
185
+ }
186
+ if (INPUT_WORDS.some((word) => haystack.includes(word))) {
187
+ return 'needs-input';
188
+ }
189
+ if (tag.toLowerCase().includes('button') ||
190
+ attrs.clickable === 'true' ||
191
+ attrs.enabled === 'true' ||
192
+ attrs.accessible === 'true') {
193
+ return 'safe';
194
+ }
195
+ return 'unknown';
196
+ }
197
+ function extractElements(source) {
198
+ const elements = [];
199
+ const tagPattern = /<([A-Za-z][A-Za-z0-9_.:-]*)([^<>]*?)(?:\/>|>([^<>]*)<\/\1>)/g;
200
+ for (const match of source.matchAll(tagPattern)) {
201
+ const tag = match[1];
202
+ const attrs = attributeMap(match[2] ?? '');
203
+ const bodyText = (match[3] ?? '').trim();
204
+ const formControl = isLikelyFormControl(tag, attrs);
205
+ const rawLabels = unique([
206
+ redactLabel(attrs.name ?? ''),
207
+ redactLabel(attrs.label ?? ''),
208
+ formControl ? '' : redactLabel(attrs.text ?? ''),
209
+ formControl ? '' : redactLabel(attrs.value ?? ''),
210
+ redactLabel(attrs['content-desc'] ?? ''),
211
+ redactLabel(attrs['contentDescription'] ?? ''),
212
+ formControl ? '' : redactLabel(bodyText)
213
+ ]);
214
+ const id = attrs['resource-id'] ?? attrs.id ?? attrs.identifier ?? '';
215
+ const targets = unique([
216
+ ...rawLabels,
217
+ ...rawLabels.map((label) => `text=${label}`),
218
+ id ? `id=${id}` : ''
219
+ ]);
220
+ if (targets.length === 0) {
221
+ continue;
222
+ }
223
+ const stable = Boolean(rawLabels.length > 0 || id);
224
+ const selector = id ? `id=${id}` : rawLabels[0] ?? targets[0];
225
+ elements.push({
226
+ tag,
227
+ selector,
228
+ targets,
229
+ labels: rawLabels,
230
+ stable,
231
+ safety: classifyControl(tag, rawLabels, attrs)
232
+ });
233
+ }
234
+ return elements;
235
+ }
236
+ function observeSource(source) {
237
+ const elements = extractElements(source);
238
+ const normalizedLabels = elements.flatMap((element) => element.labels.map(normalizeLabel));
239
+ const elementKeys = unique([
240
+ ...elements.flatMap((element) => element.targets.map(normalizeLabel)),
241
+ ...normalizedLabels
242
+ ]).sort();
243
+ const normalizedFingerprint = signatureFor(elementKeys);
244
+ const authRequired = AUTH_WORDS.some((word) => normalizedLabels.some((label) => label.includes(word)));
245
+ return {
246
+ fingerprint: signatureFor(source),
247
+ normalized_fingerprint: normalizedFingerprint,
248
+ elements,
249
+ element_keys: elementKeys,
250
+ auth_required: authRequired
251
+ };
252
+ }
253
+ function similarity(left, right) {
254
+ if (left.length === 0 && right.length === 0) {
255
+ return 1;
256
+ }
257
+ const leftSet = new Set(left);
258
+ const rightSet = new Set(right);
259
+ const intersection = Array.from(leftSet).filter((value) => rightSet.has(value)).length;
260
+ const union = new Set([...left, ...right]).size;
261
+ const jaccard = union === 0 ? 0 : intersection / union;
262
+ const smaller = Math.min(leftSet.size, rightSet.size);
263
+ const containment = smaller === 0 ? 0 : intersection / smaller;
264
+ return containment >= 0.8 ? Math.max(jaccard, containment) : jaccard;
265
+ }
266
+ function elementIdentityKeys(elements) {
267
+ return unique(elements.flatMap((element) => {
268
+ const tag = element.tag.toLowerCase();
269
+ const labels = element.labels.map(normalizeLabel);
270
+ const ids = element.targets.filter((target) => target.startsWith('id=')).map(normalizeLabel);
271
+ return [...labels, ...ids].map((value) => `${tag}:${element.safety}:${value}`);
272
+ })).sort();
273
+ }
274
+ function newVariant(map, observation) {
275
+ const screen = {
276
+ id: `screen_${map.screens.length + 1}`,
277
+ variant_ids: []
278
+ };
279
+ const variant = {
280
+ id: `variant_${map.variants.length + 1}`,
281
+ screen_id: screen.id,
282
+ fingerprint: observation.fingerprint,
283
+ normalized_fingerprint: observation.normalized_fingerprint,
284
+ elements: observation.elements,
285
+ element_keys: observation.element_keys,
286
+ auth_required: observation.auth_required,
287
+ observations: 1,
288
+ confidence: 0.6,
289
+ last_observed_at: utcNowIso()
290
+ };
291
+ screen.variant_ids.push(variant.id);
292
+ map.screens.push(screen);
293
+ map.variants.push(variant);
294
+ return variant;
295
+ }
296
+ function upsertVariant(map, observation) {
297
+ let best = null;
298
+ let bestScore = 0;
299
+ for (const variant of map.variants) {
300
+ const score = variant.normalized_fingerprint === observation.normalized_fingerprint
301
+ ? 1
302
+ : similarity(elementIdentityKeys(variant.elements), elementIdentityKeys(observation.elements));
303
+ if (score > bestScore) {
304
+ best = variant;
305
+ bestScore = score;
306
+ }
307
+ }
308
+ if (!best || bestScore < 0.45) {
309
+ return {
310
+ observation,
311
+ variant: newVariant(map, observation),
312
+ created: true
313
+ };
314
+ }
315
+ best.fingerprint = observation.fingerprint;
316
+ best.normalized_fingerprint = observation.normalized_fingerprint;
317
+ best.elements = observation.elements;
318
+ best.element_keys = observation.element_keys;
319
+ best.auth_required = observation.auth_required;
320
+ best.observations += 1;
321
+ best.confidence = Math.min(1, best.confidence + 0.05);
322
+ best.last_observed_at = utcNowIso();
323
+ return {
324
+ observation,
325
+ variant: best,
326
+ created: false
327
+ };
328
+ }
329
+ async function observeCurrentScreen(context) {
330
+ const sourceDir = ensureDir(path.join(os.tmpdir(), 'visor-app-map-source'));
331
+ const sourcePath = path.join(sourceDir, `${process.pid}-${Date.now()}-${randomUUID()}.xml`);
332
+ const details = await context.adapter.source({ label: 'app-map-source', path: sourcePath });
333
+ const args = details.args;
334
+ const sourceArgs = args && typeof args === 'object' && !Array.isArray(args)
335
+ ? args
336
+ : {};
337
+ const detailPath = typeof sourceArgs.path === 'string'
338
+ ? sourceArgs.path
339
+ : sourcePath;
340
+ const source = fs.existsSync(detailPath) ? fs.readFileSync(detailPath, 'utf8') : '';
341
+ try {
342
+ fs.rmSync(detailPath, { force: true });
343
+ }
344
+ catch {
345
+ // Source observations are transient; stale temp files should not fail a run.
346
+ }
347
+ const observed = upsertVariant(context.map, observeSource(source));
348
+ context.changed = true;
349
+ return observed;
350
+ }
351
+ function targetDescriptor(command, args) {
352
+ if (isValueBearingAction(command, args)) {
353
+ return { kind: 'unknown', confidence: 0 };
354
+ }
355
+ if (command === 'tap') {
356
+ try {
357
+ if (resolveTapMode(args) === 'coordinates') {
358
+ return { kind: 'coordinate', confidence: 0 };
359
+ }
360
+ }
361
+ catch {
362
+ return { kind: 'unknown', confidence: 0 };
363
+ }
364
+ }
365
+ const target = typeof args.target === 'string' ? args.target : undefined;
366
+ if (!target) {
367
+ return { kind: 'unknown', confidence: 0 };
368
+ }
369
+ if (target.startsWith('text=')) {
370
+ return { kind: 'text', target, value: target.slice(5), confidence: 0.6 };
371
+ }
372
+ if (target.startsWith('xpath=')) {
373
+ return { kind: 'unknown', target, value: target.slice(6), confidence: 0.2 };
374
+ }
375
+ return { kind: 'stable', target, value: target.replace(/^(id=|accessibility=)/, ''), confidence: 0.9 };
376
+ }
377
+ function isValueBearingAction(command, args) {
378
+ return command === 'act' && args.name === 'type' && args.value !== undefined;
379
+ }
380
+ function replayableEdge(edge) {
381
+ return !isValueBearingAction(edge.command, edge.args);
382
+ }
383
+ function variantContainsTarget(variant, descriptor) {
384
+ if (!descriptor.target && !descriptor.value) {
385
+ return false;
386
+ }
387
+ const target = descriptor.target ?? '';
388
+ const value = descriptor.value ?? target;
389
+ return variant.elements.some((element) => {
390
+ if (element.targets.includes(target) || element.targets.includes(value)) {
391
+ return true;
392
+ }
393
+ if (descriptor.kind === 'text') {
394
+ return element.labels.some((label) => label === value);
395
+ }
396
+ return element.labels.some((label) => label === value);
397
+ });
398
+ }
399
+ function candidateVariants(map, descriptor) {
400
+ return map.variants.filter((variant) => variantContainsTarget(variant, descriptor));
401
+ }
402
+ function summarizeEdge(edge) {
403
+ return {
404
+ command: edge.command,
405
+ target: edge.target,
406
+ confidence: Math.round(edge.confidence * 100) / 100
407
+ };
408
+ }
409
+ function summarizeControl(command, target, confidence) {
410
+ return {
411
+ command,
412
+ target,
413
+ confidence: Math.round(confidence * 100) / 100
414
+ };
415
+ }
416
+ function planRoute(map, fromVariantId, descriptor) {
417
+ if (descriptor.kind === 'coordinate' || descriptor.kind === 'unknown') {
418
+ return { edges: [], ambiguous: false, authRequired: false };
419
+ }
420
+ const destinations = candidateVariants(map, descriptor);
421
+ if (destinations.length === 0) {
422
+ return { edges: [], ambiguous: false, authRequired: false };
423
+ }
424
+ if (descriptor.kind === 'text' && destinations.length > 1) {
425
+ return { edges: [], ambiguous: true, authRequired: false };
426
+ }
427
+ const destinationIds = new Set(destinations.map((variant) => variant.id));
428
+ const visited = new Set([fromVariantId]);
429
+ const queue = [{ variantId: fromVariantId, edges: [] }];
430
+ while (queue.length > 0) {
431
+ const current = queue.shift();
432
+ if (!current) {
433
+ break;
434
+ }
435
+ if (destinationIds.has(current.variantId)) {
436
+ const destination = map.variants.find((variant) => variant.id === current.variantId);
437
+ return { edges: current.edges, ambiguous: false, authRequired: Boolean(destination?.auth_required) };
438
+ }
439
+ const outgoing = map.edges
440
+ .filter((edge) => edge.from_variant_id === current.variantId && !edge.stale && edge.confidence >= 0.25 && replayableEdge(edge))
441
+ .sort((left, right) => right.confidence - left.confidence);
442
+ for (const edge of outgoing) {
443
+ if (visited.has(edge.to_variant_id)) {
444
+ continue;
445
+ }
446
+ visited.add(edge.to_variant_id);
447
+ queue.push({ variantId: edge.to_variant_id, edges: [...current.edges, edge] });
448
+ }
449
+ }
450
+ return { edges: [], ambiguous: false, authRequired: destinations.some((variant) => variant.auth_required) };
451
+ }
452
+ function contractTargets(variant) {
453
+ return variant.elements
454
+ .filter((element) => element.stable)
455
+ .flatMap((element) => element.targets)
456
+ .slice(0, 8);
457
+ }
458
+ function matchingEdge(map, fromVariantId, command, target) {
459
+ return map.edges.find((edge) => edge.from_variant_id === fromVariantId &&
460
+ edge.command === command &&
461
+ String(edge.target ?? '') === String(target ?? ''));
462
+ }
463
+ function recordEdgeSuccess(context, from, to, command, args, candidate) {
464
+ if (from.id === to.id) {
465
+ return;
466
+ }
467
+ const descriptor = targetDescriptor(command, args);
468
+ if (descriptor.kind === 'coordinate' || descriptor.kind === 'unknown') {
469
+ return;
470
+ }
471
+ const edge = matchingEdge(context.map, from.id, command, descriptor.target);
472
+ if (edge) {
473
+ edge.to_variant_id = to.id;
474
+ edge.successes += 1;
475
+ edge.failures = 0;
476
+ edge.stale = false;
477
+ edge.candidate = edge.candidate && edge.successes < 2;
478
+ edge.confidence = Math.min(1, edge.confidence + 0.15);
479
+ edge.destination_contract = {
480
+ variant_id: to.id,
481
+ required_targets: contractTargets(to),
482
+ normalized_fingerprint: to.normalized_fingerprint
483
+ };
484
+ edge.last_observed_at = utcNowIso();
485
+ context.changed = true;
486
+ return;
487
+ }
488
+ context.map.edges.push({
489
+ id: `edge_${context.map.edges.length + 1}`,
490
+ from_variant_id: from.id,
491
+ to_variant_id: to.id,
492
+ command,
493
+ args: structuredClone(args),
494
+ target: descriptor.target,
495
+ confidence: candidate ? 0.45 : descriptor.confidence,
496
+ successes: 1,
497
+ failures: 0,
498
+ stale: false,
499
+ candidate,
500
+ destination_contract: {
501
+ variant_id: to.id,
502
+ required_targets: contractTargets(to),
503
+ normalized_fingerprint: to.normalized_fingerprint
504
+ },
505
+ last_observed_at: utcNowIso()
506
+ });
507
+ context.changed = true;
508
+ }
509
+ function demoteEdge(context, edge) {
510
+ edge.failures += 1;
511
+ edge.confidence = Math.max(0, edge.confidence - 0.25);
512
+ if (edge.failures >= 2 || edge.confidence < 0.25) {
513
+ edge.stale = true;
514
+ }
515
+ edge.last_observed_at = utcNowIso();
516
+ context.changed = true;
517
+ }
518
+ function satisfiesContract(edge, observed) {
519
+ if (observed.variant.id === edge.destination_contract.variant_id) {
520
+ return true;
521
+ }
522
+ const observedTargets = new Set(observed.variant.elements.flatMap((element) => element.targets));
523
+ const required = edge.destination_contract.required_targets;
524
+ if (required.length === 0) {
525
+ return observed.variant.normalized_fingerprint === edge.destination_contract.normalized_fingerprint;
526
+ }
527
+ const matched = required.filter((target) => observedTargets.has(target)).length;
528
+ return matched / required.length >= 0.6;
529
+ }
530
+ async function driveRoute(context, current, route) {
531
+ for (const edge of route) {
532
+ const args = structuredClone(edge.args);
533
+ let observed;
534
+ try {
535
+ await context.adapter[edge.command](args);
536
+ observed = await observeCurrentScreen(context);
537
+ }
538
+ catch {
539
+ demoteEdge(context, edge);
540
+ try {
541
+ current = await observeCurrentScreen(context);
542
+ }
543
+ catch {
544
+ // Keep the last known screen if the app cannot provide source after a failed cached action.
545
+ }
546
+ return { current, failedEdge: edge };
547
+ }
548
+ if (!satisfiesContract(edge, observed)) {
549
+ demoteEdge(context, edge);
550
+ return { current: observed, failedEdge: edge };
551
+ }
552
+ recordEdgeSuccess(context, current.variant, observed.variant, edge.command, args, false);
553
+ current = observed;
554
+ }
555
+ return { current };
556
+ }
557
+ function safeTapTargets(variant) {
558
+ return unique(variant.elements
559
+ .filter((element) => element.safety === 'safe' && element.stable)
560
+ .map((element) => {
561
+ const plainLabel = element.targets.find((target) => !target.includes('='));
562
+ return plainLabel ?? element.targets[0] ?? '';
563
+ }));
564
+ }
565
+ async function repairToTarget(context, current, descriptor, depth, deadline, blockedTargets) {
566
+ if (Date.now() > deadline || depth <= 0) {
567
+ return null;
568
+ }
569
+ const attemptedTargets = new Map();
570
+ const attemptFrom = async (start, remainingDepth, route) => {
571
+ let cursor = start;
572
+ let depthLeft = remainingDepth;
573
+ let routeSoFar = route;
574
+ while (Date.now() <= deadline) {
575
+ if (variantContainsTarget(cursor.variant, descriptor)) {
576
+ return { found: true, current: cursor, route: routeSoFar, remainingDepth: depthLeft };
577
+ }
578
+ if (depthLeft <= 0) {
579
+ return { found: false, current: cursor, route: routeSoFar, remainingDepth: depthLeft };
580
+ }
581
+ const attemptsForVariant = attemptedTargets.get(cursor.variant.id) ?? new Set();
582
+ attemptedTargets.set(cursor.variant.id, attemptsForVariant);
583
+ const target = safeTapTargets(cursor.variant).find((candidate) => !blockedTargets.has(candidate) && !attemptsForVariant.has(candidate));
584
+ if (!target) {
585
+ return { found: false, current: cursor, route: routeSoFar, remainingDepth: depthLeft };
586
+ }
587
+ attemptsForVariant.add(target);
588
+ const args = { target };
589
+ let observed;
590
+ try {
591
+ await context.adapter.tap(args);
592
+ observed = await observeCurrentScreen(context);
593
+ }
594
+ catch {
595
+ try {
596
+ cursor = await observeCurrentScreen(context);
597
+ }
598
+ catch {
599
+ // If both the exploratory tap and source read fail, keep the last observed screen.
600
+ }
601
+ continue;
602
+ }
603
+ recordEdgeSuccess(context, cursor.variant, observed.variant, 'tap', args, true);
604
+ const nextRoute = [...routeSoFar, summarizeControl('tap', target, 0.45)];
605
+ const nested = await attemptFrom(observed, depthLeft - 1, nextRoute);
606
+ if (nested.found) {
607
+ return nested;
608
+ }
609
+ cursor = nested.current;
610
+ depthLeft = nested.remainingDepth;
611
+ routeSoFar = nested.route;
612
+ }
613
+ return { found: false, current: cursor, route: routeSoFar, remainingDepth: depthLeft };
614
+ };
615
+ const attempt = await attemptFrom(current, depth, []);
616
+ return attempt.found ? { current: attempt.current, route: attempt.route } : null;
617
+ }
618
+ export async function runMappedCommand(context, command, args) {
619
+ const descriptor = targetDescriptor(command, args);
620
+ const metadata = {
621
+ enabled: true,
622
+ routed: false,
623
+ route: [],
624
+ repaired: false,
625
+ repairs: 0,
626
+ path: context.filePath
627
+ };
628
+ if (descriptor.kind === 'unknown' || descriptor.kind === 'coordinate') {
629
+ const details = await context.adapter[command](args);
630
+ return { ...details, map: metadata };
631
+ }
632
+ let before = await observeCurrentScreen(context);
633
+ if (!variantContainsTarget(before.variant, descriptor)) {
634
+ if (before.variant.auth_required) {
635
+ throw new Error(`Target '${descriptor.target ?? descriptor.value}' requires authentication from the current screen`);
636
+ }
637
+ const route = planRoute(context.map, before.variant.id, descriptor);
638
+ if (route.ambiguous) {
639
+ throw new Error(`Target '${descriptor.target ?? descriptor.value}' is ambiguous in the app map`);
640
+ }
641
+ if (route.authRequired) {
642
+ throw new Error(`Target '${descriptor.target ?? descriptor.value}' is behind an auth-required screen`);
643
+ }
644
+ if (route.edges.length > 0) {
645
+ context.summary.used = true;
646
+ metadata.routed = true;
647
+ metadata.route = route.edges.map(summarizeEdge);
648
+ const driven = await driveRoute(context, before, route.edges);
649
+ before = driven.current;
650
+ if (driven.failedEdge) {
651
+ const repair = await repairToTarget(context, before, descriptor, context.options.repairDepth, Date.now() + context.options.repairTimeoutMs, new Set(driven.failedEdge.target ? [driven.failedEdge.target] : []));
652
+ if (!repair) {
653
+ throw new Error(`Cached app-map route for '${driven.failedEdge.target ?? driven.failedEdge.command}' could not be repaired within the bounded repair budget`);
654
+ }
655
+ context.summary.repaired = true;
656
+ context.summary.repairs += 1;
657
+ metadata.repaired = true;
658
+ metadata.repairs = context.summary.repairs;
659
+ metadata.route = [...metadata.route, ...repair.route];
660
+ before = repair.current;
661
+ }
662
+ }
663
+ else {
664
+ const repair = await repairToTarget(context, before, descriptor, context.options.repairDepth, Date.now() + context.options.repairTimeoutMs, new Set());
665
+ if (repair) {
666
+ context.summary.used = true;
667
+ context.summary.repaired = true;
668
+ context.summary.repairs += 1;
669
+ metadata.routed = repair.route.length > 0;
670
+ metadata.repaired = true;
671
+ metadata.repairs = context.summary.repairs;
672
+ metadata.route = repair.route;
673
+ before = repair.current;
674
+ }
675
+ }
676
+ }
677
+ if (!variantContainsTarget(before.variant, descriptor)) {
678
+ throw new Error(`Target '${descriptor.target ?? descriptor.value}' is not reachable from the current app-map state`);
679
+ }
680
+ const details = await context.adapter[command](args);
681
+ const after = await observeCurrentScreen(context);
682
+ recordEdgeSuccess(context, before.variant, after.variant, command, args, false);
683
+ return { ...details, map: metadata };
684
+ }
685
+ export async function discoverAppMap(adapter, options) {
686
+ const context = createAppMapContext(adapter, options);
687
+ if (!context) {
688
+ return {
689
+ action: 'discover',
690
+ map: createMapSummary(adapter, options),
691
+ screen: null
692
+ };
693
+ }
694
+ try {
695
+ const observed = await observeCurrentScreen(context);
696
+ const summary = persistAppMapContext(context);
697
+ return {
698
+ action: 'discover',
699
+ map: summary,
700
+ screen: {
701
+ variant_id: observed.variant.id,
702
+ screen_id: observed.variant.screen_id,
703
+ element_count: observed.variant.elements.length,
704
+ auth_required: observed.variant.auth_required,
705
+ created: observed.created
706
+ }
707
+ };
708
+ }
709
+ finally {
710
+ persistAppMapContext(context);
711
+ }
712
+ }
713
+ export function mapDebugSnapshot(rootDir, platform, appId) {
714
+ const identity = mapIdentity(platform, appId);
715
+ const filePath = mapFilePath(rootDir, identity);
716
+ if (!fs.existsSync(filePath)) {
717
+ return null;
718
+ }
719
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
720
+ return JSON.parse(canonicalJson(parsed));
721
+ }