@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,557 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tapp CI gate — the report/verdict half of scripts/ci-gate.sh.
|
|
3
|
+
//
|
|
4
|
+
// Takes the OCQA markers a CI exploration produced (plus, optionally, a stored baseline and any
|
|
5
|
+
// flow replay logs), and turns them into: a human-readable console report, a GitHub Actions step
|
|
6
|
+
// summary (when GITHUB_STEP_SUMMARY is set), a machine-readable JSON report, and — the point —
|
|
7
|
+
// an exit code CI can gate a merge on.
|
|
8
|
+
//
|
|
9
|
+
// node src/ci-report.js --markers <ocqa-markers.txt>
|
|
10
|
+
// [--baseline <baseline.json>] # prior run's findings[] (or a full report)
|
|
11
|
+
// [--flow-log <log> ...] # run-flow.sh logs (repeatable)
|
|
12
|
+
// [--json-out <report.json>] # full report incl. findings for the next baseline
|
|
13
|
+
// [--md-out <report.md>] # rendered markdown (for a PR comment)
|
|
14
|
+
// [--html-dir <capture-dir>] # self-contained evidence index in the capture
|
|
15
|
+
// [--label <app-or-run-label>] # label shown in the HTML report
|
|
16
|
+
// [--pr-plan <plan.json>] # selected PR contract execution manifest
|
|
17
|
+
// [--project-dir <repo> --maintenance-url <url>]
|
|
18
|
+
// # optional disposable web patch replay
|
|
19
|
+
// [--fail-on <gate|blocked|any>] # default: gate
|
|
20
|
+
//
|
|
21
|
+
// Gate policy (--fail-on):
|
|
22
|
+
// gate fail when the run introduced NEW high/critical findings vs. the baseline
|
|
23
|
+
// (no baseline ⇒ falls back to `blocked`), or when any flow failed. The default:
|
|
24
|
+
// pre-existing debt doesn't block, regressions and broken flows do.
|
|
25
|
+
// blocked fail when the verdict is blocked/inconclusive, or when any flow failed.
|
|
26
|
+
// any fail on any finding at all, or any flow failure. Strictest.
|
|
27
|
+
import fs from "fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { buildQaReport, computeRegression, computeContentCollapse, computeReachabilityLoss } from "./report.js";
|
|
30
|
+
import { writeHtmlReport } from "./html-report.js";
|
|
31
|
+
import { buildUiMapFromMarkers, writeUiMap } from "./ui-map.js";
|
|
32
|
+
import { proposeSelectorMaintenance, validateWebMaintenanceProposal } from "./maintenance-proposal.js";
|
|
33
|
+
import { isBusinessUiMapNode, releasePlanCandidateFromUiMapNode } from "./application-model.js";
|
|
34
|
+
|
|
35
|
+
function parseArgs(argv) {
|
|
36
|
+
const args = { flowLogs: [], failOn: "gate" };
|
|
37
|
+
for (let i = 0; i < argv.length; i++) {
|
|
38
|
+
const a = argv[i];
|
|
39
|
+
if (a === "--markers") args.markers = argv[++i];
|
|
40
|
+
else if (a === "--baseline") args.baseline = argv[++i];
|
|
41
|
+
else if (a === "--flow-log" || a === "--scenario-log" || a === "--contract-log") args.flowLogs.push(argv[++i]);
|
|
42
|
+
else if (a === "--json-out") args.jsonOut = argv[++i];
|
|
43
|
+
else if (a === "--md-out") args.mdOut = argv[++i];
|
|
44
|
+
else if (a === "--html-dir") args.htmlDir = argv[++i];
|
|
45
|
+
else if (a === "--label") args.label = argv[++i];
|
|
46
|
+
else if (a === "--platform") args.platform = argv[++i];
|
|
47
|
+
else if (a === "--target-key") args.targetKey = argv[++i];
|
|
48
|
+
else if (a === "--fail-on") args.failOn = argv[++i];
|
|
49
|
+
else if (a === "--pr-plan") args.prPlan = argv[++i];
|
|
50
|
+
else if (a === "--project-dir") args.projectDir = argv[++i];
|
|
51
|
+
else if (a === "--maintenance-url") args.maintenanceUrl = argv[++i];
|
|
52
|
+
else {
|
|
53
|
+
console.error(`Unknown argument: ${a}`);
|
|
54
|
+
process.exit(2);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!args.markers) {
|
|
58
|
+
console.error("Required: --markers <ocqa-markers.txt>");
|
|
59
|
+
process.exit(2);
|
|
60
|
+
}
|
|
61
|
+
if (!["gate", "blocked", "any"].includes(args.failOn)) {
|
|
62
|
+
console.error(`--fail-on must be gate|blocked|any, got: ${args.failOn}`);
|
|
63
|
+
process.exit(2);
|
|
64
|
+
}
|
|
65
|
+
return args;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function validateMaintenancePlan(plan, args) {
|
|
69
|
+
if (!plan || plan.platform !== "web" || !args.projectDir || !args.maintenanceUrl) return plan;
|
|
70
|
+
let remaining = 3;
|
|
71
|
+
for (const candidate of plan.maintenanceCandidates || []) {
|
|
72
|
+
if (candidate.proposal?.kind !== "task-maintenance-patch") continue;
|
|
73
|
+
if (remaining <= 0) {
|
|
74
|
+
candidate.proposal.validation = { status: "not-run", passed: false, reason: "Per-run disposable maintenance validation limit (3) reached" };
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
remaining -= 1;
|
|
78
|
+
try {
|
|
79
|
+
const validation = await validateWebMaintenanceProposal({
|
|
80
|
+
proposal: candidate.proposal,
|
|
81
|
+
projectDir: args.projectDir,
|
|
82
|
+
url: args.maintenanceUrl,
|
|
83
|
+
evidenceDir: args.htmlDir ? path.join(args.htmlDir, "maintenance") : "",
|
|
84
|
+
});
|
|
85
|
+
if (args.htmlDir && validation.evidence?.screenshotDir) {
|
|
86
|
+
validation.evidence.artifactPath = path.relative(args.htmlDir, validation.evidence.screenshotDir).replaceAll(path.sep, "/");
|
|
87
|
+
}
|
|
88
|
+
candidate.proposal = {
|
|
89
|
+
...candidate.proposal,
|
|
90
|
+
status: validation.passed ? "validated-awaiting-review" : "validation-failed",
|
|
91
|
+
validation,
|
|
92
|
+
};
|
|
93
|
+
} catch (error) {
|
|
94
|
+
candidate.proposal.validation = { status: "not-run", passed: false, reason: error.message || String(error) };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return plan;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Port of flow_lib.py report() / FlowRunnerService.parseReport — kept in sync deliberately.
|
|
101
|
+
function parseFlowLog(logPath) {
|
|
102
|
+
const name = logPath.split("/").pop().replace(/\.log$/, "");
|
|
103
|
+
if (!fs.existsSync(logPath)) return { name, passed: false, total: 0, failed: 0, steps: [], missing: true };
|
|
104
|
+
const steps = [];
|
|
105
|
+
let total = 0, executed = 0, failed = 0, passed = false, sawResult = false, flowName = null, kind = "flow", contract = "", criticality = "";
|
|
106
|
+
for (const raw of fs.readFileSync(logPath, "utf8").split(/\r?\n/)) {
|
|
107
|
+
const line = raw.trim();
|
|
108
|
+
if (line.startsWith("OCQA_FLOW_STEP:{")) {
|
|
109
|
+
try {
|
|
110
|
+
const o = JSON.parse(line.slice("OCQA_FLOW_STEP:".length));
|
|
111
|
+
steps.push({ status: o.status || "?", action: o.action || "", target: o.target || "", detail: o.detail || "", ...(o.actor ? { actor: o.actor } : {}), ...(o.task ? { task: o.task } : {}), ...(o.contract ? { contract: o.contract } : {}) });
|
|
112
|
+
} catch { /* ignore malformed */ }
|
|
113
|
+
} else if (line.startsWith("OCQA_FLOW_RESULT:{")) {
|
|
114
|
+
try {
|
|
115
|
+
const o = JSON.parse(line.slice("OCQA_FLOW_RESULT:".length));
|
|
116
|
+
total = o.total ?? steps.length;
|
|
117
|
+
executed = o.executed ?? steps.length;
|
|
118
|
+
failed = o.failed ?? steps.filter((s) => s.status === "fail").length;
|
|
119
|
+
passed = o.passed ?? (failed === 0 && steps.length > 0);
|
|
120
|
+
if (o.name) flowName = o.name;
|
|
121
|
+
if (o.kind) kind = o.kind;
|
|
122
|
+
if (o.contract) contract = o.contract;
|
|
123
|
+
if (o.criticality) criticality = o.criticality;
|
|
124
|
+
sawResult = true;
|
|
125
|
+
} catch { /* ignore malformed */ }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!sawResult) {
|
|
129
|
+
total = steps.length;
|
|
130
|
+
executed = steps.length;
|
|
131
|
+
failed = steps.filter((s) => s.status === "fail").length;
|
|
132
|
+
passed = steps.length > 0 && failed === 0;
|
|
133
|
+
}
|
|
134
|
+
return { name: flowName || name, kind, ...(contract ? { contract, criticality } : {}), passed, total, executed, failed, steps };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function loadBaseline(baselinePath) {
|
|
138
|
+
if (!baselinePath) return null;
|
|
139
|
+
if (!fs.existsSync(baselinePath)) return null; // first run: no baseline yet is not an error
|
|
140
|
+
const parsed = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
|
|
141
|
+
// Accept either a bare findings[] or a full report JSON (as written by --json-out).
|
|
142
|
+
// Keep the full report when available — the gate needs the baseline's inconclusive flag.
|
|
143
|
+
if (Array.isArray(parsed)) return { findings: parsed, inconclusive: false, screenElementCounts: null, screens: null, actionsPerformed: 0 };
|
|
144
|
+
return {
|
|
145
|
+
findings: parsed.findings || [],
|
|
146
|
+
inconclusive: !!parsed.inconclusive,
|
|
147
|
+
screenElementCounts: parsed.screenElementCounts || null,
|
|
148
|
+
screens: parsed.screens || null,
|
|
149
|
+
actionsPerformed: parsed.actionsPerformed || 0,
|
|
150
|
+
platform: parsed.baselineIdentity?.platform || parsed.platform || null,
|
|
151
|
+
targetKey: parsed.baselineIdentity?.targetId || parsed.targetKey || null,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function loadPrPlan(planPath) {
|
|
156
|
+
if (!planPath) return null;
|
|
157
|
+
if (!fs.existsSync(planPath)) throw new Error(`PR plan not found: ${planPath}`);
|
|
158
|
+
const plan = JSON.parse(fs.readFileSync(planPath, "utf8"));
|
|
159
|
+
if (plan?.schemaVersion !== 1 || !Array.isArray(plan.selected) || !Array.isArray(plan.changedFiles)) {
|
|
160
|
+
throw new Error("PR plan must be a UI-aware Tapp PR plan v1");
|
|
161
|
+
}
|
|
162
|
+
return plan;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function prTargetMarkers(markersPath) {
|
|
166
|
+
const markers = new Map();
|
|
167
|
+
if (!markersPath || !fs.existsSync(markersPath)) return markers;
|
|
168
|
+
for (const line of fs.readFileSync(markersPath, "utf8").split(/\r?\n/)) {
|
|
169
|
+
const match = line.trim().match(/^OCQA_PR_TARGET:(\{.*\})$/);
|
|
170
|
+
if (!match) continue;
|
|
171
|
+
try {
|
|
172
|
+
const value = JSON.parse(match[1]);
|
|
173
|
+
if (!["observed", "failed"].includes(value.status)) continue;
|
|
174
|
+
if (typeof value.targetId === "string" && value.targetId) markers.set(`id:${value.targetId}`, value);
|
|
175
|
+
if (typeof value.route === "string" && value.route) markers.set(`route:${value.route}`, value);
|
|
176
|
+
} catch {}
|
|
177
|
+
}
|
|
178
|
+
return markers;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function controlKeys(control) {
|
|
182
|
+
return new Set([control?.id, control?.semanticKey, control?.label, ...(control?.selectors || []).map((selector) => selector.value)]
|
|
183
|
+
.filter(Boolean).map((value) => String(value).toLowerCase()));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function sameControl(left, right) {
|
|
187
|
+
const leftKeys = controlKeys(left);
|
|
188
|
+
return [...controlKeys(right)].some((key) => leftKeys.has(key));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function targetCoverageDisposition(target, currentNode, projectDir = "") {
|
|
192
|
+
if (!currentNode || !isBusinessUiMapNode(currentNode)) return {};
|
|
193
|
+
const item = releasePlanCandidateFromUiMapNode(currentNode, [target.platform], "customer");
|
|
194
|
+
item.groundedBy.push({
|
|
195
|
+
type: "pr-exploration",
|
|
196
|
+
targetId: target.id,
|
|
197
|
+
changedFiles: target.changedFiles || [],
|
|
198
|
+
route: target.navigation?.route || "",
|
|
199
|
+
navigationMode: target.navigation?.mode || (target.navigation?.route ? "route" : "unknown"),
|
|
200
|
+
edgeIds: (target.navigation?.steps || []).map((step) => step.edgeId),
|
|
201
|
+
provenance: "runtime-observed",
|
|
202
|
+
});
|
|
203
|
+
if (projectDir) {
|
|
204
|
+
const releasePlanPath = path.join(path.resolve(projectDir), ".autotap", "release-plan.json");
|
|
205
|
+
try {
|
|
206
|
+
const releasePlan = JSON.parse(fs.readFileSync(releasePlanPath, "utf8"));
|
|
207
|
+
const existing = (releasePlan.items || []).find((candidate) => candidate.id === item.id || candidate.name === item.name ||
|
|
208
|
+
(candidate.groundedBy || []).some((ground) => ground.type === "ui-map-node" && ground.id === currentNode.id));
|
|
209
|
+
if (existing) return {
|
|
210
|
+
existingReleasePlanItem: {
|
|
211
|
+
path: ".autotap/release-plan.json",
|
|
212
|
+
id: existing.id,
|
|
213
|
+
name: existing.name,
|
|
214
|
+
decision: existing.decision,
|
|
215
|
+
origin: existing.origin,
|
|
216
|
+
detail: "The repository release plan already records this grounded UI Map coverage decision; Tapp preserved it instead of proposing a duplicate.",
|
|
217
|
+
},
|
|
218
|
+
coverageProposal: {
|
|
219
|
+
kind: "release-plan-item-proposal",
|
|
220
|
+
status: "matches-existing-release-plan",
|
|
221
|
+
autoApply: false,
|
|
222
|
+
targetPath: ".autotap/release-plan.json",
|
|
223
|
+
operation: { op: "reconcile-item", item },
|
|
224
|
+
reason: "Fresh PR runtime and changed-file evidence can be attached to the existing grounded item only through explicit adoption; its current human decision is preserved.",
|
|
225
|
+
requiredValidation: "After explicit evidence reconciliation, keep the normal review, generation, deterministic replay, and promotion requirements.",
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
} catch {}
|
|
229
|
+
}
|
|
230
|
+
return { coverageProposal: {
|
|
231
|
+
kind: "release-plan-item-proposal",
|
|
232
|
+
status: "awaiting-explicit-adoption",
|
|
233
|
+
autoApply: false,
|
|
234
|
+
targetPath: ".autotap/release-plan.json",
|
|
235
|
+
operation: { op: "add-item", item },
|
|
236
|
+
reason: `The changed ${currentNode.name} surface was observed in this PR run but is not covered by a selected release contract.`,
|
|
237
|
+
requiredValidation: "Explicitly adopt and review this item, generate reusable UI-Map-backed Tasks, then replay the resulting contract against the real target before promotion.",
|
|
238
|
+
} };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function enrichExplorationTargets(plan, currentUiMap, markersPath, projectDir = "") {
|
|
242
|
+
const markers = prTargetMarkers(markersPath);
|
|
243
|
+
return (plan.explorationTargets || []).map((target) => {
|
|
244
|
+
if (target.navigation?.status !== "replayable") return { ...target, status: "blocked", execution: { status: "blocked", conclusive: false, reason: target.navigation?.reason || "No replayable navigation reference" } };
|
|
245
|
+
const marker = markers.get(`id:${target.id}`) || (target.navigation.route ? markers.get(`route:${target.navigation.route}`) : null);
|
|
246
|
+
if (!marker) return { ...target, status: "not-reached", execution: { status: "not-reached", conclusive: false, reason: "The bounded exploration run did not observe its planned UI Map target" } };
|
|
247
|
+
if (marker.status === "failed") return { ...target, status: "failed", execution: { status: "failed", conclusive: false, error: marker.error || "Navigation failed" } };
|
|
248
|
+
const currentNode = (currentUiMap?.nodes || []).find((node) => node.id === target.node?.id) || (currentUiMap?.nodes || []).find((node) =>
|
|
249
|
+
(node.routes || []).some((route) => route.platform === target.platform && route.path === target.navigation.route) ||
|
|
250
|
+
node.semanticKey === target.node?.semanticKey || node.name === marker.screen);
|
|
251
|
+
const baselineControls = target.baselineControls || [];
|
|
252
|
+
const currentControls = currentNode?.controls || [];
|
|
253
|
+
const notObserved = baselineControls.filter((baseline) => !currentControls.some((current) => sameControl(baseline, current)));
|
|
254
|
+
const added = currentControls.filter((current) => !baselineControls.some((baseline) => sameControl(baseline, current)));
|
|
255
|
+
const coverageDisposition = target.coverage?.status === "not-covered-by-selected-contract" ? targetCoverageDisposition(target, currentNode, projectDir) : {};
|
|
256
|
+
return {
|
|
257
|
+
...target,
|
|
258
|
+
status: "observed",
|
|
259
|
+
execution: {
|
|
260
|
+
status: "observed",
|
|
261
|
+
conclusive: true,
|
|
262
|
+
navigation: {
|
|
263
|
+
mode: target.navigation.mode || (target.navigation.route ? "route" : "unknown"),
|
|
264
|
+
...(target.navigation.route ? { route: target.navigation.route } : {}),
|
|
265
|
+
edgeIds: (target.navigation.steps || []).map((step) => step.edgeId),
|
|
266
|
+
},
|
|
267
|
+
screen: marker.screen || currentNode?.name || target.node?.name,
|
|
268
|
+
currentNodeId: currentNode?.id || null,
|
|
269
|
+
evidence: { marker: "OCQA_PR_TARGET", uiMapArtifact: currentUiMap ? "ui-map.json" : null },
|
|
270
|
+
controls: {
|
|
271
|
+
baseline: baselineControls.length,
|
|
272
|
+
current: currentControls.length,
|
|
273
|
+
retained: baselineControls.length - notObserved.length,
|
|
274
|
+
notObserved: notObserved.map((control) => ({ id: control.id, label: control.label })),
|
|
275
|
+
added: added.map((control) => ({ id: control.id, label: control.label })),
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
...coverageDisposition,
|
|
279
|
+
};
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function enrichPrPlan(plan, contracts, currentUiMap = null, markersPath = "", projectDir = "") {
|
|
284
|
+
if (!plan) return null;
|
|
285
|
+
const results = new Map(contracts.map((contract) => [contract.contract, contract]));
|
|
286
|
+
const selected = plan.selected.map((item) => {
|
|
287
|
+
const result = results.get(item.name);
|
|
288
|
+
const firstFailure = result?.steps?.find((step) => step.status === "fail");
|
|
289
|
+
return {
|
|
290
|
+
...item,
|
|
291
|
+
execution: result ? {
|
|
292
|
+
status: result.passed ? "passed" : "failed",
|
|
293
|
+
passed: result.passed,
|
|
294
|
+
executed: result.executed,
|
|
295
|
+
total: result.total,
|
|
296
|
+
...(firstFailure ? { firstFailure } : {}),
|
|
297
|
+
} : { status: "not-run", passed: false, executed: 0, total: 0 },
|
|
298
|
+
};
|
|
299
|
+
});
|
|
300
|
+
const selectedByName = new Map(selected.map((item) => [item.name, item]));
|
|
301
|
+
const maintenanceCandidates = (plan.maintenanceCandidates || []).map((candidate) => {
|
|
302
|
+
const selectedContract = selectedByName.get(candidate.contract);
|
|
303
|
+
const status = selectedContract?.execution?.status || "not-run";
|
|
304
|
+
const selectorPatch = status === "failed" ? proposeSelectorMaintenance({ candidate, execution: selectedContract?.execution, currentMap: currentUiMap, platform: plan.platform }) : null;
|
|
305
|
+
return {
|
|
306
|
+
...candidate,
|
|
307
|
+
disposition: status === "passed" ? "not-required" : status === "failed" ? "review-required" : "execution-missing",
|
|
308
|
+
replayEvidence: selectedContract?.execution || { status: "not-run", passed: false, executed: 0, total: 0 },
|
|
309
|
+
proposal: selectorPatch || (status === "failed" ? {
|
|
310
|
+
kind: "task-maintenance-candidate",
|
|
311
|
+
status: "unclassified",
|
|
312
|
+
editablePaths: candidate.taskPaths || [],
|
|
313
|
+
preservedIntent: [candidate.contract],
|
|
314
|
+
reason: "A changed Task-owned surface and the unchanged contract both have failure evidence. Determine whether this is an intentional UI change or a product regression before authoring a patch.",
|
|
315
|
+
requiredValidation: "Replay the unchanged business contract after the reviewed Task-only patch; do not weaken or silently rewrite contract assertions.",
|
|
316
|
+
} : null),
|
|
317
|
+
};
|
|
318
|
+
});
|
|
319
|
+
const explorationTargets = enrichExplorationTargets(plan, currentUiMap, markersPath, projectDir);
|
|
320
|
+
const counts = {
|
|
321
|
+
selected: selected.length,
|
|
322
|
+
passed: selected.filter((item) => item.execution.status === "passed").length,
|
|
323
|
+
failed: selected.filter((item) => item.execution.status === "failed").length,
|
|
324
|
+
notRun: selected.filter((item) => item.execution.status === "not-run").length,
|
|
325
|
+
skipped: plan.skipped?.length || 0,
|
|
326
|
+
unknownFiles: plan.uncoveredChangedFiles?.length || 0,
|
|
327
|
+
mappedCoverageGaps: (plan.uncoveredUiMap?.nodes?.length || 0) + (plan.uncoveredUiMap?.edges?.length || 0),
|
|
328
|
+
explorationPlanned: explorationTargets.length,
|
|
329
|
+
explorationObserved: explorationTargets.filter((target) => target.execution?.status === "observed").length,
|
|
330
|
+
explorationFailed: explorationTargets.filter((target) => ["failed", "not-reached"].includes(target.execution?.status)).length,
|
|
331
|
+
explorationBlocked: explorationTargets.filter((target) => target.execution?.status === "blocked").length,
|
|
332
|
+
coverageProposals: explorationTargets.filter((target) => target.coverageProposal?.status === "awaiting-explicit-adoption").length,
|
|
333
|
+
evidenceReconciliations: explorationTargets.filter((target) => target.coverageProposal?.status === "matches-existing-release-plan").length,
|
|
334
|
+
existingPlanItems: explorationTargets.filter((target) => target.existingReleasePlanItem).length,
|
|
335
|
+
};
|
|
336
|
+
return { ...plan, selected, explorationTargets, maintenanceCandidates, execution: counts };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const VERDICT_BADGE = { ready: "🟢 SHIP-READY", caution: "🟡 CAUTION", blocked: "🔴 BLOCKED" };
|
|
340
|
+
const SEV_ICON = { critical: "🟥", high: "🟧", medium: "🟨", low: "🟩" };
|
|
341
|
+
|
|
342
|
+
function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan, gate) {
|
|
343
|
+
const lines = [];
|
|
344
|
+
lines.push(`## tapp release check — ${VERDICT_BADGE[report.verdict] || report.verdict}`);
|
|
345
|
+
lines.push("");
|
|
346
|
+
lines.push(report.headline);
|
|
347
|
+
lines.push("");
|
|
348
|
+
lines.push(`**release score ${report.confidence}/100** · ${report.screensExplored} screens · ${report.actionsPerformed} actions · ${report.findingCounts.total} finding(s)`);
|
|
349
|
+
if (report.uiMap) lines.push(`**UI Map:** ${report.uiMap.nodeCount} states · ${report.uiMap.edgeCount} transitions · ${report.uiMap.controlCount} semantic controls`);
|
|
350
|
+
if (report.findings.length) {
|
|
351
|
+
lines.push("");
|
|
352
|
+
lines.push("| | Severity | Finding | Screen |");
|
|
353
|
+
lines.push("|---|---|---|---|");
|
|
354
|
+
for (const f of report.findings) {
|
|
355
|
+
lines.push(`| ${SEV_ICON[f.severity] || ""} | ${f.severity} | ${f.title} | ${f.screen ?? "—"} |`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (regression) {
|
|
359
|
+
const g = regression.gate;
|
|
360
|
+
lines.push("");
|
|
361
|
+
lines.push(`### Since baseline — ${g.failed ? "🔴 regression gate FAILED" : "🟢 regression gate passed"}`);
|
|
362
|
+
lines.push(`+${regression.counts.new} new · ${regression.counts.persisting} persisting · ${regression.counts.resolved} resolved` +
|
|
363
|
+
(g.failed ? ` — **${g.newCritical} new critical, ${g.newHigh} new high**` : ""));
|
|
364
|
+
for (const f of regression.newFindings) {
|
|
365
|
+
lines.push(`- NEW ${SEV_ICON[f.severity] || ""} ${f.severity}: ${f.title} (${f.screen ?? "—"})`);
|
|
366
|
+
}
|
|
367
|
+
} else if (gate.policy === "gate") {
|
|
368
|
+
lines.push("");
|
|
369
|
+
lines.push("### Baseline — 🟡 not active yet");
|
|
370
|
+
lines.push("No baseline was supplied, so this run used the blocked/inconclusive fallback. Save this report as a baseline—or run the GitHub Action on the default branch—to activate new-regression gating.");
|
|
371
|
+
}
|
|
372
|
+
if (prPlan) {
|
|
373
|
+
lines.push("");
|
|
374
|
+
const counts = prPlan.execution;
|
|
375
|
+
lines.push(`### PR release plan — ${counts.failed || counts.notRun ? "🔴 incomplete" : "🟢 executed"}`);
|
|
376
|
+
lines.push(`${counts.selected} selected · ${counts.skipped} skipped · ${counts.unknownFiles} unknown changed file(s) · ${counts.mappedCoverageGaps} mapped coverage gap(s)`);
|
|
377
|
+
for (const contract of prPlan.selected) {
|
|
378
|
+
const icon = contract.execution.status === "passed" ? "✅" : contract.execution.status === "failed" ? "❌" : "⚠️";
|
|
379
|
+
lines.push(`- ${icon} **${contract.name}** — ${contract.execution.status}; ${contract.reasons.map((reason) => reason.type).join(", ")}`);
|
|
380
|
+
}
|
|
381
|
+
for (const file of prPlan.uncoveredChangedFiles || []) lines.push(`- ⚠️ Unknown ownership: \`${file}\``);
|
|
382
|
+
for (const node of prPlan.uncoveredUiMap?.nodes || []) lines.push(`- ⚠️ Changed mapped state lacks a selected contract: \`${node}\``);
|
|
383
|
+
for (const edge of prPlan.uncoveredUiMap?.edges || []) lines.push(`- ⚠️ Changed mapped transition lacks a selected contract: \`${edge}\``);
|
|
384
|
+
for (const target of prPlan.explorationTargets || []) {
|
|
385
|
+
const icon = target.execution?.status === "observed" ? "🔎" : target.execution?.status === "blocked" ? "⚠️" : "❌";
|
|
386
|
+
const navigation = target.navigation?.route
|
|
387
|
+
? ` at \`${target.navigation.route}\``
|
|
388
|
+
: target.navigation?.mode === "ui-map-path" ? ` through ${(target.navigation.steps || []).length} observed map edge(s)` : "";
|
|
389
|
+
lines.push(`- ${icon} PR exploration **${target.node?.name || target.id}** — ${target.execution?.status || target.status}${navigation}`);
|
|
390
|
+
if (target.coverageProposal?.status === "awaiting-explicit-adoption") lines.push(` - Reviewable coverage proposal ready; never auto-applied. Next: \`tapp pr adopt <pr-plan.json> --item ${target.id} --project-dir .\``);
|
|
391
|
+
if (target.existingReleasePlanItem) {
|
|
392
|
+
lines.push(` - Existing release-plan item \`${target.existingReleasePlanItem.name}\` remains ${target.existingReleasePlanItem.decision}; no duplicate or decision change was made.`);
|
|
393
|
+
lines.push(` - Optional explicit evidence reconciliation: \`tapp pr adopt <pr-plan.json> --item ${target.id} --project-dir .\``);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
for (const candidate of prPlan.maintenanceCandidates || []) {
|
|
397
|
+
if (candidate.disposition === "review-required") {
|
|
398
|
+
const patch = candidate.proposal?.kind === "task-maintenance-patch" ? candidate.proposal.operations?.[0] : null;
|
|
399
|
+
lines.push(`- 🛠️ **${candidate.contract}** — Task maintenance review required; existing contract remains failed and unchanged`);
|
|
400
|
+
if (patch) {
|
|
401
|
+
const validation = candidate.proposal.validation;
|
|
402
|
+
const proof = validation?.passed
|
|
403
|
+
? `validated ${validation.executed}/${validation.total} in a disposable real-target replay${validation.evidence?.artifactPath ? ` (evidence: \`${validation.evidence.artifactPath}\`)` : ""}; awaiting review and never auto-applied`
|
|
404
|
+
: `unvalidated and never auto-applied`;
|
|
405
|
+
lines.push(` - Proposed Task-only selector patch: \`${patch.taskPath}${patch.pointer}\` · \`${patch.before}\` → \`${patch.after}\` (${patch.selector.kind}); ${proof}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (flows.length) {
|
|
411
|
+
lines.push("");
|
|
412
|
+
const failedFlows = flows.filter((f) => !f.passed);
|
|
413
|
+
lines.push(`### Flows — ${failedFlows.length ? `🔴 ${failedFlows.length}/${flows.length} failed` : `🟢 ${flows.length}/${flows.length} passed`}`);
|
|
414
|
+
for (const f of flows) {
|
|
415
|
+
const firstFail = f.steps.find((s) => s.status === "fail");
|
|
416
|
+
lines.push(`- ${f.passed ? "✅" : "❌"} **${f.name}** — ${f.steps.filter((s) => s.status === "pass").length}/${f.total} steps` +
|
|
417
|
+
(firstFail ? ` — failed at \`${firstFail.action} ${firstFail.target}\`${firstFail.detail ? `: ${firstFail.detail}` : ""}` : "") +
|
|
418
|
+
(f.missing ? " — log missing (flow did not run)" : ""));
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (scenarios.length) {
|
|
422
|
+
lines.push("");
|
|
423
|
+
const failedScenarios = scenarios.filter((scenario) => !scenario.passed);
|
|
424
|
+
lines.push(`### Multi-actor Scenarios — ${failedScenarios.length ? `🔴 ${failedScenarios.length}/${scenarios.length} failed` : `🟢 ${scenarios.length}/${scenarios.length} passed`}`);
|
|
425
|
+
for (const scenario of scenarios) {
|
|
426
|
+
const firstFail = scenario.steps.find((step) => step.status === "fail");
|
|
427
|
+
const actors = [...new Set(scenario.steps.map((step) => step.actor).filter((actor) => actor && !["setup", "teardown"].includes(actor)))];
|
|
428
|
+
lines.push(`- ${scenario.passed ? "✅" : "❌"} **${scenario.name}** — ${actors.length} isolated actor(s) · ${scenario.executed}/${scenario.total} steps executed` +
|
|
429
|
+
(firstFail ? ` — failed for **${firstFail.actor || "scenario"}** at \`${firstFail.action} ${firstFail.target}\`${firstFail.detail ? `: ${firstFail.detail}` : ""}` : ""));
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (contracts.length) {
|
|
433
|
+
lines.push("");
|
|
434
|
+
const failedContracts = contracts.filter((contract) => !contract.passed);
|
|
435
|
+
lines.push(`### Release Contracts — ${failedContracts.length ? `🔴 ${failedContracts.length}/${contracts.length} failed` : `🟢 ${contracts.length}/${contracts.length} passed`}`);
|
|
436
|
+
for (const contract of contracts) {
|
|
437
|
+
const firstFail = contract.steps.find((step) => step.status === "fail");
|
|
438
|
+
const actors = [...new Set(contract.steps.map((step) => step.actor).filter((actor) => actor && !["setup", "teardown"].includes(actor)))];
|
|
439
|
+
lines.push(`- ${contract.passed ? "✅" : "❌"} **${contract.name}** — ${contract.criticality || "unspecified"} · ${actors.length || 1} actor(s) · ${contract.executed}/${contract.total} steps executed` +
|
|
440
|
+
(firstFail ? ` — failed${firstFail.actor ? ` for **${firstFail.actor}**` : ""} at \`${firstFail.action} ${firstFail.target}\`${firstFail.task ? ` (Task \`${firstFail.task}\`)` : ""}${firstFail.detail ? `: ${firstFail.detail}` : ""}` : ""));
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
lines.push("");
|
|
444
|
+
lines.push(`**Gate (${gate.policy}): ${gate.failed ? "🔴 FAIL" : "🟢 PASS"}**${gate.reasons.length ? " — " + gate.reasons.join("; ") : ""}`);
|
|
445
|
+
return lines.join("\n");
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const args = parseArgs(process.argv.slice(2));
|
|
449
|
+
const report = buildQaReport(args.markers, { platform: args.platform || "ios" });
|
|
450
|
+
if (!report) {
|
|
451
|
+
console.error(`No OCQA markers found at ${args.markers} — the exploration did not run.`);
|
|
452
|
+
process.exit(1);
|
|
453
|
+
}
|
|
454
|
+
let currentUiMap = null;
|
|
455
|
+
if (args.htmlDir) {
|
|
456
|
+
try {
|
|
457
|
+
const map = buildUiMapFromMarkers({ markersPath: args.markers, platform: args.platform || "ios", target: args.label || "", runId: path.basename(args.htmlDir) });
|
|
458
|
+
currentUiMap = map;
|
|
459
|
+
const mapPath = writeUiMap(path.join(args.htmlDir, "ui-map.json"), map);
|
|
460
|
+
report.uiMap = {
|
|
461
|
+
schemaVersion: map.schemaVersion,
|
|
462
|
+
path: mapPath,
|
|
463
|
+
nodeCount: map.nodes.length,
|
|
464
|
+
edgeCount: map.edges.length,
|
|
465
|
+
controlCount: map.nodes.reduce((total, node) => total + node.controls.length, 0),
|
|
466
|
+
};
|
|
467
|
+
} catch (error) {
|
|
468
|
+
report.uiMap = { error: error.message || String(error) };
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const baseline = loadBaseline(args.baseline);
|
|
472
|
+
if (baseline?.platform && baseline.platform !== (args.platform || "ios")) {
|
|
473
|
+
console.error(`Baseline platform '${baseline.platform}' does not match current platform '${args.platform || "ios"}'`);
|
|
474
|
+
process.exit(2);
|
|
475
|
+
}
|
|
476
|
+
if (baseline?.targetKey && baseline.targetKey !== args.targetKey) {
|
|
477
|
+
console.error(`Baseline target '${baseline.targetKey}' does not match current target '${args.targetKey || "unspecified"}'; pass the application-model target id with --target-key`);
|
|
478
|
+
process.exit(2);
|
|
479
|
+
}
|
|
480
|
+
if (args.targetKey) report.targetKey = args.targetKey;
|
|
481
|
+
// Content-collapse findings are cross-run by nature — merge them into the current findings
|
|
482
|
+
// BEFORE the regression diff so they count as new-vs-baseline and drive the gate normally.
|
|
483
|
+
const collapsed = [
|
|
484
|
+
...computeContentCollapse(report.screenElementCounts, baseline?.screenElementCounts),
|
|
485
|
+
...computeReachabilityLoss(report, baseline),
|
|
486
|
+
];
|
|
487
|
+
if (collapsed.length) {
|
|
488
|
+
report.findings.push(...collapsed);
|
|
489
|
+
report.findingCounts.high += collapsed.length;
|
|
490
|
+
report.findingCounts.total += collapsed.length;
|
|
491
|
+
// Keep the displayed verdict consistent with the merged findings (same scoring as report.js:
|
|
492
|
+
// high costs 10 confidence; any high caps the verdict at caution).
|
|
493
|
+
report.confidence = Math.max(0, report.confidence - collapsed.length * 10);
|
|
494
|
+
report.releaseScore = report.confidence;
|
|
495
|
+
if (report.verdict === "ready") report.verdict = report.confidence < 50 ? "blocked" : "caution";
|
|
496
|
+
report.headline = `Proceed with caution — ${collapsed.length} screen(s) regressed vs. baseline (content collapsed or became unreachable).`;
|
|
497
|
+
}
|
|
498
|
+
const regression = computeRegression(report.findings, baseline?.findings ?? null);
|
|
499
|
+
const runs = args.flowLogs.map(parseFlowLog);
|
|
500
|
+
const contracts = runs.filter((run) => run.kind === "release-contract");
|
|
501
|
+
const flows = runs.filter((run) => !["scenario", "release-contract"].includes(run.kind));
|
|
502
|
+
const scenarios = runs.filter((run) => run.kind === "scenario");
|
|
503
|
+
let prPlan;
|
|
504
|
+
try {
|
|
505
|
+
prPlan = enrichPrPlan(loadPrPlan(args.prPlan), contracts, currentUiMap, args.markers, args.projectDir || "");
|
|
506
|
+
prPlan = await validateMaintenancePlan(prPlan, args);
|
|
507
|
+
if (prPlan && args.prPlan) fs.writeFileSync(args.prPlan, JSON.stringify(prPlan, null, 2) + "\n");
|
|
508
|
+
} catch (error) {
|
|
509
|
+
console.error(`Could not load PR plan: ${error.message || String(error)}`);
|
|
510
|
+
process.exit(2);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const reasons = [];
|
|
514
|
+
const failedFlows = flows.filter((f) => !f.passed);
|
|
515
|
+
if (failedFlows.length) reasons.push(`${failedFlows.length} flow(s) failed`);
|
|
516
|
+
const failedScenarios = scenarios.filter((scenario) => !scenario.passed);
|
|
517
|
+
if (failedScenarios.length) reasons.push(`${failedScenarios.length} multi-actor scenario(s) failed`);
|
|
518
|
+
const failedContracts = contracts.filter((contract) => !contract.passed);
|
|
519
|
+
if (failedContracts.length) reasons.push(`${failedContracts.length} release contract(s) failed`);
|
|
520
|
+
if (prPlan?.execution.notRun) reasons.push(`${prPlan.execution.notRun} selected release contract(s) did not run`);
|
|
521
|
+
if (prPlan?.execution.explorationFailed) reasons.push(`${prPlan.execution.explorationFailed} planned PR exploration target(s) failed or were not reached`);
|
|
522
|
+
if (args.failOn === "any") {
|
|
523
|
+
if (report.findingCounts.total > 0) reasons.push(`${report.findingCounts.total} finding(s) (fail-on: any)`);
|
|
524
|
+
} else if (args.failOn === "blocked" || (args.failOn === "gate" && !regression)) {
|
|
525
|
+
if (report.verdict === "blocked") reasons.push("verdict is blocked");
|
|
526
|
+
if (report.inconclusive) reasons.push("run was inconclusive (coverage floor not met)");
|
|
527
|
+
} else {
|
|
528
|
+
if (regression?.gate.failed) {
|
|
529
|
+
reasons.push(`${regression.gate.newCritical} new critical + ${regression.gate.newHigh} new high vs. baseline`);
|
|
530
|
+
}
|
|
531
|
+
// A regression gate must also catch regressions in EXPLORABILITY, not just in findings:
|
|
532
|
+
// a change that makes the app crash at launch (or reintroduces a login wall) produces an
|
|
533
|
+
// inconclusive run with zero new findings — that must never pass. (Found via corpus
|
|
534
|
+
// bug-seeding: a seeded crash-at-startup sailed through on the findings diff alone.)
|
|
535
|
+
if (report.verdict === "blocked") reasons.push("verdict is blocked");
|
|
536
|
+
if (report.inconclusive && !baseline.inconclusive) {
|
|
537
|
+
reasons.push("run became inconclusive vs. baseline (app may no longer launch/explore)");
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
const gate = { policy: args.failOn, failed: reasons.length > 0, reasons };
|
|
541
|
+
|
|
542
|
+
const md = renderMarkdown(report, regression, flows, scenarios, contracts, prPlan, gate);
|
|
543
|
+
console.log(md);
|
|
544
|
+
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
545
|
+
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md + "\n");
|
|
546
|
+
}
|
|
547
|
+
if (args.mdOut) {
|
|
548
|
+
fs.writeFileSync(args.mdOut, md + "\n");
|
|
549
|
+
}
|
|
550
|
+
if (args.jsonOut) {
|
|
551
|
+
fs.writeFileSync(args.jsonOut, JSON.stringify({ ...report, regression, flows, scenarios, contracts, ...(prPlan ? { prPlan } : {}), gate }, null, 2) + "\n");
|
|
552
|
+
}
|
|
553
|
+
if (args.htmlDir) {
|
|
554
|
+
const html = writeHtmlReport(args.htmlDir, { report, label: args.label || "CI run" });
|
|
555
|
+
if (html) console.log(`\nEvidence report: ${html}`);
|
|
556
|
+
}
|
|
557
|
+
process.exit(gate.failed ? 1 : 0);
|