@aarwitz/tapp 0.15.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/AGENTS.md +123 -0
- package/Harness/OCQAHarness/AppDelegate.swift +21 -0
- package/Harness/OCQAHarness/Info.plist +26 -0
- package/Harness/OCQAHarness.xcodeproj/project.pbxproj +199 -0
- package/Harness/OCQAHarness.xcodeproj/xcshareddata/xcschemes/OCQAHarnessUITests.xcscheme +22 -0
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +4526 -0
- package/Harness/OCQAHarnessUITests/Info.plist +22 -0
- package/Harness/generate-harness-xcodeproj.rb +254 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/bin/tapp.js +1382 -0
- package/browser/app.css +227 -0
- package/browser/app.js +675 -0
- package/browser/index.html +195 -0
- package/browser/product-contract.js +25 -0
- package/browser/view-model.js +16 -0
- package/docs/BROWSER-PRODUCT.md +72 -0
- package/docs/PRODUCT-ENGINE.md +102 -0
- package/docs/application-model.md +276 -0
- package/docs/scenarios.md +95 -0
- package/mcp-server/src/android-driver.js +287 -0
- package/mcp-server/src/android-explorer.js +197 -0
- package/mcp-server/src/android-flow.js +89 -0
- package/mcp-server/src/application-model.js +1597 -0
- package/mcp-server/src/browser-product.js +659 -0
- package/mcp-server/src/browser-workspaces.js +234 -0
- package/mcp-server/src/ci-report.js +557 -0
- package/mcp-server/src/ci-setup.js +359 -0
- package/mcp-server/src/contract-authoring.js +10 -0
- package/mcp-server/src/enrich.js +57 -0
- package/mcp-server/src/flow-runtime.js +127 -0
- package/mcp-server/src/html-report.js +124 -0
- package/mcp-server/src/index.js +3775 -0
- package/mcp-server/src/maintenance-proposal.js +178 -0
- package/mcp-server/src/managed-operation.js +61 -0
- package/mcp-server/src/pr-selection.js +841 -0
- package/mcp-server/src/product-execution.js +155 -0
- package/mcp-server/src/product-operations.js +526 -0
- package/mcp-server/src/project-config.js +101 -0
- package/mcp-server/src/release-contract.d.ts +81 -0
- package/mcp-server/src/release-contract.js +226 -0
- package/mcp-server/src/report.js +363 -0
- package/mcp-server/src/scenario-runtime.js +139 -0
- package/mcp-server/src/static-server.js +44 -0
- package/mcp-server/src/task-runtime.js +266 -0
- package/mcp-server/src/ui-map.js +661 -0
- package/mcp-server/src/web-explorer.js +493 -0
- package/mcp-server/src/web-flow.js +238 -0
- package/package.json +82 -0
- package/scripts/android-corpus-e2e.sh +30 -0
- package/scripts/ci-gate.sh +323 -0
- package/scripts/cleanup-xcode.sh +157 -0
- package/scripts/compile-contract.js +27 -0
- package/scripts/compile-flow.js +18 -0
- package/scripts/corpus-apps.txt +9 -0
- package/scripts/corpus-sweep.sh +121 -0
- package/scripts/coverage-eval.sh +92 -0
- package/scripts/coverage_eval_parse.py +95 -0
- package/scripts/deploy-and-build.sh +99 -0
- package/scripts/flow-platform.js +18 -0
- package/scripts/flow_ai_judge.py +102 -0
- package/scripts/flow_lib.py +154 -0
- package/scripts/mutation-recall-desktop.sh +186 -0
- package/scripts/mutation-recall.sh +121 -0
- package/scripts/mutation_lib.py +128 -0
- package/scripts/mutation_operators.py +144 -0
- package/scripts/platform-gate.js +186 -0
- package/scripts/pr-plan.js +68 -0
- package/scripts/quick-capture.sh +419 -0
- package/scripts/run-android-flow.js +27 -0
- package/scripts/run-flow.sh +90 -0
- package/scripts/run-web-flow.js +28 -0
- package/scripts/run-web-scenario.js +23 -0
- package/scripts/validation-matrix.sh +146 -0
- package/scripts/vision-fp-eval.sh +206 -0
- package/scripts/vision_escalation_responder.py +147 -0
- package/scripts/vision_fp_probe.py +221 -0
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
// Deterministic PR-to-contract relevance. This layer consumes only reviewed
|
|
2
|
+
// source ownership, Task composition, and UI Map coverage; it does not guess
|
|
3
|
+
// product intent or let AI decide what gates a merge.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import { loadReleaseContractFile } from "./release-contract.js";
|
|
9
|
+
import { loadTaskRegistry } from "./task-runtime.js";
|
|
10
|
+
import { replayableUiMapNavigation, semanticUiKey } from "./ui-map.js";
|
|
11
|
+
|
|
12
|
+
function posix(value) {
|
|
13
|
+
return String(value || "").replaceAll("\\", "/").replace(/^\.\//, "").replace(/^\/+/, "");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function wildcard(pattern) {
|
|
17
|
+
return new RegExp(`^${posix(pattern).replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("**", "§§").replaceAll("*", "[^/]*").replaceAll("§§", ".*")}(?:/.*)?$`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function sourcePathMatches(changedFile, ownershipPath) {
|
|
21
|
+
const file = posix(changedFile);
|
|
22
|
+
const owner = posix(ownershipPath).replace(/\/$/, "");
|
|
23
|
+
if (!file || !owner) return false;
|
|
24
|
+
if (owner.includes("*")) return wildcard(owner).test(file);
|
|
25
|
+
return file === owner || file.startsWith(owner + "/");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function repoRootFor(sourcePath) {
|
|
29
|
+
let current = path.dirname(path.resolve(sourcePath));
|
|
30
|
+
while (current !== path.dirname(current)) {
|
|
31
|
+
if (path.basename(current) === ".autotap") return path.dirname(current);
|
|
32
|
+
if (fs.existsSync(path.join(current, ".autotap"))) return current;
|
|
33
|
+
current = path.dirname(current);
|
|
34
|
+
}
|
|
35
|
+
return process.cwd();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function relativeSource(sourcePath, repoRoot) {
|
|
39
|
+
const absolute = path.resolve(sourcePath);
|
|
40
|
+
return posix(path.relative(repoRoot, absolute));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mapReferences(items, references) {
|
|
44
|
+
const keys = new Set((references || []).map((item) => semanticUiKey(item)));
|
|
45
|
+
return items.filter((item) => keys.has(semanticUiKey(item.id)) || keys.has(semanticUiKey(item.semanticKey)) || keys.has(semanticUiKey(item.name)));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function matchedFiles(changedFiles, ownership) {
|
|
49
|
+
return changedFiles.filter((file) => ownership.some((owner) => sourcePathMatches(file, owner)));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function exactStaticRouteFiles(node, changedFiles) {
|
|
53
|
+
const matches = [];
|
|
54
|
+
for (const route of node.routes || []) {
|
|
55
|
+
if (route?.platform !== "web" || route.replayable !== true || typeof route.path !== "string") continue;
|
|
56
|
+
if (route.path.includes("<") || route.path.includes("?")) continue;
|
|
57
|
+
let routeFile = "";
|
|
58
|
+
try { routeFile = posix(decodeURIComponent(route.path)); } catch { continue; }
|
|
59
|
+
if (!routeFile || routeFile.endsWith("/")) continue;
|
|
60
|
+
for (const file of changedFiles) {
|
|
61
|
+
if (posix(file) === routeFile) matches.push({ file, route: route.path });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return matches;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function selectedCoverageFor(contract, relevantTasks, map) {
|
|
68
|
+
if (!map) return { nodes: [], edges: [] };
|
|
69
|
+
const nodes = mapReferences(map.nodes || [], [
|
|
70
|
+
...(contract.coverage?.nodes || []),
|
|
71
|
+
...relevantTasks.flatMap((task) => task.coverage?.nodes || []),
|
|
72
|
+
]).map((node) => node.id);
|
|
73
|
+
const edges = [
|
|
74
|
+
...(contract.coverage?.edges || []),
|
|
75
|
+
...relevantTasks.flatMap((task) => task.coverage?.edges || []),
|
|
76
|
+
].filter((edge) => (map.edges || []).some((item) => item.id === edge));
|
|
77
|
+
return { nodes: [...new Set(nodes)].sort(), edges: [...new Set(edges)].sort() };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function explorationTargetForNode({ node, evidence, platform, selectedNodeIds, map }) {
|
|
81
|
+
if (!node || selectedNodeIds.has(node.id)) return null;
|
|
82
|
+
const route = (node.routes || []).find((item) => item.platform === platform && item.replayable === true);
|
|
83
|
+
const mapNavigation = route ? null : replayableUiMapNavigation(map, node.id, platform);
|
|
84
|
+
const changedFiles = [...new Set(evidence.flatMap((item) => item.files || []))].sort();
|
|
85
|
+
const id = `explore_${crypto.createHash("sha256").update(`${platform}|${node.id}|${changedFiles.join("|")}`).digest("hex").slice(0, 16)}`;
|
|
86
|
+
return {
|
|
87
|
+
id,
|
|
88
|
+
platform,
|
|
89
|
+
node: { id: node.id, semanticKey: node.semanticKey, name: node.name },
|
|
90
|
+
changedFiles,
|
|
91
|
+
evidence,
|
|
92
|
+
navigation: route
|
|
93
|
+
? { status: "replayable", mode: "route", route: route.path, provenance: "observed-ui-map" }
|
|
94
|
+
: mapNavigation,
|
|
95
|
+
baselineControls: (node.controls || []).slice(0, 30).map((control) => ({
|
|
96
|
+
id: control.id,
|
|
97
|
+
semanticKey: control.semanticKey,
|
|
98
|
+
kind: control.kind,
|
|
99
|
+
label: control.label,
|
|
100
|
+
selectors: control.selectors || [],
|
|
101
|
+
})),
|
|
102
|
+
coverage: { status: "not-covered-by-selected-contract", tasks: node.coveredBy?.tasks || [], contracts: node.coveredBy?.contracts || [] },
|
|
103
|
+
budget: { maxTargetRoutes: 1, maxActions: 12 },
|
|
104
|
+
status: "planned",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function changedInput(value) {
|
|
109
|
+
if (Array.isArray(value)) return value;
|
|
110
|
+
const raw = String(value || "").trim();
|
|
111
|
+
if (!raw) return [];
|
|
112
|
+
if (raw.startsWith("[")) {
|
|
113
|
+
const parsed = JSON.parse(raw);
|
|
114
|
+
if (!Array.isArray(parsed)) throw new Error("changed files JSON must be an array");
|
|
115
|
+
return parsed;
|
|
116
|
+
}
|
|
117
|
+
return raw.split(/[\n,]/).map((item) => item.trim()).filter(Boolean);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Extract only declaration identities, never diff source. This deliberately
|
|
121
|
+
// favors an attributable file-level fallback over guessing when a hunk cannot
|
|
122
|
+
// be tied to a named function/type.
|
|
123
|
+
function declarationsFromLine(value) {
|
|
124
|
+
const line = String(value || "");
|
|
125
|
+
const symbols = [];
|
|
126
|
+
const patterns = [
|
|
127
|
+
/\b(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/g,
|
|
128
|
+
/\b(?:func|fun|def)\s+([A-Za-z_$][\w$]*)/g,
|
|
129
|
+
/\b(?:class|struct|interface|enum|protocol|extension|actor|record)\s+([A-Za-z_$][\w$]*)/g,
|
|
130
|
+
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/g,
|
|
131
|
+
/^\s*(?:(?:public|private|protected|internal|static|final|abstract|open|override|virtual|sealed|synchronized|native|async|export)\s+)+(?:[A-Za-z_$][\w$<>,.?\[\]:]*\s+)+([A-Za-z_$][\w$]*)\s*\(/g,
|
|
132
|
+
];
|
|
133
|
+
for (const pattern of patterns) {
|
|
134
|
+
for (const match of line.matchAll(pattern)) symbols.push(match[1]);
|
|
135
|
+
}
|
|
136
|
+
return [...new Set(symbols)];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function patchEvidence(file, patch) {
|
|
140
|
+
const hunks = [];
|
|
141
|
+
let active = null;
|
|
142
|
+
for (const line of String(patch || "").split("\n")) {
|
|
143
|
+
if (line.startsWith("@@")) {
|
|
144
|
+
const context = line.match(/^@@[\s\S]*?@@\s*(.*)$/)?.[1] || "";
|
|
145
|
+
active = { symbols: new Set(declarationsFromLine(context)) };
|
|
146
|
+
hunks.push(active);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!active || (!line.startsWith("+") && !line.startsWith("-")) || line.startsWith("+++") || line.startsWith("---")) continue;
|
|
150
|
+
for (const symbol of declarationsFromLine(line.slice(1))) active.symbols.add(symbol);
|
|
151
|
+
}
|
|
152
|
+
const symbols = hunks.flatMap((hunk) => [...hunk.symbols]);
|
|
153
|
+
return {
|
|
154
|
+
file: posix(file),
|
|
155
|
+
patchAvailable: typeof patch === "string" && patch.length > 0,
|
|
156
|
+
hunks: hunks.length,
|
|
157
|
+
attributedHunks: hunks.filter((hunk) => hunk.symbols.size > 0).length,
|
|
158
|
+
precise: hunks.length > 0 && hunks.every((hunk) => hunk.symbols.size > 0),
|
|
159
|
+
symbols: [...new Set(symbols)].sort(),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function mergeDiffEvidence(items) {
|
|
164
|
+
const byFile = new Map();
|
|
165
|
+
for (const item of items) {
|
|
166
|
+
if (!item?.file) continue;
|
|
167
|
+
const file = posix(item.file);
|
|
168
|
+
const previous = byFile.get(file);
|
|
169
|
+
if (!previous) {
|
|
170
|
+
byFile.set(file, { ...item, file, symbols: [...new Set(item.symbols || [])].sort() });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
byFile.set(file, {
|
|
174
|
+
file,
|
|
175
|
+
patchAvailable: previous.patchAvailable || item.patchAvailable,
|
|
176
|
+
hunks: previous.hunks + item.hunks,
|
|
177
|
+
attributedHunks: previous.attributedHunks + item.attributedHunks,
|
|
178
|
+
precise: previous.precise && item.precise,
|
|
179
|
+
symbols: [...new Set([...(previous.symbols || []), ...(item.symbols || [])])].sort(),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return [...byFile.values()].sort((a, b) => a.file.localeCompare(b.file));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function parseChangedDiffEvidence(value) {
|
|
186
|
+
const evidence = [];
|
|
187
|
+
for (const item of changedInput(value)) {
|
|
188
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
|
189
|
+
const file = item.path || item.filename;
|
|
190
|
+
if (!file || typeof item.patch !== "string") continue;
|
|
191
|
+
evidence.push(patchEvidence(file, item.patch));
|
|
192
|
+
}
|
|
193
|
+
return mergeDiffEvidence(evidence);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function parseChangedSymbols(value) {
|
|
197
|
+
return parseChangedDiffEvidence(value).flatMap((item) => item.symbols.map((symbol) => ({
|
|
198
|
+
file: item.file,
|
|
199
|
+
symbol,
|
|
200
|
+
basis: "diff-declaration-or-hunk-context",
|
|
201
|
+
})));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function taskCalls(task) {
|
|
205
|
+
const variants = Array.isArray(task.steps)
|
|
206
|
+
? [task.steps]
|
|
207
|
+
: Object.values(task.implementations || {}).map((value) => Array.isArray(value) ? value : value?.steps);
|
|
208
|
+
return [...new Set(variants.flatMap((steps) => (steps || []).flatMap((step) => {
|
|
209
|
+
if (typeof step?.task === "string") return [step.task];
|
|
210
|
+
if (typeof step?.do?.task === "string") return [step.do.task];
|
|
211
|
+
return [];
|
|
212
|
+
})))];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function sha256(file) {
|
|
216
|
+
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function implementationFor(task, platform) {
|
|
220
|
+
if (Array.isArray(task.steps)) return { platform: "shared", steps: task.steps, path: ["steps"] };
|
|
221
|
+
const implementations = task.implementations || {};
|
|
222
|
+
const key = implementations[platform] ? platform : implementations.shared ? "shared" : implementations.default ? "default" : "";
|
|
223
|
+
if (!key) return null;
|
|
224
|
+
const value = implementations[key];
|
|
225
|
+
return Array.isArray(value)
|
|
226
|
+
? { platform: key, steps: value, path: ["implementations", key] }
|
|
227
|
+
: Array.isArray(value?.steps) ? { platform: key, steps: value.steps, path: ["implementations", key, "steps"] } : null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function selectorStepReference(step, index, prefix) {
|
|
231
|
+
if (!step || typeof step !== "object" || Array.isArray(step)) return null;
|
|
232
|
+
if (typeof step.action === "string") {
|
|
233
|
+
const action = step.action.toLowerCase();
|
|
234
|
+
if (!['tap', 'type'].includes(action)) return null;
|
|
235
|
+
const field = action === "type" && step.field !== undefined ? "field" : "target";
|
|
236
|
+
if (typeof step[field] !== "string") return null;
|
|
237
|
+
return { action, target: step[field], pointer: [...prefix, String(index), field] };
|
|
238
|
+
}
|
|
239
|
+
const [action, body] = Object.entries(step)[0] || [];
|
|
240
|
+
if (!['tap', 'type'].includes(String(action || "").toLowerCase())) return null;
|
|
241
|
+
if (typeof body === "string") return { action: action.toLowerCase(), target: body, pointer: [...prefix, String(index), action] };
|
|
242
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
243
|
+
const field = action.toLowerCase() === "type" ? "field" : "target";
|
|
244
|
+
if (typeof body[field] !== "string") return null;
|
|
245
|
+
return { action: action.toLowerCase(), target: body[field], pointer: [...prefix, String(index), action, field] };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function controlMatches(control, target) {
|
|
249
|
+
const key = semanticUiKey(target);
|
|
250
|
+
return [control.id, control.semanticKey, control.label, ...(control.selectors || []).map((selector) => selector.value)]
|
|
251
|
+
.some((value) => semanticUiKey(value) === key);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function selectorReferences(task, map, platform, root) {
|
|
255
|
+
const implementation = implementationFor(task, platform);
|
|
256
|
+
if (!implementation || !map) return [];
|
|
257
|
+
const nodes = mapReferences(map.nodes || [], task.coverage?.nodes || []);
|
|
258
|
+
if (!nodes.length) return [];
|
|
259
|
+
return implementation.steps.flatMap((step, index) => {
|
|
260
|
+
const reference = selectorStepReference(step, index, implementation.path);
|
|
261
|
+
if (!reference || reference.target.includes("{{")) return [];
|
|
262
|
+
const controls = nodes.flatMap((node) => (node.controls || []).filter((control) => controlMatches(control, reference.target)).map((control) => ({
|
|
263
|
+
nodeId: node.id,
|
|
264
|
+
nodeSemanticKey: node.semanticKey,
|
|
265
|
+
nodeName: node.name,
|
|
266
|
+
controlId: control.id,
|
|
267
|
+
label: control.label,
|
|
268
|
+
selectors: (control.selectors || []).filter((selector) => ["testId", "accessibilityId", "resourceId", "cssId", "label"].includes(selector.kind)),
|
|
269
|
+
})));
|
|
270
|
+
if (!controls.length) return [];
|
|
271
|
+
return [{
|
|
272
|
+
task: task.name,
|
|
273
|
+
taskPath: relativeSource(task.__path, root),
|
|
274
|
+
taskSha256: sha256(task.__path),
|
|
275
|
+
platform: implementation.platform,
|
|
276
|
+
action: reference.action,
|
|
277
|
+
target: reference.target,
|
|
278
|
+
pointer: "/" + reference.pointer.map((part) => String(part).replaceAll("~", "~0").replaceAll("/", "~1")).join("/"),
|
|
279
|
+
baselineControls: controls,
|
|
280
|
+
}];
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function transitiveTasks(names, registry) {
|
|
285
|
+
const found = [];
|
|
286
|
+
const visited = new Set();
|
|
287
|
+
const visit = (name) => {
|
|
288
|
+
if (visited.has(name)) return;
|
|
289
|
+
visited.add(name);
|
|
290
|
+
const task = registry.get(name);
|
|
291
|
+
if (!task) return;
|
|
292
|
+
found.push(task);
|
|
293
|
+
for (const child of taskCalls(task)) visit(child);
|
|
294
|
+
};
|
|
295
|
+
for (const name of names) visit(name);
|
|
296
|
+
return found;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function parseChangedFiles(value) {
|
|
300
|
+
const input = changedInput(value);
|
|
301
|
+
if (Array.isArray(input)) {
|
|
302
|
+
const files = input.flatMap((item) => {
|
|
303
|
+
if (typeof item === "string") return [item];
|
|
304
|
+
if (!item || typeof item !== "object") throw new Error("changed files must be strings or change objects");
|
|
305
|
+
return [item.path || item.filename, item.previousPath || item.previous_filename].filter(Boolean);
|
|
306
|
+
});
|
|
307
|
+
return [...new Set(files.map(posix).filter(Boolean))].sort();
|
|
308
|
+
}
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function webSeedRoutesFromPrPlan(plan, limit = 5) {
|
|
313
|
+
if (!plan || typeof plan !== "object" || Array.isArray(plan)) return [];
|
|
314
|
+
const boundedLimit = Math.max(0, Math.min(5, Number(limit) || 0));
|
|
315
|
+
if (boundedLimit === 0) return [];
|
|
316
|
+
const routes = [];
|
|
317
|
+
for (const target of plan.explorationTargets || []) {
|
|
318
|
+
const route = target?.navigation?.route;
|
|
319
|
+
if (target?.platform !== "web" || target?.status !== "planned" || target?.navigation?.status !== "replayable" || typeof route !== "string" || !route.startsWith("/")) continue;
|
|
320
|
+
if (!routes.includes(route)) routes.push(route);
|
|
321
|
+
if (routes.length >= boundedLimit) break;
|
|
322
|
+
}
|
|
323
|
+
return routes;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function prExplorationTargetsFromPlan(plan, platform, limit = platform === "web" ? 5 : 1) {
|
|
327
|
+
if (!plan || typeof plan !== "object" || Array.isArray(plan) || !["ios", "android", "web"].includes(platform)) return [];
|
|
328
|
+
const maximum = platform === "web" ? 5 : 1;
|
|
329
|
+
const boundedLimit = Math.max(0, Math.min(maximum, Number(limit) || 0));
|
|
330
|
+
if (boundedLimit === 0) return [];
|
|
331
|
+
const targets = [];
|
|
332
|
+
const ids = new Set();
|
|
333
|
+
for (const candidate of plan.explorationTargets || []) {
|
|
334
|
+
if (candidate?.platform !== platform || candidate?.status !== "planned" || candidate?.navigation?.status !== "replayable") continue;
|
|
335
|
+
if (typeof candidate.id !== "string" || !candidate.id || ids.has(candidate.id) || !candidate.node?.id || !candidate.node?.name) continue;
|
|
336
|
+
const navigation = candidate.navigation;
|
|
337
|
+
let safeNavigation = null;
|
|
338
|
+
if (navigation.mode === "route" || (navigation.mode === undefined && typeof navigation.route === "string")) {
|
|
339
|
+
if (platform !== "web" || typeof navigation.route !== "string" || !navigation.route.startsWith("/") || navigation.route.includes("<")) continue;
|
|
340
|
+
safeNavigation = { status: "replayable", mode: "route", route: navigation.route, provenance: "observed-ui-map" };
|
|
341
|
+
} else if (navigation.mode === "ui-map-path") {
|
|
342
|
+
if (typeof navigation.entryNodeId !== "string" || typeof navigation.targetNodeId !== "string" || navigation.targetNodeId !== candidate.node.id || !Array.isArray(navigation.steps) || navigation.steps.length > 8) continue;
|
|
343
|
+
const steps = [];
|
|
344
|
+
let valid = true;
|
|
345
|
+
for (const step of navigation.steps) {
|
|
346
|
+
const action = step?.action;
|
|
347
|
+
if (!step?.edgeId || !step?.from || !step?.to || !["tap", "back"].includes(action?.type) || typeof action?.target !== "string" || !action.target || /<[^>]+>|\{\{/.test(action.target)) { valid = false; break; }
|
|
348
|
+
steps.push(structuredClone(step));
|
|
349
|
+
}
|
|
350
|
+
if (!valid) continue;
|
|
351
|
+
safeNavigation = {
|
|
352
|
+
status: "replayable", mode: "ui-map-path", provenance: "observed-ui-map",
|
|
353
|
+
entryNodeId: navigation.entryNodeId, targetNodeId: navigation.targetNodeId,
|
|
354
|
+
steps, maxSteps: Math.max(0, Math.min(8, Number(navigation.maxSteps) || 8)),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
if (!safeNavigation) continue;
|
|
358
|
+
ids.add(candidate.id);
|
|
359
|
+
targets.push({
|
|
360
|
+
id: candidate.id,
|
|
361
|
+
platform,
|
|
362
|
+
status: "planned",
|
|
363
|
+
node: { id: candidate.node.id, semanticKey: candidate.node.semanticKey, name: candidate.node.name },
|
|
364
|
+
navigation: safeNavigation,
|
|
365
|
+
budget: structuredClone(candidate.budget || { maxTargetRoutes: 1, maxActions: 12 }),
|
|
366
|
+
});
|
|
367
|
+
if (targets.length >= boundedLimit) break;
|
|
368
|
+
}
|
|
369
|
+
return targets;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function inside(root, candidate) {
|
|
373
|
+
return candidate === root || candidate.startsWith(root + path.sep);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function groundingKey(entry) {
|
|
377
|
+
if (entry?.type === "ui-map-node") return `ui-map-node:${entry.id}`;
|
|
378
|
+
if (entry?.type === "pr-exploration") return `pr-exploration:${entry.targetId}`;
|
|
379
|
+
return JSON.stringify(entry);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function mergeGroundingEvidence(existing, incoming) {
|
|
383
|
+
const merged = [];
|
|
384
|
+
const indexByKey = new Map();
|
|
385
|
+
for (const entry of [...(existing || []), ...(incoming || [])]) {
|
|
386
|
+
const key = groundingKey(entry);
|
|
387
|
+
const index = indexByKey.get(key);
|
|
388
|
+
if (index === undefined) {
|
|
389
|
+
indexByKey.set(key, merged.length);
|
|
390
|
+
merged.push(structuredClone(entry));
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
const prior = merged[index];
|
|
394
|
+
if (entry.type === "ui-map-node") prior.observationCount = Math.max(Number(prior.observationCount || 0), Number(entry.observationCount || 0));
|
|
395
|
+
else if (entry.type === "pr-exploration") {
|
|
396
|
+
prior.changedFiles = [...new Set([...(prior.changedFiles || []), ...(entry.changedFiles || [])])].sort();
|
|
397
|
+
prior.route ||= entry.route;
|
|
398
|
+
prior.navigationMode ||= entry.navigationMode;
|
|
399
|
+
prior.edgeIds = [...new Set([...(prior.edgeIds || []), ...(entry.edgeIds || [])])].sort();
|
|
400
|
+
prior.provenance ||= entry.provenance;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return merged;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function adoptPrCoverageProposal({ projectDir, prPlanPath, item, releasePlanPath = ".autotap/release-plan.json" } = {}) {
|
|
407
|
+
const root = fs.realpathSync(path.resolve(projectDir || process.cwd()));
|
|
408
|
+
const source = path.resolve(prPlanPath || "");
|
|
409
|
+
if (!prPlanPath || !fs.existsSync(source)) throw new Error(`PR plan not found: ${source || "(missing path)"}`);
|
|
410
|
+
const targetPath = path.resolve(root, releasePlanPath);
|
|
411
|
+
if (!inside(root, targetPath)) throw new Error("Release plan path must stay inside the project directory");
|
|
412
|
+
if (!fs.existsSync(targetPath)) throw new Error(`Release plan not found: ${targetPath}; run tapp init first`);
|
|
413
|
+
const prPlan = JSON.parse(fs.readFileSync(source, "utf8"));
|
|
414
|
+
if (prPlan?.schemaVersion !== 1 || !Array.isArray(prPlan.explorationTargets)) throw new Error("PR plan must be an executed Tapp PR plan v1");
|
|
415
|
+
const matches = prPlan.explorationTargets.filter((target) => target.id === item);
|
|
416
|
+
if (matches.length !== 1) throw new Error(matches.length ? `PR exploration item is ambiguous: ${item}` : `PR exploration item not found: ${item}`);
|
|
417
|
+
const target = matches[0];
|
|
418
|
+
if (target.execution?.status !== "observed" || target.execution?.conclusive !== true) throw new Error(`PR exploration item '${item}' has no conclusive observed execution evidence`);
|
|
419
|
+
const proposal = target.coverageProposal;
|
|
420
|
+
if (proposal?.kind !== "release-plan-item-proposal" || proposal.autoApply !== false || !["add-item", "reconcile-item"].includes(proposal.operation?.op)) {
|
|
421
|
+
throw new Error(`PR exploration item '${item}' has no reviewable release-plan proposal`);
|
|
422
|
+
}
|
|
423
|
+
const proposed = structuredClone(proposal.operation.item);
|
|
424
|
+
if (proposed?.origin !== "deterministic-ui-map-proposal" || proposed?.decision !== "pending") throw new Error("Coverage proposal is not a pending UI-Map-grounded release-plan item");
|
|
425
|
+
const ground = (proposed.groundedBy || []).find((entry) => entry.type === "ui-map-node");
|
|
426
|
+
const mapPath = path.join(root, ".autotap", "ui-map.json");
|
|
427
|
+
if (!ground || !fs.existsSync(mapPath)) throw new Error("Coverage proposal requires the repository's persistent UI Map");
|
|
428
|
+
const map = JSON.parse(fs.readFileSync(mapPath, "utf8"));
|
|
429
|
+
const node = (map.nodes || []).find((candidate) => candidate.id === ground.id && candidate.status !== "proposed");
|
|
430
|
+
if (!node) throw new Error(`Coverage proposal UI Map node is stale or missing: ${ground.id}`);
|
|
431
|
+
if (target.navigation?.route && !(node.routes || []).some((route) => route.platform === target.platform && route.path === target.navigation.route && route.replayable === true)) {
|
|
432
|
+
throw new Error(`Coverage proposal route is stale in the persistent UI Map: ${target.navigation.route}`);
|
|
433
|
+
}
|
|
434
|
+
if (target.navigation?.mode === "ui-map-path") {
|
|
435
|
+
const currentNavigation = replayableUiMapNavigation(map, node.id, target.platform);
|
|
436
|
+
const expectedEdges = (target.navigation.steps || []).map((step) => step.edgeId);
|
|
437
|
+
const currentEdges = (currentNavigation.steps || []).map((step) => step.edgeId);
|
|
438
|
+
if (currentNavigation.status !== "replayable" || JSON.stringify(expectedEdges) !== JSON.stringify(currentEdges)) {
|
|
439
|
+
throw new Error(`Coverage proposal UI Map path is stale for ${node.name}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const releasePlan = JSON.parse(fs.readFileSync(targetPath, "utf8"));
|
|
443
|
+
if (releasePlan?.schemaVersion !== 1 || releasePlan?.kind !== "tapp-release-plan" || !Array.isArray(releasePlan.items)) throw new Error("Target release plan is not a Tapp release plan v1");
|
|
444
|
+
const duplicateIndex = releasePlan.items.findIndex((candidate) => candidate.id === proposed.id || candidate.name === proposed.name);
|
|
445
|
+
const adoption = {
|
|
446
|
+
source: "executed-pr-exploration",
|
|
447
|
+
targetId: target.id,
|
|
448
|
+
changedFiles: target.changedFiles || [],
|
|
449
|
+
adoptedAt: new Date().toISOString(),
|
|
450
|
+
};
|
|
451
|
+
let mode = "added";
|
|
452
|
+
let adopted = proposed;
|
|
453
|
+
let nextItems;
|
|
454
|
+
if (duplicateIndex >= 0) {
|
|
455
|
+
const duplicate = releasePlan.items[duplicateIndex];
|
|
456
|
+
const duplicateGround = (duplicate.groundedBy || []).find((entry) => entry.type === "ui-map-node");
|
|
457
|
+
if (duplicate.origin === "committed" || duplicateGround?.id !== ground.id || (duplicate.id !== proposed.id && duplicate.name !== proposed.name)) {
|
|
458
|
+
throw new Error(`Release plan already contains incompatible '${duplicate.name}' (${duplicate.id}); no existing item was changed`);
|
|
459
|
+
}
|
|
460
|
+
const grounding = mergeGroundingEvidence(duplicate.groundedBy, proposed.groundedBy);
|
|
461
|
+
adopted = { ...duplicate, groundedBy: grounding, adoption };
|
|
462
|
+
nextItems = releasePlan.items.map((candidate, index) => index === duplicateIndex ? adopted : candidate);
|
|
463
|
+
mode = "reconciled-existing";
|
|
464
|
+
} else {
|
|
465
|
+
if (proposal.operation.op === "reconcile-item") throw new Error(`Release plan item '${proposed.name}' disappeared before evidence reconciliation`);
|
|
466
|
+
adopted.adoption = adoption;
|
|
467
|
+
nextItems = [...releasePlan.items, adopted];
|
|
468
|
+
}
|
|
469
|
+
const next = { ...releasePlan, status: nextItems.some((candidate) => candidate.decision === "pending") ? "awaiting-review" : releasePlan.status, items: nextItems };
|
|
470
|
+
const temporary = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
|
|
471
|
+
fs.writeFileSync(temporary, JSON.stringify(next, null, 2) + "\n");
|
|
472
|
+
fs.renameSync(temporary, targetPath);
|
|
473
|
+
return { path: targetPath, item: adopted, plan: next, mode };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export function readChangedFilesFile(filePath) {
|
|
477
|
+
const absolute = path.resolve(filePath);
|
|
478
|
+
if (!fs.existsSync(absolute)) throw new Error(`changed-files file not found: ${absolute}`);
|
|
479
|
+
const raw = fs.readFileSync(absolute, "utf8");
|
|
480
|
+
const parsed = changedInput(raw);
|
|
481
|
+
parseChangedFiles(parsed); // Validate before returning bounded patch objects.
|
|
482
|
+
return parsed;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// NUL-delimited name-status output preserves spaces and both sides of renames.
|
|
486
|
+
// Refs are passed as argv and `--` terminates revision parsing.
|
|
487
|
+
export function changedFilesFromGit({ projectDir, base, head = "HEAD" }) {
|
|
488
|
+
if (!base) throw new Error("base ref is required");
|
|
489
|
+
const result = spawnSync("git", ["diff", "--name-status", "-z", "--find-renames", `${base}...${head}`, "--"], {
|
|
490
|
+
cwd: path.resolve(projectDir || process.cwd()),
|
|
491
|
+
encoding: "utf8",
|
|
492
|
+
});
|
|
493
|
+
if (result.status !== 0) {
|
|
494
|
+
throw new Error(`Could not read git diff ${base}...${head}: ${(result.stderr || result.stdout || "git exited non-zero").trim()}`);
|
|
495
|
+
}
|
|
496
|
+
const fields = result.stdout.split("\0");
|
|
497
|
+
const changes = [];
|
|
498
|
+
for (let index = 0; index < fields.length && fields[index];) {
|
|
499
|
+
const status = fields[index++];
|
|
500
|
+
if (/^[RC]/.test(status)) {
|
|
501
|
+
const previousPath = fields[index++];
|
|
502
|
+
const currentPath = fields[index++];
|
|
503
|
+
if (previousPath) changes.push(previousPath);
|
|
504
|
+
if (currentPath) changes.push(currentPath);
|
|
505
|
+
} else {
|
|
506
|
+
const file = fields[index++];
|
|
507
|
+
if (file) changes.push(file);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return parseChangedFiles(changes);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function diffPath(value) {
|
|
514
|
+
let file = String(value || "").trim();
|
|
515
|
+
if (!file || file === "/dev/null") return "";
|
|
516
|
+
if (file.startsWith('"') && file.endsWith('"')) {
|
|
517
|
+
try { file = JSON.parse(file); } catch {}
|
|
518
|
+
}
|
|
519
|
+
return posix(file.replace(/^[ab]\//, ""));
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export function changedSymbolEvidenceFromGit({ projectDir, base, head = "HEAD" }) {
|
|
523
|
+
if (!base) throw new Error("base ref is required");
|
|
524
|
+
const result = spawnSync("git", ["diff", "--unified=0", "--no-ext-diff", "--find-renames", `${base}...${head}`, "--"], {
|
|
525
|
+
cwd: path.resolve(projectDir || process.cwd()),
|
|
526
|
+
encoding: "utf8",
|
|
527
|
+
});
|
|
528
|
+
if (result.status !== 0) {
|
|
529
|
+
throw new Error(`Could not read git diff evidence ${base}...${head}: ${(result.stderr || result.stdout || "git exited non-zero").trim()}`);
|
|
530
|
+
}
|
|
531
|
+
const evidence = [];
|
|
532
|
+
let oldFile = "";
|
|
533
|
+
let currentFile = "";
|
|
534
|
+
let patch = [];
|
|
535
|
+
const flush = () => {
|
|
536
|
+
const file = currentFile || oldFile;
|
|
537
|
+
if (file && patch.length) evidence.push(patchEvidence(file, patch.join("\n")));
|
|
538
|
+
oldFile = "";
|
|
539
|
+
currentFile = "";
|
|
540
|
+
patch = [];
|
|
541
|
+
};
|
|
542
|
+
for (const line of result.stdout.split("\n")) {
|
|
543
|
+
if (line.startsWith("diff --git ")) { flush(); continue; }
|
|
544
|
+
if (line.startsWith("--- ")) { oldFile = diffPath(line.slice(4)); continue; }
|
|
545
|
+
if (line.startsWith("+++ ")) { currentFile = diffPath(line.slice(4)); continue; }
|
|
546
|
+
if (line.startsWith("@@") || patch.length) patch.push(line);
|
|
547
|
+
}
|
|
548
|
+
flush();
|
|
549
|
+
return mergeDiffEvidence(evidence);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function taskSymbolOwnership(task) {
|
|
553
|
+
return (task.coverage?.sourceSymbols || []).flatMap((item) => {
|
|
554
|
+
if (!item || typeof item !== "object" || Array.isArray(item) || typeof item.path !== "string" || !Array.isArray(item.symbols)) return [];
|
|
555
|
+
return [{ path: posix(item.path), symbols: [...new Set(item.symbols.filter((symbol) => typeof symbol === "string" && symbol).map(String))] }];
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function taskFileImpact(task, file, evidenceByFile) {
|
|
560
|
+
const ownership = taskSymbolOwnership(task).filter((item) => sourcePathMatches(file, item.path));
|
|
561
|
+
if (!ownership.length) return { affected: true, mode: "file" };
|
|
562
|
+
const evidence = evidenceByFile.get(posix(file));
|
|
563
|
+
if (!evidence?.precise) return { affected: true, mode: "file-fallback" };
|
|
564
|
+
const owned = new Set(ownership.flatMap((item) => item.symbols));
|
|
565
|
+
const symbols = evidence.symbols.filter((symbol) => owned.has(symbol));
|
|
566
|
+
return { affected: symbols.length > 0, mode: "symbol", symbols, ownership };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export async function buildPrContractPlan({
|
|
570
|
+
projectDir,
|
|
571
|
+
changedFiles,
|
|
572
|
+
changedSymbolEvidence = [],
|
|
573
|
+
platform = "",
|
|
574
|
+
mapPath = "",
|
|
575
|
+
contractPaths = [],
|
|
576
|
+
discoverContracts = true,
|
|
577
|
+
} = {}) {
|
|
578
|
+
const root = path.resolve(projectDir || process.cwd());
|
|
579
|
+
const changes = parseChangedFiles(changedFiles);
|
|
580
|
+
if (!changes.length) throw new Error("changedFiles must contain at least one repository-relative path");
|
|
581
|
+
const diffEvidence = mergeDiffEvidence([
|
|
582
|
+
...parseChangedDiffEvidence(changedFiles),
|
|
583
|
+
...(Array.isArray(changedSymbolEvidence) ? changedSymbolEvidence : []),
|
|
584
|
+
]);
|
|
585
|
+
const evidenceByFile = new Map(diffEvidence.map((item) => [item.file, item]));
|
|
586
|
+
const contractDir = path.join(root, ".autotap", "contracts");
|
|
587
|
+
const discovered = discoverContracts && fs.existsSync(contractDir)
|
|
588
|
+
? fs.readdirSync(contractDir).filter((name) => /\.contract\.(?:ts|mts|mjs|js|json)$/i.test(name)).map((name) => path.join(contractDir, name)) : [];
|
|
589
|
+
const files = [...new Set([...discovered, ...contractPaths.map((item) => path.resolve(root, item))])];
|
|
590
|
+
const contracts = [];
|
|
591
|
+
for (const file of files) {
|
|
592
|
+
const contract = await loadReleaseContractFile(file);
|
|
593
|
+
if (!platform || contract.platforms.includes(platform)) contracts.push(contract);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
let tasks = new Map();
|
|
597
|
+
const registrySource = files[0] || path.join(root, ".autotap", "contracts", "contract.ts");
|
|
598
|
+
try { tasks = loadTaskRegistry({ sourcePath: registrySource, projectDir: root }); } catch {}
|
|
599
|
+
let map = null;
|
|
600
|
+
const resolvedMapPath = mapPath ? path.resolve(root, mapPath) : path.join(root, ".autotap", "ui-map.json");
|
|
601
|
+
if (fs.existsSync(resolvedMapPath)) map = JSON.parse(fs.readFileSync(resolvedMapPath, "utf8"));
|
|
602
|
+
|
|
603
|
+
const impactedNodes = map ? map.nodes.filter((node) => matchedFiles(changes, node.sourcePaths || []).length) : [];
|
|
604
|
+
const impactedEdges = map ? map.edges.filter((edge) => matchedFiles(changes, edge.sourcePaths || []).length) : [];
|
|
605
|
+
const routeImpacts = map && (!platform || platform === "web")
|
|
606
|
+
? map.nodes.flatMap((node) => {
|
|
607
|
+
const matches = exactStaticRouteFiles(node, changes);
|
|
608
|
+
return matches.length ? [{ node, matches }] : [];
|
|
609
|
+
})
|
|
610
|
+
: [];
|
|
611
|
+
const selected = [];
|
|
612
|
+
const skipped = [];
|
|
613
|
+
const allOwnership = [];
|
|
614
|
+
const reviewedTaskSurfaceImpacts = [];
|
|
615
|
+
for (const task of tasks.values()) {
|
|
616
|
+
if (platform && platform !== "all" && !implementationFor(task, platform)) continue;
|
|
617
|
+
const taskRelative = relativeSource(task.__path, root);
|
|
618
|
+
const symbolOwnership = taskSymbolOwnership(task);
|
|
619
|
+
const ownership = [...new Set([...(task.coverage?.sourcePaths || []), ...symbolOwnership.map((item) => item.path)])];
|
|
620
|
+
allOwnership.push(...ownership);
|
|
621
|
+
const sourceFiles = matchedFiles(changes, ownership).filter((file) => taskFileImpact(task, file, evidenceByFile).affected);
|
|
622
|
+
const artifactChanged = changes.includes(taskRelative);
|
|
623
|
+
if (!map || (!sourceFiles.length && !artifactChanged)) continue;
|
|
624
|
+
const nodes = mapReferences(map.nodes || [], task.coverage?.nodes || []);
|
|
625
|
+
for (const node of nodes) reviewedTaskSurfaceImpacts.push({
|
|
626
|
+
node,
|
|
627
|
+
task: task.name,
|
|
628
|
+
taskPath: taskRelative,
|
|
629
|
+
files: [...new Set([...(artifactChanged ? [taskRelative] : []), ...sourceFiles])].sort(),
|
|
630
|
+
ownership,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
for (const contract of contracts) {
|
|
635
|
+
const reasons = [];
|
|
636
|
+
const contractRelative = relativeSource(contract.__path, root);
|
|
637
|
+
if (changes.includes(contractRelative)) reasons.push({ type: "contract-changed", files: [contractRelative] });
|
|
638
|
+
if (contract.criticality === "critical" || contract.policy?.always === true) reasons.push({ type: "always", detail: contract.criticality === "critical" ? "critical contract" : "policy.always" });
|
|
639
|
+
|
|
640
|
+
const directOwnership = contract.coverage?.sourcePaths || [];
|
|
641
|
+
allOwnership.push(...directOwnership);
|
|
642
|
+
const direct = matchedFiles(changes, directOwnership);
|
|
643
|
+
if (direct.length) reasons.push({ type: "contract-source", files: direct, ownership: directOwnership });
|
|
644
|
+
|
|
645
|
+
const contractTaskNames = [...new Set(contract.steps.filter((step) => step.task).map((step) => step.task))];
|
|
646
|
+
const relevantTasks = transitiveTasks(contractTaskNames, tasks);
|
|
647
|
+
for (const task of relevantTasks) {
|
|
648
|
+
const taskRelative = relativeSource(task.__path, root);
|
|
649
|
+
const symbolOwnership = taskSymbolOwnership(task);
|
|
650
|
+
const taskOwnership = [...new Set([...(task.coverage?.sourcePaths || []), ...symbolOwnership.map((item) => item.path)])];
|
|
651
|
+
allOwnership.push(...taskOwnership);
|
|
652
|
+
const owned = matchedFiles(changes, taskOwnership);
|
|
653
|
+
if (changes.includes(taskRelative)) reasons.push({ type: "task-changed", task: task.name, files: [taskRelative] });
|
|
654
|
+
const sourceFiles = [];
|
|
655
|
+
const symbolFiles = [];
|
|
656
|
+
const symbols = [];
|
|
657
|
+
const fallbackFiles = [];
|
|
658
|
+
for (const file of owned) {
|
|
659
|
+
const impact = taskFileImpact(task, file, evidenceByFile);
|
|
660
|
+
if (!impact.affected) continue;
|
|
661
|
+
if (impact.mode === "symbol") {
|
|
662
|
+
symbolFiles.push(file);
|
|
663
|
+
symbols.push(...impact.symbols);
|
|
664
|
+
} else {
|
|
665
|
+
sourceFiles.push(file);
|
|
666
|
+
if (impact.mode === "file-fallback") fallbackFiles.push(file);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
if (symbolFiles.length) reasons.push({
|
|
670
|
+
type: "task-symbol",
|
|
671
|
+
task: task.name,
|
|
672
|
+
files: [...new Set(symbolFiles)].sort(),
|
|
673
|
+
symbols: [...new Set(symbols)].sort(),
|
|
674
|
+
ownership: symbolOwnership,
|
|
675
|
+
});
|
|
676
|
+
if (sourceFiles.length) reasons.push({
|
|
677
|
+
type: "task-source",
|
|
678
|
+
task: task.name,
|
|
679
|
+
files: [...new Set(sourceFiles)].sort(),
|
|
680
|
+
ownership: taskOwnership,
|
|
681
|
+
...(fallbackFiles.length ? { precision: "file-fallback", detail: "Diff hunks were unavailable or not fully attributable to reviewed symbols" } : {}),
|
|
682
|
+
});
|
|
683
|
+
if (map) {
|
|
684
|
+
const taskNodes = new Set(mapReferences(map.nodes, task.coverage?.nodes).map((node) => node.id));
|
|
685
|
+
const taskEdges = new Set(task.coverage?.edges || []);
|
|
686
|
+
const nodeHits = impactedNodes.filter((node) => taskNodes.has(node.id) && matchedFiles(changes, node.sourcePaths || []).some((file) => taskFileImpact(task, file, evidenceByFile).affected));
|
|
687
|
+
const edgeHits = impactedEdges.filter((edge) => taskEdges.has(edge.id) && matchedFiles(changes, edge.sourcePaths || []).some((file) => taskFileImpact(task, file, evidenceByFile).affected));
|
|
688
|
+
if (nodeHits.length || edgeHits.length) reasons.push({ type: "task-ui-map", task: task.name, nodes: nodeHits.map((node) => node.id), edges: edgeHits.map((edge) => edge.id) });
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (map) {
|
|
693
|
+
const coveredNodes = new Set(mapReferences(map.nodes, contract.coverage?.nodes).map((node) => node.id));
|
|
694
|
+
const coveredEdges = new Set(contract.coverage?.edges || []);
|
|
695
|
+
const nodeHits = impactedNodes.filter((node) => coveredNodes.has(node.id));
|
|
696
|
+
const edgeHits = impactedEdges.filter((edge) => coveredEdges.has(edge.id));
|
|
697
|
+
if (nodeHits.length || edgeHits.length) reasons.push({ type: "contract-ui-map", nodes: nodeHits.map((node) => node.id), edges: edgeHits.map((edge) => edge.id) });
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const item = {
|
|
701
|
+
name: contract.name,
|
|
702
|
+
title: contract.title,
|
|
703
|
+
criticality: contract.criticality,
|
|
704
|
+
platforms: contract.platforms,
|
|
705
|
+
path: contractRelative,
|
|
706
|
+
intentSha256: sha256(contract.__path),
|
|
707
|
+
tasks: relevantTasks.map((task) => task.name),
|
|
708
|
+
taskPaths: relevantTasks.map((task) => relativeSource(task.__path, root)),
|
|
709
|
+
coverage: selectedCoverageFor(contract, relevantTasks, map),
|
|
710
|
+
reasons,
|
|
711
|
+
};
|
|
712
|
+
if (reasons.length) selected.push(item);
|
|
713
|
+
else skipped.push({ ...item, reasons: [{ type: "not-relevant", detail: "No reviewed source, Task, or UI Map ownership matched the diff" }] });
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const ownedArtifacts = [
|
|
717
|
+
...files.map((file) => relativeSource(file, root)),
|
|
718
|
+
...[...tasks.values()].map((task) => relativeSource(task.__path, root)),
|
|
719
|
+
...(map ? [relativeSource(resolvedMapPath, root)] : []),
|
|
720
|
+
];
|
|
721
|
+
const mapOwnership = map ? [...map.nodes, ...map.edges].flatMap((item) => item.sourcePaths || []) : [];
|
|
722
|
+
const routeMappedFiles = new Set(routeImpacts.flatMap((impact) => impact.matches.map((match) => match.file)));
|
|
723
|
+
const uncoveredChangedFiles = changes.filter((file) =>
|
|
724
|
+
![...allOwnership, ...mapOwnership].some((owner) => sourcePathMatches(file, owner)) && !ownedArtifacts.includes(file) && !routeMappedFiles.has(file));
|
|
725
|
+
const selectedNodeIds = new Set(selected.flatMap((item) => item.coverage.nodes));
|
|
726
|
+
const selectedEdgeIds = new Set(selected.flatMap((item) => item.coverage.edges));
|
|
727
|
+
const derivedNodeIds = new Set(routeImpacts.map((impact) => impact.node.id));
|
|
728
|
+
const uncoveredUiMap = {
|
|
729
|
+
nodes: [...new Set([
|
|
730
|
+
...impactedNodes.filter((node) => !selectedNodeIds.has(node.id)).map((node) => node.id),
|
|
731
|
+
...reviewedTaskSurfaceImpacts.filter((impact) => !selectedNodeIds.has(impact.node.id)).map((impact) => impact.node.id),
|
|
732
|
+
...routeImpacts.filter((impact) => !selectedNodeIds.has(impact.node.id)).map((impact) => impact.node.id),
|
|
733
|
+
])].sort(),
|
|
734
|
+
edges: impactedEdges.filter((edge) => !selectedEdgeIds.has(edge.id)).map((edge) => edge.id),
|
|
735
|
+
};
|
|
736
|
+
const targetEvidence = new Map();
|
|
737
|
+
for (const node of impactedNodes) {
|
|
738
|
+
targetEvidence.set(node.id, [{
|
|
739
|
+
type: "reviewed-source-ownership",
|
|
740
|
+
provenance: "human-authored-ui-map",
|
|
741
|
+
files: matchedFiles(changes, node.sourcePaths || []),
|
|
742
|
+
ownership: node.sourcePaths || [],
|
|
743
|
+
}]);
|
|
744
|
+
}
|
|
745
|
+
for (const impact of reviewedTaskSurfaceImpacts) {
|
|
746
|
+
const evidence = targetEvidence.get(impact.node.id) || [];
|
|
747
|
+
evidence.push({
|
|
748
|
+
type: "reviewed-task-source-ownership",
|
|
749
|
+
provenance: "human-authored-task",
|
|
750
|
+
task: impact.task,
|
|
751
|
+
taskPath: impact.taskPath,
|
|
752
|
+
files: impact.files,
|
|
753
|
+
ownership: impact.ownership,
|
|
754
|
+
});
|
|
755
|
+
targetEvidence.set(impact.node.id, evidence);
|
|
756
|
+
}
|
|
757
|
+
for (const impact of routeImpacts) {
|
|
758
|
+
const evidence = targetEvidence.get(impact.node.id) || [];
|
|
759
|
+
evidence.push({
|
|
760
|
+
type: "exact-static-route",
|
|
761
|
+
provenance: "source-derived",
|
|
762
|
+
files: [...new Set(impact.matches.map((match) => match.file))].sort(),
|
|
763
|
+
routes: [...new Set(impact.matches.map((match) => match.route))].sort(),
|
|
764
|
+
detail: "An observed replayable web route exactly matches a repository-relative changed file",
|
|
765
|
+
});
|
|
766
|
+
targetEvidence.set(impact.node.id, evidence);
|
|
767
|
+
}
|
|
768
|
+
for (const edge of impactedEdges) {
|
|
769
|
+
const node = map?.nodes.find((candidate) => candidate.id === edge.to);
|
|
770
|
+
if (!node || selectedEdgeIds.has(edge.id)) continue;
|
|
771
|
+
const evidence = targetEvidence.get(node.id) || [];
|
|
772
|
+
evidence.push({
|
|
773
|
+
type: "reviewed-edge-source-ownership",
|
|
774
|
+
provenance: "human-authored-ui-map",
|
|
775
|
+
files: matchedFiles(changes, edge.sourcePaths || []),
|
|
776
|
+
edgeIds: [edge.id],
|
|
777
|
+
ownership: edge.sourcePaths || [],
|
|
778
|
+
});
|
|
779
|
+
targetEvidence.set(node.id, evidence);
|
|
780
|
+
}
|
|
781
|
+
const allExplorationTargets = [...targetEvidence.entries()]
|
|
782
|
+
.map(([nodeId, evidence]) => explorationTargetForNode({
|
|
783
|
+
node: map?.nodes.find((node) => node.id === nodeId), evidence, platform: platform || "all", selectedNodeIds, map,
|
|
784
|
+
}))
|
|
785
|
+
.filter(Boolean)
|
|
786
|
+
.sort((a, b) => `${a.navigation.status}:${a.node.id}`.localeCompare(`${b.navigation.status}:${b.node.id}`));
|
|
787
|
+
const explorationLimit = platform === "web" ? 5 : 1;
|
|
788
|
+
const explorationTargets = allExplorationTargets.slice(0, explorationLimit);
|
|
789
|
+
const maintenanceCandidates = selected.flatMap((item) => {
|
|
790
|
+
const taskReasons = item.reasons.filter((reason) => ["task-changed", "task-symbol", "task-source", "task-ui-map"].includes(reason.type));
|
|
791
|
+
if (!taskReasons.length) return [];
|
|
792
|
+
const affected = new Set(taskReasons.map((reason) => reason.task).filter(Boolean));
|
|
793
|
+
const affectedTasks = [...tasks.values()].filter((task) => affected.has(task.name));
|
|
794
|
+
return [{
|
|
795
|
+
contract: item.name,
|
|
796
|
+
contractPath: item.path,
|
|
797
|
+
contractIntentSha256: item.intentSha256,
|
|
798
|
+
tasks: item.tasks.filter((task) => affected.has(task)),
|
|
799
|
+
taskPaths: item.taskPaths.filter((taskPath, index) => affected.has(item.tasks[index])),
|
|
800
|
+
selectorReferences: affectedTasks.flatMap((task) => selectorReferences(task, map, platform, root)),
|
|
801
|
+
changedFiles: [...new Set(taskReasons.flatMap((reason) => reason.files || []))].sort(),
|
|
802
|
+
reason: "Task implementation or its owned UI surface changed",
|
|
803
|
+
nextAction: "Replay the unchanged contract first; propose a reviewed Task patch only if evidence shows intentional UI maintenance rather than a behavioral regression.",
|
|
804
|
+
}];
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
return {
|
|
808
|
+
schemaVersion: 1,
|
|
809
|
+
platform: platform || "all",
|
|
810
|
+
changedFiles: changes,
|
|
811
|
+
changedSymbols: diffEvidence.flatMap((item) => item.symbols.map((symbol) => ({ file: item.file, symbol, basis: "diff-declaration-or-hunk-context" }))),
|
|
812
|
+
diffEvidence: diffEvidence.map(({ symbols: _symbols, ...item }) => item),
|
|
813
|
+
selected,
|
|
814
|
+
skipped,
|
|
815
|
+
impactedUiMap: {
|
|
816
|
+
nodes: [...new Set([...impactedNodes.map((node) => node.id), ...reviewedTaskSurfaceImpacts.map((impact) => impact.node.id)])].sort(),
|
|
817
|
+
edges: impactedEdges.map((edge) => edge.id),
|
|
818
|
+
},
|
|
819
|
+
derivedUiMapImpacts: {
|
|
820
|
+
nodes: [...derivedNodeIds].sort(),
|
|
821
|
+
evidence: routeImpacts.map((impact) => ({
|
|
822
|
+
nodeId: impact.node.id,
|
|
823
|
+
type: "exact-static-route",
|
|
824
|
+
files: [...new Set(impact.matches.map((match) => match.file))].sort(),
|
|
825
|
+
routes: [...new Set(impact.matches.map((match) => match.route))].sort(),
|
|
826
|
+
})),
|
|
827
|
+
advisoryOnly: true,
|
|
828
|
+
},
|
|
829
|
+
uncoveredUiMap,
|
|
830
|
+
uncoveredChangedFiles,
|
|
831
|
+
explorationTargets,
|
|
832
|
+
explorationTargetSummary: { planned: explorationTargets.length, omittedByLimit: allExplorationTargets.length - explorationTargets.length, limit: explorationLimit },
|
|
833
|
+
maintenanceCandidates,
|
|
834
|
+
policy: {
|
|
835
|
+
criticalAlways: true,
|
|
836
|
+
uncertainOwnership: "report-uncovered",
|
|
837
|
+
derivedRouteEvidence: "bounded-exploration-only",
|
|
838
|
+
maintenance: "fail-existing-contract-before-proposing-reviewable-task-change",
|
|
839
|
+
},
|
|
840
|
+
};
|
|
841
|
+
}
|