@aarwitz/tapp 0.17.0 → 0.17.2
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/.claude-plugin/plugin.json +2 -2
- package/AGENTS.md +16 -6
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +54 -7
- package/README.md +29 -17
- package/bin/tapp.js +170 -2
- package/docs/scenarios.md +1 -1
- package/mcp-server/src/android-driver.js +20 -2
- package/mcp-server/src/environment-preflight.js +35 -0
- package/mcp-server/src/focused-navigation.js +271 -0
- package/mcp-server/src/index.js +325 -29
- package/mcp-server/src/ui-map.js +2 -2
- package/package.json +3 -3
- package/scripts/quick-capture.sh +42 -11
- package/scripts/run-flow.sh +12 -1
- package/skills/tapp/SKILL.md +28 -5
- package/skills/tapp/references/commands.md +13 -5
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// Source-connected focused navigation. This module does not ask a model to guess a route: source
|
|
2
|
+
// locates the requested surface, while only runtime-observed UI Map edges authorize replay.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { existingProjectArtifactPath } from "./project-paths.js";
|
|
8
|
+
import { replayableUiMapNavigation, semanticUiKey, validateUiMap } from "./ui-map.js";
|
|
9
|
+
|
|
10
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
11
|
+
".swift", ".m", ".mm", ".h", ".kt", ".kts", ".java", ".xml",
|
|
12
|
+
".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte", ".html", ".css",
|
|
13
|
+
".cs", ".xaml", ".dart", ".rb", ".py",
|
|
14
|
+
]);
|
|
15
|
+
const IGNORED_SEGMENTS = new Set([
|
|
16
|
+
".git", ".tapp", "node_modules", "Pods", ".build", "build", "dist", "DerivedData",
|
|
17
|
+
".next", ".nuxt", "vendor", "coverage", "captures",
|
|
18
|
+
]);
|
|
19
|
+
const INTENT_WORDS = new Set([
|
|
20
|
+
"above", "after", "again", "and", "app", "before", "below", "button", "check", "click",
|
|
21
|
+
"control", "destination", "ensure", "exactly", "field", "find", "go", "keyboard", "link", "make",
|
|
22
|
+
"navigate", "once", "open", "page", "please", "press", "reach", "screen", "should", "show",
|
|
23
|
+
"tap", "tapp", "that", "then", "the", "this", "to", "use", "verify", "view", "visible", "when",
|
|
24
|
+
"where", "with", "works",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
function posix(value) { return String(value || "").replaceAll("\\", "/").replace(/^\.\//, ""); }
|
|
28
|
+
function words(value) {
|
|
29
|
+
return [...new Set(semanticUiKey(value).split("-").filter((word) => word.length >= 3 && !INTENT_WORDS.has(word)))];
|
|
30
|
+
}
|
|
31
|
+
function phrase(value) { return String(value || "").normalize("NFKC").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); }
|
|
32
|
+
function uniq(values) { return [...new Set(values.filter(Boolean))]; }
|
|
33
|
+
function focusPhrase(value) {
|
|
34
|
+
const tokens = phrase(value).split(" ").filter(Boolean).filter((word) => !INTENT_WORDS.has(word));
|
|
35
|
+
return uniq(tokens).join(" ") || phrase(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sourceFiles(projectDir) {
|
|
39
|
+
const tracked = spawnSync("git", ["-C", projectDir, "ls-files", "-co", "--exclude-standard", "-z"], {
|
|
40
|
+
encoding: "utf8", maxBuffer: 16 * 1024 * 1024,
|
|
41
|
+
});
|
|
42
|
+
if (tracked.status === 0) return tracked.stdout.split("\0").filter(Boolean).map(posix);
|
|
43
|
+
const found = [];
|
|
44
|
+
const walk = (relative = "") => {
|
|
45
|
+
if (found.length >= 8000) return;
|
|
46
|
+
const absolute = path.join(projectDir, relative);
|
|
47
|
+
let entries;
|
|
48
|
+
try { entries = fs.readdirSync(absolute, { withFileTypes:true }); } catch { return; }
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
if (IGNORED_SEGMENTS.has(entry.name)) continue;
|
|
51
|
+
const child = posix(path.join(relative, entry.name));
|
|
52
|
+
if (entry.isDirectory()) walk(child);
|
|
53
|
+
else if (entry.isFile()) found.push(child);
|
|
54
|
+
if (found.length >= 8000) break;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
walk();
|
|
58
|
+
return found;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function symbolNear(lines, index) {
|
|
62
|
+
const patterns = [
|
|
63
|
+
/\b(?:struct|class|enum|protocol|interface|object)\s+([A-Za-z_$][\w$]*)/,
|
|
64
|
+
/\b(?:function|func|fun|def)\s+([A-Za-z_$][\w$]*)/,
|
|
65
|
+
/\b(?:const|let|var)\s+([A-Z][A-Za-z0-9_$]*)\s*=.*(?:=>|function|View|Screen|Page|Component|\()/,
|
|
66
|
+
];
|
|
67
|
+
for (let i = index; i >= Math.max(0, index - 80); i -= 1) {
|
|
68
|
+
for (const pattern of patterns) {
|
|
69
|
+
const match = pattern.exec(lines[i]);
|
|
70
|
+
if (match) return match[1];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const enclosingType = /\b(?:struct|class|enum|protocol|interface|object)\s+([A-Za-z_$][\w$]*)/;
|
|
74
|
+
for (let i = Math.max(0, index - 81); i >= 0; i -= 1) {
|
|
75
|
+
const match = enclosingType.exec(lines[i]);
|
|
76
|
+
if (match) return match[1];
|
|
77
|
+
}
|
|
78
|
+
return "";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sourceHint(value) {
|
|
82
|
+
return String(value || "")
|
|
83
|
+
.replace(/\.(?:swift|tsx?|jsx?|kt|java|cs|xaml|vue|svelte|dart)$/i, "")
|
|
84
|
+
.replace(/(?:ViewController|View|Screen|Page|Component|Activity|Fragment)$/i, "")
|
|
85
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function findSourceUiMatches({ projectDir, query, limit = 10 } = {}) {
|
|
89
|
+
const root = fs.realpathSync(path.resolve(projectDir || process.cwd()));
|
|
90
|
+
const queryPhrase = focusPhrase(query);
|
|
91
|
+
const queryWords = words(query);
|
|
92
|
+
if (!queryPhrase || !queryWords.length) return [];
|
|
93
|
+
const matches = [];
|
|
94
|
+
for (const relative of sourceFiles(root)) {
|
|
95
|
+
const absolute = path.join(root, relative);
|
|
96
|
+
const ext = path.extname(relative).toLowerCase();
|
|
97
|
+
if (!SOURCE_EXTENSIONS.has(ext) || relative.split("/").some((part) => IGNORED_SEGMENTS.has(part))) continue;
|
|
98
|
+
let stat;
|
|
99
|
+
try { stat = fs.statSync(absolute); } catch { continue; }
|
|
100
|
+
if (stat.size > 768 * 1024) continue;
|
|
101
|
+
let text;
|
|
102
|
+
try { text = fs.readFileSync(absolute, "utf8"); } catch { continue; }
|
|
103
|
+
if (text.includes("\0")) continue;
|
|
104
|
+
const lines = text.split(/\r?\n/);
|
|
105
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
106
|
+
const linePhrase = phrase(lines[index]);
|
|
107
|
+
if (!linePhrase) continue;
|
|
108
|
+
const hits = queryWords.filter((word) => linePhrase.includes(word));
|
|
109
|
+
if (!hits.length) continue;
|
|
110
|
+
const exact = linePhrase.includes(queryPhrase);
|
|
111
|
+
const coverage = hits.length / queryWords.length;
|
|
112
|
+
if (!exact && hits.length < Math.min(2, queryWords.length) && coverage < 0.6) continue;
|
|
113
|
+
const symbol = symbolNear(lines, index);
|
|
114
|
+
const fileHint = sourceHint(path.basename(relative));
|
|
115
|
+
const uiLiteral = /(?:Text|Button|Label|NavigationLink|accessibilityLabel|contentDescription|aria-label)\s*\(?\s*["']/.test(lines[index]);
|
|
116
|
+
matches.push({
|
|
117
|
+
path:relative, line:index + 1, snippet:lines[index].trim().replace(/\s+/g, " ").slice(0, 240),
|
|
118
|
+
symbol:symbol || undefined, screenHint:sourceHint(symbol) || fileHint || undefined,
|
|
119
|
+
matchedTerms:hits, score:(exact ? 120 : 0) + (uiLiteral ? 35 : 0) + Math.round(coverage * 80) + hits.length * 5,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return matches
|
|
124
|
+
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path) || a.line - b.line)
|
|
125
|
+
.filter((match, index, all) => index === all.findIndex((item) => item.path === match.path && item.screenHint === match.screenHint))
|
|
126
|
+
.slice(0, Math.max(1, Math.min(25, limit)));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function sourceReferenceTrail(projectDir, sourceMatches, { maxDepth = 2, limit = 12 } = {}) {
|
|
130
|
+
const files = sourceFiles(projectDir).filter((relative) =>
|
|
131
|
+
SOURCE_EXTENSIONS.has(path.extname(relative).toLowerCase()) && !relative.split("/").some((part) => IGNORED_SEGMENTS.has(part))
|
|
132
|
+
);
|
|
133
|
+
const documents = [];
|
|
134
|
+
for (const relative of files) {
|
|
135
|
+
const absolute = path.join(projectDir, relative);
|
|
136
|
+
try {
|
|
137
|
+
if (fs.statSync(absolute).size > 768 * 1024) continue;
|
|
138
|
+
const text = fs.readFileSync(absolute, "utf8");
|
|
139
|
+
if (!text.includes("\0")) documents.push({ path:relative, lines:text.split(/\r?\n/) });
|
|
140
|
+
} catch { /* one unreadable source file cannot block location */ }
|
|
141
|
+
}
|
|
142
|
+
// Begin from the strongest UI definition only. Lower-ranked matches often name implementation
|
|
143
|
+
// helpers (`save…`, `sectionCard`) and create irrelevant call-graph noise instead of a route.
|
|
144
|
+
let frontier = uniq(sourceMatches.slice(0, 1).map((match) => match.symbol)).filter((symbol) => symbol.length >= 4);
|
|
145
|
+
const visited = new Set(frontier);
|
|
146
|
+
const trail = [];
|
|
147
|
+
for (let depth = 0; depth < maxDepth && frontier.length && trail.length < limit; depth += 1) {
|
|
148
|
+
const next = [];
|
|
149
|
+
for (const symbol of frontier) {
|
|
150
|
+
const definition = new RegExp(`\\b(?:struct|class|enum|protocol|interface|object|function|func|fun|def)\\s+${symbol.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
|
151
|
+
for (const document of documents) {
|
|
152
|
+
for (let index = 0; index < document.lines.length; index += 1) {
|
|
153
|
+
const line = document.lines[index];
|
|
154
|
+
if (!line.includes(symbol) || definition.test(line)) continue;
|
|
155
|
+
const owner = symbolNear(document.lines, index);
|
|
156
|
+
const key = `${document.path}:${index + 1}`;
|
|
157
|
+
const context = document.lines.slice(Math.max(0, index - 2), Math.min(document.lines.length, index + 3)).map((item) => item.trim()).filter(Boolean).join(" ");
|
|
158
|
+
const followingContext = document.lines.slice(index, Math.min(document.lines.length, index + 3)).join(" ");
|
|
159
|
+
const tag = followingContext.match(/\.tag\([^)]*\.([A-Za-z_$][\w$]*)\)/)?.[1];
|
|
160
|
+
let controlHint = "";
|
|
161
|
+
if (tag) {
|
|
162
|
+
const taggedChoice = document.lines.find((candidate) => candidate.includes(`.${tag},`) && /["'][^"']+["']/.test(candidate));
|
|
163
|
+
const quoted = taggedChoice ? [...taggedChoice.matchAll(/["']([^"']+)["']/g)].map((match) => match[1]) : [];
|
|
164
|
+
controlHint = quoted.at(-1) || "";
|
|
165
|
+
}
|
|
166
|
+
if (!trail.some((item) => `${item.path}:${item.line}` === key)) trail.push({
|
|
167
|
+
path:document.path, line:index + 1, references:symbol, owner:owner || undefined,
|
|
168
|
+
snippet:context.replace(/\s+/g, " ").slice(0, 300), ...(controlHint ? { controlHint } : {}), depth:depth + 1,
|
|
169
|
+
});
|
|
170
|
+
if (owner && owner !== symbol && owner.length >= 4 && !visited.has(owner)) { visited.add(owner); next.push(owner); }
|
|
171
|
+
if (trail.length >= limit) break;
|
|
172
|
+
}
|
|
173
|
+
if (trail.length >= limit) break;
|
|
174
|
+
}
|
|
175
|
+
if (trail.length >= limit) break;
|
|
176
|
+
}
|
|
177
|
+
frontier = uniq(next);
|
|
178
|
+
}
|
|
179
|
+
return trail;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function mapScore(node, query, queryWords, sourceMatches) {
|
|
183
|
+
const nodeValues = [node.name, node.semanticKey, ...(node.aliases || [])];
|
|
184
|
+
const controlValues = (node.controls || []).flatMap((control) => [
|
|
185
|
+
control.label, control.id, control.semanticKey, ...(control.selectors || []).map((selector) => selector.value),
|
|
186
|
+
]);
|
|
187
|
+
const exactQuery = focusPhrase(query);
|
|
188
|
+
const exactControl = controlValues.some((value) => phrase(value) === exactQuery);
|
|
189
|
+
const exactNode = nodeValues.some((value) => phrase(value) === exactQuery);
|
|
190
|
+
const combined = phrase([...nodeValues, ...controlValues].join(" "));
|
|
191
|
+
const hitCount = queryWords.filter((word) => combined.includes(word)).length;
|
|
192
|
+
const hints = sourceMatches.flatMap((match) => [match.screenHint, sourceHint(path.basename(match.path))]).filter(Boolean);
|
|
193
|
+
const hintHit = hints.some((hint) => {
|
|
194
|
+
const key = semanticUiKey(hint);
|
|
195
|
+
return nodeValues.some((value) => semanticUiKey(value) === key || semanticUiKey(value).includes(key) || key.includes(semanticUiKey(value)));
|
|
196
|
+
});
|
|
197
|
+
const ownershipHit = sourceMatches.some((match) => (node.sourcePaths || []).some((owner) => posix(match.path).startsWith(posix(owner).replace(/\/$/, ""))));
|
|
198
|
+
// A destination whose own name exactly matches the request outranks a parent screen that merely
|
|
199
|
+
// contains a same-label navigation control (for example Settings → "Update Profile"). Otherwise
|
|
200
|
+
// focused navigation stops one edge early and hands the routing burden back to the agent.
|
|
201
|
+
return (exactControl ? 260 : 0) + (exactNode ? 420 : 0) + hitCount * 24 + (hintHit ? 110 : 0) + (ownershipHit ? 140 : 0);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function readMap(projectDir, mapPath) {
|
|
205
|
+
const resolved = path.resolve(projectDir, mapPath || existingProjectArtifactPath(projectDir, "ui-map.json"));
|
|
206
|
+
if (!resolved.startsWith(`${path.resolve(projectDir)}${path.sep}`) || !fs.existsSync(resolved)) return { path:resolved, map:null, error:"UI Map not found" };
|
|
207
|
+
try {
|
|
208
|
+
const map = JSON.parse(fs.readFileSync(resolved, "utf8"));
|
|
209
|
+
const errors = validateUiMap(map);
|
|
210
|
+
return errors.length ? { path:resolved, map:null, error:`Invalid UI Map: ${errors.join("; ")}` } : { path:resolved, map };
|
|
211
|
+
} catch (error) { return { path:resolved, map:null, error:error.message || String(error) }; }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function locateFocusedTarget({ projectDir = process.cwd(), query, platform = "", mapPath = "", currentScreen = "" } = {}) {
|
|
215
|
+
const root = fs.realpathSync(path.resolve(projectDir));
|
|
216
|
+
const requested = String(query || "").trim();
|
|
217
|
+
if (!requested) throw new Error("A focused UI query is required");
|
|
218
|
+
const sourceMatches = findSourceUiMatches({ projectDir:root, query:requested });
|
|
219
|
+
const sourceTrail = sourceReferenceTrail(root, sourceMatches);
|
|
220
|
+
const loaded = readMap(root, mapPath);
|
|
221
|
+
if (!loaded.map) return {
|
|
222
|
+
kind:"tapp-focused-target", status:sourceMatches.length ? "source-located" : "not-found", query:requested,
|
|
223
|
+
projectDir:root, platform:platform || null, sourceMatches, sourceTrail, map:{ path:loaded.path, available:false, reason:loaded.error },
|
|
224
|
+
navigation:{ status:"blocked", reason:"No valid observed UI Map is available; Tapp will not invent a route from source alone." },
|
|
225
|
+
};
|
|
226
|
+
const map = loaded.map;
|
|
227
|
+
const selectedPlatform = platform || (map.app?.platforms || [])[0] || "";
|
|
228
|
+
const queryWords = words(requested);
|
|
229
|
+
const ranked = (map.nodes || []).map((node) => ({ node, score:mapScore(node, requested, queryWords, sourceMatches) }))
|
|
230
|
+
.filter((item) => item.score > 0 && (!selectedPlatform || !(item.node.platforms || []).length || item.node.platforms.includes(selectedPlatform)))
|
|
231
|
+
.sort((a, b) => b.score - a.score || a.node.id.localeCompare(b.node.id));
|
|
232
|
+
const best = ranked[0];
|
|
233
|
+
if (!best || (ranked[1] && best.score === ranked[1].score && best.score < 200)) return {
|
|
234
|
+
kind:"tapp-focused-target", status:sourceMatches.length ? "source-located" : "not-found", query:requested,
|
|
235
|
+
projectDir:root, platform:selectedPlatform || null, sourceMatches, sourceTrail,
|
|
236
|
+
map:{ path:loaded.path, available:true, candidates:ranked.slice(0, 5).map(({ node, score }) => ({ id:node.id, name:node.name, score })) },
|
|
237
|
+
navigation:{ status:"blocked", reason:best ? "More than one UI Map state matches; choose a screen instead of guessing." : "No observed UI Map state matches the requested surface." },
|
|
238
|
+
};
|
|
239
|
+
const current = currentScreen ? (map.nodes || []).find((node) => [node.name, ...(node.aliases || [])].some((value) => semanticUiKey(value) === semanticUiKey(currentScreen))) : null;
|
|
240
|
+
let navigation = replayableUiMapNavigation(map, best.node.id, selectedPlatform, { maxSteps:8, startNodeId:current?.id || "" });
|
|
241
|
+
if (selectedPlatform === "web") {
|
|
242
|
+
const route = (best.node.routes || []).find((item) => item.platform === "web" && item.replayable === true);
|
|
243
|
+
if (route) navigation = { status:"replayable", mode:"direct-web-route", provenance:"observed-ui-map", targetNodeId:best.node.id, route:route.path, steps:[] };
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
kind:"tapp-focused-target", status:navigation.status === "replayable" ? "route-ready" : "source-and-map-located",
|
|
247
|
+
query:requested, projectDir:root, platform:selectedPlatform || null, sourceMatches, sourceTrail,
|
|
248
|
+
target:{ id:best.node.id, name:best.node.name, semanticKey:best.node.semanticKey, score:best.score,
|
|
249
|
+
matchedControls:(best.node.controls || []).filter((control) => words(`${control.label} ${control.id} ${control.semanticKey}`).some((word) => queryWords.includes(word))).slice(0, 10) },
|
|
250
|
+
map:{ path:loaded.path, available:true }, navigation,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function focusedTargetSummary(result) {
|
|
255
|
+
const lines = [`🎯 ${result.status === "route-ready" ? "Focused route ready" : "Focused target located"} — ${result.query}`];
|
|
256
|
+
if (result.target) lines.push(`Observed screen: **${result.target.name}** (${result.platform || "platform unknown"})`);
|
|
257
|
+
if (result.navigation?.status === "replayable") {
|
|
258
|
+
if (result.navigation.mode === "direct-web-route") lines.push(`Fast path: open observed route \`${result.navigation.route}\``);
|
|
259
|
+
else lines.push(`Fast path: ${(result.navigation.steps || []).map((step) => `tap \`${step.action.target}\``).join(" → ") || "already on the target screen"}`);
|
|
260
|
+
} else lines.push(`Route: unavailable — ${result.navigation?.reason || "not observed"}`);
|
|
261
|
+
if (result.sourceMatches?.length) {
|
|
262
|
+
lines.push("Source evidence:");
|
|
263
|
+
for (const match of result.sourceMatches.slice(0, 5)) lines.push(`- \`${match.path}:${match.line}\`${match.symbol ? ` · ${match.symbol}` : ""} — ${match.snippet}`);
|
|
264
|
+
} else lines.push("Source evidence: no matching owned source text found.");
|
|
265
|
+
if (result.sourceTrail?.length) {
|
|
266
|
+
lines.push("Source navigation breadcrumbs:");
|
|
267
|
+
for (const clue of result.sourceTrail.slice(0, 6)) lines.push(`- \`${clue.path}:${clue.line}\` · ${clue.owner ? `${clue.owner} → ` : ""}${clue.references}${clue.controlHint ? ` · control hint \`${clue.controlHint}\`` : ""} — ${clue.snippet}`);
|
|
268
|
+
}
|
|
269
|
+
lines.push("Source identifies likely intent; only runtime-observed UI Map edges are replayed.");
|
|
270
|
+
return lines.join("\n");
|
|
271
|
+
}
|