@aarwitz/tapp 0.16.4 → 0.16.5

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 CHANGED
@@ -82,10 +82,13 @@ Rules that prevent 90% of failures:
82
82
 
83
83
  ## Autonomous QA (`tapp_run_qa`)
84
84
 
85
- Returns `{verdict, confidence, headline, screensExplored, actionsPerformed, findings[]}`.
85
+ Returns `{verdict, confidence, releaseScore, headline, screensExplored, actionsPerformed, findings[]}`.
86
+ Exploratory web runs set `confidence` and `releaseScore` to `null`; report their deterministic
87
+ finding counts, advisory sampled-probe counts, and coverage instead of inventing a scalar.
86
88
 
87
- - `verdict`: `ready` | `caution` | `blocked`. **Trust it it's deterministic.** Report it to the
88
- user as-is; never soften a `blocked` or inflate a `caution`.
89
+ - `verdict`: `ready` | `caution` | `blocked`. Report it as-is; never soften a `blocked` or inflate a
90
+ `caution`. Judgment is deterministic for a given evidence trace, while adaptive exploration and
91
+ live target state can still change which evidence a run observes.
89
92
  - `inconclusive: true` means the run couldn't see enough (crash on launch, login wall). That is
90
93
  **not a pass** — tell the user what blocked exploration and what would unblock it.
91
94
  - Login walls: pass `testEmail`/`testPassword` (auto-typed into login forms), `appLaunchArgs`
@@ -95,9 +98,10 @@ Returns `{verdict, confidence, headline, screensExplored, actionsPerformed, find
95
98
  - Diff two runs: pass the previous run's `findings` as `baselineFindings` → you get a
96
99
  `regression` block (`new` / `persisting` / `resolved`, plus a CI `gate` signal).
97
100
  - On web, report the exact verdict but preserve its scope: Tapp deterministically checks technical
98
- behavior such as failed requests, missing assets, placeholder links, and inert controls. It does
101
+ behavior such as failed requests, missing assets, and placeholder links. Dead-control probes are
102
+ budget-capped advisory findings and do not drive the verdict. Tapp does
99
103
  not validate marketing claims against APIs, API field privacy, brand consistency, or subjective
100
- marketplace credibility unless an explicit reviewed test/contract covers them.
104
+ marketplace credibility unless an explicit reviewed test/contract or verifier covers them.
101
105
 
102
106
  ## Flows (deterministic E2E tests)
103
107
 
package/README.md CHANGED
@@ -168,7 +168,7 @@ Then ask your agent:
168
168
  | 📸 | `tapp_screenshot` | Whatever's on the sim right now, as an inline image. |
169
169
  | 🌳 | `tapp_ui_tree` | The accessibility tree of the current screen (ids, labels, hittability). |
170
170
  | 🕹 | `tapp_session_start/act/end` | **Interactive driving** — the Playwright loop. App launches once; each act (tap/type/swipe/back/wait) returns the fresh tree. |
171
- | 🧪 | `tapp_run_qa` | **Autonomous QA** — explores with no authored test, returns `{verdict, releaseScore, findings[]}`. Takes `appBundleId` (iOS), `androidAppId` (Android), or `url` (web). |
171
+ | 🧪 | `tapp_run_qa` | **Autonomous QA** — explores with no authored test, returns `{verdict, releaseScore, findings[]}` (`releaseScore` is `null` for exploratory web). Takes `appBundleId` (iOS), `androidAppId` (Android), or `url` (web). |
172
172
  | 🧭 | `tapp_init` | **Repository import** — detect targets; optionally explore a real surface; persist the shared UI Map; construct the evidence-classified model and grounded release plan. |
173
173
  | 👤 | `tapp_actor_config` | **Actor/session setup** — store roles, isolation/provisioning, and environment-variable names without accepting or persisting credential values. |
174
174
  | ✅ | `tapp_release_plan` | **Release-plan lifecycle** — inspect, approve/reject/defer, generate, real-target validate, and explicitly promote proposed guarantees without silent test edits. |
@@ -193,7 +193,7 @@ deterministic per-platform navigation root used for bounded changed-surface repl
193
193
 
194
194
  **Adaptive exploration, deterministic judgment.** Exploration is adaptive — two runs may
195
195
  traverse different paths through your app. Judgment is deterministic: the same evidence
196
- trace always produces the same findings, the same score, and the same verdict — no LLM variability
196
+ trace always produces the same findings and verdict — no LLM variability
197
197
  in the decision loop. PR gating keys on the **regression diff**
198
198
  (stable finding signatures vs. a baseline), so it reacts to what *changed*, not to
199
199
  run-to-run path variance. For critical user journeys, committed **Tasks and Flows** provide the stable CI
@@ -201,10 +201,11 @@ suite: reusable semantic actions, exact assertions, condition-based waits, fresh
201
201
  and evidence on failure. We call this *flake-resistant*, not magically flake-free—backend outages,
202
202
  unstable test data, and poorly identified controls can still make any E2E test fail.
203
203
 
204
- **A release score, not "confidence."** The 0–100 number is a heuristic quality score from
205
- fixed, documented deductions we don't call it confidence because it isn't calibrated
206
- probability. Calibrating it against seeded-fault benchmarks is ongoing work; until then it
207
- ranks runs, it doesn't promise odds.
204
+ **Native has a heuristic release score; exploratory web does not.** The native 0–100 number comes
205
+ from fixed deductions and is not calibrated probability. Web reports deterministic findings,
206
+ advisory budget-capped control probes, and concrete coverage instead of compressing those unlike
207
+ signals into a scalar. Committed Flows, Tasks, contracts, and baseline regressions provide the web
208
+ merge decision.
208
209
 
209
210
  `tapp_run_qa` explores like a user — accessibility surfaces on iOS/Android and a real browser on web —
210
211
  and detects crashes, failed sign-ins, dead buttons, stuck loading screens, error surfaces,
@@ -219,10 +220,18 @@ broken links and assets, and visible placeholder links with no destination). The
219
220
  app crashed on launch or a login wall blocked exploration, you get `inconclusive: true`,
220
221
  not a false pass. Absence of findings is not a pass.
221
222
 
222
- Web beta presents a `ready` result as **AUTOMATED CHECKS PASSED**, not “ship-ready.” Its report
223
+ Web beta presents a `ready` result as **AUTOMATED CHECKS COMPLETE**, not “ship-ready,” and displays
224
+ no scalar score. Exhaustive checks on each exercised page drive the verdict; sampled control probes
225
+ remain visible findings but are advisory. The report
223
226
  explicitly excludes content/claim accuracy, privacy and API data minimization, brand/SEO
224
227
  consistency, and subjective visual credibility. Those require reviewed contracts, privacy review,
225
- or human/vision judgment; a green technical crawl must not imply they were validated.
228
+ or human/vision judgment; an exploratory crawl must not imply they were validated.
229
+
230
+ For a business guarantee such as “every coach is insured,” use a deterministic app-owned verifier
231
+ endpoint that returns success only when the invariant holds, then require that status and the
232
+ customer-visible claim in a release contract. The current DSL does not yet read arbitrary JSON
233
+ response bodies or compare a cross-origin API payload directly with page copy; use a verifier or an
234
+ explicit CI preflight rather than assuming autonomous QA inferred the guarantee.
226
235
 
227
236
  Apps behind a login? Pass `testEmail`/`testPassword` (typed into the login form automatically),
228
237
  `appLaunchArgs` (e.g. `["--uitesting"]` if your app supports a bypass), or explicit `loginSteps`
@@ -26,7 +26,7 @@
26
26
  // any fail on any finding at all, or any flow failure. Strictest.
27
27
  import fs from "fs";
28
28
  import path from "node:path";
29
- import { buildQaReport, computeRegression, computeContentCollapse, computeReachabilityLoss, verdictBadge } from "./report.js";
29
+ import { buildQaReport, computeRegression, computeContentCollapse, computeReachabilityLoss, qaScoreLabel, verdictBadge } from "./report.js";
30
30
  import { writeHtmlReport } from "./html-report.js";
31
31
  import { buildUiMapFromMarkers, writeUiMap } from "./ui-map.js";
32
32
  import { proposeSelectorMaintenance, validateWebMaintenanceProposal } from "./maintenance-proposal.js";
@@ -345,7 +345,10 @@ function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan,
345
345
  lines.push("");
346
346
  lines.push(report.headline);
347
347
  lines.push("");
348
- lines.push(`**release score ${report.confidence}/100** · ${report.screensExplored} screens · ${report.actionsPerformed} actions · ${report.findingCounts.total} finding(s)`);
348
+ lines.push(`**${qaScoreLabel(report)}** · ${report.screensExplored} screens · ${report.actionsPerformed} actions · ${report.findingCounts.total} finding(s)`);
349
+ if (report.platform === "web") {
350
+ lines.push(`**Verdict basis:** ${report.verdictFindingCounts?.total || 0} deterministic finding(s); ${report.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory.`);
351
+ }
349
352
  if (report.uiMap) lines.push(`**UI Map:** ${report.uiMap.nodeCount} states · ${report.uiMap.edgeCount} transitions · ${report.uiMap.controlCount} semantic controls`);
350
353
  if (report.findings.length) {
351
354
  lines.push("");
@@ -488,11 +491,17 @@ if (collapsed.length) {
488
491
  report.findings.push(...collapsed);
489
492
  report.findingCounts.high += collapsed.length;
490
493
  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";
494
+ report.verdictFindingCounts.high += collapsed.length;
495
+ report.verdictFindingCounts.total += collapsed.length;
496
+ // Keep native scoring compatible. Exploratory web deliberately has no scalar; deterministic
497
+ // baseline regressions still raise its verdict directly.
498
+ if (Number.isFinite(report.confidence)) {
499
+ report.confidence = Math.max(0, report.confidence - collapsed.length * 10);
500
+ report.releaseScore = report.confidence;
501
+ }
502
+ if (report.verdict === "ready") {
503
+ report.verdict = Number.isFinite(report.confidence) && report.confidence < 50 ? "blocked" : "caution";
504
+ }
496
505
  report.headline = `Proceed with caution — ${collapsed.length} screen(s) regressed vs. baseline (content collapsed or became unreachable).`;
497
506
  }
498
507
  const regression = computeRegression(report.findings, baseline?.findings ?? null);
@@ -8,7 +8,7 @@
8
8
 
9
9
  import fs from "fs";
10
10
  import path from "path";
11
- import { buildQaReport, verdictBadge } from "./report.js";
11
+ import { buildQaReport, qaScoreLabel, verdictBadge } from "./report.js";
12
12
 
13
13
  const SEV_COLOR = { critical: "#cf222e", high: "#bc4c00", medium: "#9a6700", low: "#57606a" };
14
14
 
@@ -101,8 +101,9 @@ export function writeHtmlReport(captureDir, { report, label = "" } = {}) {
101
101
  </style>
102
102
  </head>
103
103
  <body>
104
- <h1>${esc(verdictBadge(r))} <span class="dim">· release score ${r.releaseScore ?? r.confidence}/100</span></h1>
104
+ <h1>${esc(verdictBadge(r))} <span class="dim">· ${esc(qaScoreLabel(r))}</span></h1>
105
105
  <div class="meta">${esc(label)} · ${r.screensExplored} screens · ${r.actionsPerformed} actions · ${r.findingCounts.total} finding(s)</div>
106
+ ${r.platform === "web" ? `<div class="meta">Verdict basis: ${r.verdictFindingCounts?.total || 0} deterministic finding(s); ${r.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory.</div>` : ""}
106
107
  <div class="headline">${esc(r.headline)}</div>
107
108
  <h2>Findings</h2>
108
109
  <ul class="findings">
@@ -12,7 +12,7 @@ import {
12
12
  ListToolsRequestSchema,
13
13
  } from "@modelcontextprotocol/sdk/types.js";
14
14
 
15
- import { parseOcqaMarkers, buildQaReport, computeRegression, verdictBadge } from "./report.js";
15
+ import { parseOcqaMarkers, buildQaReport, computeRegression, qaScoreLabel, verdictBadge } from "./report.js";
16
16
  import { existingProjectArtifactPath, projectArtifactDirectory } from "./project-paths.js";
17
17
 
18
18
  const __filename = fileURLToPath(import.meta.url);
@@ -1326,11 +1326,14 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
1326
1326
  .filter(Boolean)
1327
1327
  .join(", ");
1328
1328
  const L = [];
1329
- L.push(`### 🧪 QA complete — ${badge} · release score ${report.confidence}/100${bundleId ? `\n\`${bundleId}\`` : ""}`);
1329
+ L.push(`### 🧪 QA complete — ${badge} · ${qaScoreLabel(report)}${bundleId ? `\n\`${bundleId}\`` : ""}`);
1330
1330
  L.push("");
1331
1331
  L.push(report.headline);
1332
1332
  L.push("");
1333
1333
  L.push(`**Coverage** — ${report.screensExplored} screens · ${report.actionsPerformed} actions${timedOut ? " · ⏱️ hit time limit" : ""}`);
1334
+ if (report.platform === "web") {
1335
+ L.push(`**Verdict basis** — ${report.verdictFindingCounts?.total || 0} deterministic finding(s); ${report.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory`);
1336
+ }
1334
1337
  if (uiMap) L.push(`**UI Map** — ${uiMap.nodeCount} states · ${uiMap.edgeCount} transitions · ${uiMap.controlCount} semantic controls · ${uiMap.path}`);
1335
1338
  if (reportHtml) L.push(`**Evidence** — 📄 ${reportHtml} (screenshots of every screen + findings, shareable)`);
1336
1339
  if (recording) L.push(`**Recording** — 🎬 ${recording} (full exploration, embedded in the evidence page)`);
@@ -2070,13 +2073,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2070
2073
  description:
2071
2074
  "Run autonomous QA against iOS (appBundleId), Android (androidAppId), OR a web app " +
2072
2075
  "(url — beta, requires Playwright installed) and return a structured " +
2073
- "ship/no-ship verdict. Use ONLY when the user wants a QA assessment / to find bugs / a verdict — this " +
2076
+ "QA verdict. Use ONLY when the user wants a QA assessment / to find bugs / a verdict — this " +
2074
2077
  "runs for MINUTES exploring the whole app. Do NOT use it just to view, screenshot, or reach a specific " +
2075
2078
  "screen — use tapp_open_app (launch + screenshot) or a session for that. Tapp explores the app " +
2076
2079
  "like a tester (taps, types, navigates, scrolls) and detects real issues — crashes, dead buttons, failed sign-ins, error screens, " +
2077
2080
  "stuck/hung screens; on web also uncaught JS exceptions, failed/5xx requests, broken links and assets. " +
2078
- "Returns {verdict: ready|caution|blocked, confidence, headline, screensExplored, " +
2079
- "actionsPerformed, findings:[{type,severity,category,title,screen}]}. The verdict has a coverage floor: " +
2081
+ "Returns {verdict: ready|caution|blocked, confidence, releaseScore, headline, screensExplored, " +
2082
+ "actionsPerformed, findings:[{type,severity,category,title,screen,evaluationTier}]}. Exploratory web " +
2083
+ "sets confidence/releaseScore to null and separates deterministic verdict findings from advisory " +
2084
+ "sampled control probes. The verdict has a coverage floor: " +
2080
2085
  "if the app barely explored (crash on launch / sign-in wall) it returns 'caution' + inconclusive, never a " +
2081
2086
  "false pass. For iOS the app must already be installed on a booted simulator (use tapp_list_simulators / " +
2082
2087
  "tapp_boot_simulator first). For web, only point it at an app/environment you own — it CLICKS things. " +
@@ -96,16 +96,28 @@ export const ISSUE_CATEGORY = {
96
96
  explore_timeout: "performance_timeout",
97
97
  };
98
98
  export const CRITICAL_ISSUE_TYPES = new Set(["crash"]);
99
+ export const WEB_SAMPLED_ISSUE_TYPES = new Set(["unresponsive_element"]);
99
100
 
100
101
  export function severityRank(s) {
101
102
  return { critical: 0, high: 1, medium: 2, low: 3 }[s] ?? 4;
102
103
  }
103
104
 
105
+ export function findingEvaluationTier(finding, platform = "ios") {
106
+ return platform === "web" && WEB_SAMPLED_ISSUE_TYPES.has(finding?.type) ? "sampled" : "deterministic";
107
+ }
108
+
104
109
  export function verdictBadge(report) {
105
- if (report?.platform === "web" && report?.verdict === "ready") return "🟢 AUTOMATED CHECKS PASSED";
110
+ if (report?.platform === "web" && report?.verdict === "ready") return "🔵 AUTOMATED CHECKS COMPLETE";
106
111
  return { ready: "🟢 SHIP-READY", caution: "🟡 CAUTION", blocked: "🔴 BLOCKED" }[report?.verdict] || report?.verdict;
107
112
  }
108
113
 
114
+ export function qaScoreLabel(report) {
115
+ const score = report?.releaseScore ?? report?.confidence;
116
+ if (Number.isFinite(score)) return `release score ${score}/100`;
117
+ if (report?.platform === "web") return "exploratory web · no scalar score";
118
+ return "score unavailable";
119
+ }
120
+
109
121
  // Turn a capture's OCQA markers into the same ship/no-ship report Tapp produces:
110
122
  // deduped findings + a trustworthy verdict with a coverage floor (mirrors OrchestratorService).
111
123
  export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
@@ -186,7 +198,11 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
186
198
  const key = `${i.type}|${i.screen}|${i.target ?? ""}`;
187
199
  if (seen.has(key)) continue;
188
200
  seen.add(key);
189
- findings.push({ ...i, category: ISSUE_CATEGORY[i.type] || i.type });
201
+ findings.push({
202
+ ...i,
203
+ category: ISSUE_CATEGORY[i.type] || i.type,
204
+ ...(platform === "web" ? { evaluationTier: findingEvaluationTier(i, platform) } : {}),
205
+ });
190
206
  }
191
207
  findings.sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
192
208
 
@@ -197,24 +213,32 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
197
213
  const high = findings.filter((f) => f.severity === "high").length;
198
214
  const med = findings.filter((f) => f.severity === "medium").length;
199
215
  const low = findings.filter((f) => f.severity === "low").length;
216
+ const verdictFindings = platform === "web"
217
+ ? findings.filter((finding) => finding.evaluationTier !== "sampled")
218
+ : findings;
219
+ const verdictCrit = verdictFindings.filter((f) => f.severity === "critical").length;
220
+ const verdictHigh = verdictFindings.filter((f) => f.severity === "high").length;
221
+ const verdictMed = verdictFindings.filter((f) => f.severity === "medium").length;
222
+ const verdictLow = verdictFindings.filter((f) => f.severity === "low").length;
223
+ const sampledFindings = platform === "web" ? findings.filter((finding) => finding.evaluationTier === "sampled") : [];
200
224
 
201
225
  // Coverage floor: a verdict is only trustworthy if the app was actually exercised.
202
226
  const inconclusive = screensExplored < 2 || actionsPerformed < 3;
203
- let confidence = Math.max(0, Math.min(100, 100 - crit * 25 - high * 10 - med * 3));
204
- if (inconclusive) confidence = Math.min(confidence, 40);
227
+ let riskScore = Math.max(0, Math.min(100, 100 - verdictCrit * 25 - verdictHigh * 10 - verdictMed * 3));
228
+ if (inconclusive) riskScore = Math.min(riskScore, 40);
205
229
 
206
230
  let verdict;
207
- if (crit > 0) verdict = "blocked";
231
+ if (verdictCrit > 0) verdict = "blocked";
208
232
  else if (inconclusive) verdict = "caution";
209
- else if (confidence < 50) verdict = "blocked";
210
- else if (high > 0 || confidence < 80) verdict = "caution";
233
+ else if (riskScore < 50) verdict = "blocked";
234
+ else if (verdictHigh > 0 || riskScore < 80) verdict = "caution";
211
235
  else verdict = "ready";
212
236
 
213
237
  const headline = inconclusive
214
238
  ? `Inconclusive — only ${screensExplored} screen(s) / ${actionsPerformed} action(s) explored. The app may have crashed on launch, be stuck behind a sign-in wall, or otherwise prevent exploration. Absence of issues is NOT a pass.`
215
239
  : verdict === "ready"
216
240
  ? platform === "web"
217
- ? "Automated web checks passed — no release-blocking technical issues found in the exercised surfaces. This is not a content, privacy, brand, or business-claim review."
241
+ ? "Automated web checks completed — no release-blocking deterministic findings in the exercised surfaces. Sampled control probes are advisory. This is not a content, privacy, brand, or business-claim review."
218
242
  : "Ship-ready — no release-blocking issues found."
219
243
  : verdict === "caution"
220
244
  ? `Proceed with caution — ${findings.length} issue(s) to review.`
@@ -230,7 +254,7 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
230
254
  if (platform === "web") {
231
255
  checkedFor = [
232
256
  "page errors (uncaught exceptions)", "failed/5xx requests", "broken links (404)",
233
- "placeholder links with no destination", "dead buttons", "error text on pages", "load timeouts",
257
+ "placeholder links with no destination", "sampled dead-button probes (advisory)", "error text on pages", "load timeouts",
234
258
  ];
235
259
  notChecked = [
236
260
  "app-specific business logic (cover with Flows: record or generate, then assert)",
@@ -272,11 +296,14 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
272
296
 
273
297
  return {
274
298
  verdict,
275
- // `releaseScore` is the honest name: a heuristic quality score from fixed deductions,
276
- // NOT calibrated statistical confidence. `confidence` is kept as an alias for
277
- // compatibility (baselines, desktop app, existing consumers).
278
- confidence,
279
- releaseScore: confidence,
299
+ // Exploratory web QA deliberately has no scalar. Its verdict derives from deterministic
300
+ // checks on exercised pages; budget-capped control probes remain visible but advisory.
301
+ // Native keeps the legacy heuristic score until it has an equivalent tier split.
302
+ confidence: platform === "web" ? null : riskScore,
303
+ releaseScore: platform === "web" ? null : riskScore,
304
+ scoreUnavailableReason: platform === "web"
305
+ ? "Exploratory web runs report deterministic findings, advisory sampled probes, and coverage instead of a scalar release score."
306
+ : null,
280
307
  headline,
281
308
  inconclusive,
282
309
  checkedFor,
@@ -286,6 +313,20 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
286
313
  screensExplored,
287
314
  actionsPerformed,
288
315
  findingCounts: { critical: crit, high, medium: med, low, total: findings.length },
316
+ verdictFindingCounts: {
317
+ critical: verdictCrit,
318
+ high: verdictHigh,
319
+ medium: verdictMed,
320
+ low: verdictLow,
321
+ total: verdictFindings.length,
322
+ },
323
+ sampledFindingCounts: {
324
+ critical: sampledFindings.filter((f) => f.severity === "critical").length,
325
+ high: sampledFindings.filter((f) => f.severity === "high").length,
326
+ medium: sampledFindings.filter((f) => f.severity === "medium").length,
327
+ low: sampledFindings.filter((f) => f.severity === "low").length,
328
+ total: sampledFindings.length,
329
+ },
289
330
  findings,
290
331
  screens: Array.from(screens),
291
332
  screenElementCounts,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.16.4",
3
+ "version": "0.16.5",
4
4
  "mcpName": "io.github.aarwitz/tapp",
5
5
  "description": "Release contracts, autonomous QA, and evidence-backed CI gates for iOS, Android, and web.",
6
6
  "license": "MIT",