@aarwitz/tapp 0.16.5 → 0.17.0-rc.10

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.
@@ -13,7 +13,7 @@ import { compileReleaseContract, loadReleaseContractFile } from "./release-contr
13
13
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
14
14
 
15
15
  function executionHome() {
16
- return process.env.TAPP_HOME || process.env.AUTOTAP_HOME || path.join(os.homedir(), ".tapp");
16
+ return process.env.TAPP_HOME || path.join(os.homedir(), ".tapp");
17
17
  }
18
18
 
19
19
  function atomicJson(destination, value) {
@@ -64,8 +64,77 @@ function artifactPaths(root, outDir = ".tapp") {
64
64
  };
65
65
  }
66
66
 
67
+ function normalizedInitTarget(root, target) {
68
+ const requested = String(target || "").trim();
69
+ if (!requested) return "";
70
+ let absolute;
71
+ try { absolute = fs.realpathSync(path.resolve(root, requested)); }
72
+ catch { absolute = path.resolve(root, requested); }
73
+ if (absolute === root) return "";
74
+ if (inside(root, absolute)) return path.relative(root, absolute).replaceAll(path.sep, "/");
75
+ return requested;
76
+ }
77
+
78
+ function initTargetChoices(model, platform = "") {
79
+ const candidates = (model.targets || []).filter((target) => !platform || target.platform === platform);
80
+ return candidates.map((target) => ({
81
+ target,
82
+ command: `tapp init . --explore --platform ${target.platform} --target ${target.sourcePath === "." ? JSON.stringify(target.name) : JSON.stringify(target.sourcePath)}`,
83
+ }));
84
+ }
85
+
86
+ function selectInitExplorationTarget(model, {
87
+ root,
88
+ platform = "",
89
+ target = "",
90
+ appId = "",
91
+ priorDefaultTargetId = "",
92
+ } = {}) {
93
+ const selectedPlatform = String(platform || "").trim().toLowerCase();
94
+ if (selectedPlatform && !["ios", "android", "web"].includes(selectedPlatform)) throw new Error("platform must be ios|android|web");
95
+ let requested = normalizedInitTarget(root, target);
96
+ if (!requested && appId) {
97
+ const androidMatch = (model.targets || []).find((candidate) => candidate.platform === "android" && candidate.runtime?.applicationId === appId);
98
+ if (androidMatch) requested = androidMatch.id;
99
+ }
100
+ const defaultTargetId = (model.targets || []).some((candidate) => candidate.id === priorDefaultTargetId)
101
+ ? priorDefaultTargetId
102
+ : model.application?.defaultTargetId || "";
103
+ const resolutionModel = { ...model, application: { ...(model.application || {}), defaultTargetId } };
104
+ try {
105
+ return selectApplicationTarget(resolutionModel, {
106
+ platform: selectedPlatform,
107
+ target: requested,
108
+ useDefault: !selectedPlatform && !requested && !!defaultTargetId,
109
+ });
110
+ } catch (error) {
111
+ const choices = initTargetChoices(model, selectedPlatform);
112
+ if (choices.length > 1 && !requested) {
113
+ const selection = new Error(
114
+ `Multiple application targets were detected; Tapp will not guess which one you mean:\n` +
115
+ choices.map(({ target: choice }) => ` - ${choice.platform}:${choice.name} (${choice.sourcePath})`).join("\n") +
116
+ `\nRerun with one of:\n` + choices.map(({ command }) => ` ${command}`).join("\n")
117
+ );
118
+ selection.code = "TAPP_TARGET_SELECTION_REQUIRED";
119
+ selection.details = {
120
+ reason: "target-selection-required",
121
+ choices: choices.map(({ target: choice, command }) => ({
122
+ id: choice.id,
123
+ platform: choice.platform,
124
+ name: choice.name,
125
+ sourcePath: choice.sourcePath,
126
+ selector: choice.sourcePath === "." ? choice.name : choice.sourcePath,
127
+ command,
128
+ })),
129
+ };
130
+ throw selection;
131
+ }
132
+ throw error;
133
+ }
134
+ }
135
+
67
136
  function productRunRoot(root) {
68
- const home = process.env.TAPP_HOME || process.env.AUTOTAP_HOME || path.join(os.homedir(), ".tapp");
137
+ const home = process.env.TAPP_HOME || path.join(os.homedir(), ".tapp");
69
138
  const identity = crypto.createHash("sha256").update(root).digest("hex").slice(0, 16);
70
139
  return path.join(home, "product-runs", identity);
71
140
  }
@@ -84,7 +153,7 @@ function listProductRuns(root) {
84
153
  id: entry.name,
85
154
  createdAt: fs.statSync(dir).birthtime.toISOString(),
86
155
  status: report ? "completed" : "incomplete",
87
- verdict: report?.verdict || null,
156
+ outcome: report?.gate?.outcome || null,
88
157
  gate: report?.gate || null,
89
158
  reportPath: fs.existsSync(reportPath) ? reportPath : null,
90
159
  markdownPath: fs.existsSync(markdownPath) ? markdownPath : null,
@@ -287,22 +356,42 @@ export async function initializeProductProject({
287
356
  if (!["inspect", "write", "refresh", "explore"].includes(mode)) throw new Error("mode must be inspect|write|refresh|explore");
288
357
  if (!Number.isInteger(Number(maxContracts)) || Number(maxContracts) < 1 || Number(maxContracts) > 50) throw new Error("maxContracts must be between 1 and 50");
289
358
  const paths = artifactPaths(root, outDir);
359
+ const priorModel = readJson(paths.model);
290
360
  let exploration = null;
361
+ let selectedTarget = null;
291
362
  if (mode === "explore") {
292
363
  if (typeof runExploration !== "function") throw new Error("The selected adapter did not provide a platform exploration capability");
293
- const selectedPlatform = String(platform || (ownedUrl ? "web" : appId || apkPath ? "android" : "ios")).toLowerCase();
364
+ const selectedPlatform = String(platform || (ownedUrl ? "web" : appId || apkPath ? "android" : "")).toLowerCase();
365
+ const inspected = await buildInitArtifacts({
366
+ projectDir: root,
367
+ ownedUrl,
368
+ outDir,
369
+ maxContracts: Number(maxContracts),
370
+ defaultTargetId: priorModel?.application?.defaultTargetId || "",
371
+ });
372
+ selectedTarget = selectInitExplorationTarget(inspected.model, {
373
+ root,
374
+ platform: selectedPlatform,
375
+ target,
376
+ appId,
377
+ priorDefaultTargetId: priorModel?.application?.defaultTargetId || "",
378
+ });
379
+ const sourceTarget = selectedTarget.sourcePath === "." ? root : path.resolve(root, selectedTarget.sourcePath);
294
380
  exploration = await runExploration({
295
- projectDir: root, platform: selectedPlatform, outDir, url: ownedUrl, target: target || root,
296
- bundleId, appId, apkPath, serial, scheme, configuration, maxActions: Number(maxActions), timeout: Number(timeout),
297
- testEmail, testPassword, onProgress, onStatus,
381
+ projectDir: root, platform: selectedTarget.platform, outDir, url: ownedUrl, target: sourceTarget,
382
+ bundleId, appId: appId || selectedTarget.runtime?.applicationId || "", apkPath, serial, scheme, configuration, maxActions: Number(maxActions), timeout: Number(timeout),
383
+ testEmail, testPassword, onProgress: (progress) => onProgress({ ...progress, platform: selectedTarget.platform }), onStatus,
298
384
  });
299
385
  if (exploration?.error) throw Object.assign(new Error(exploration.error), { details: exploration.details || {} });
300
386
  }
301
387
  const built = await buildInitArtifacts({
302
388
  projectDir: root,
303
389
  ownedUrl: ownedUrl || (exploration?.platform === "web" && !exploration.managedRuntime ? exploration.target : ""),
304
- platform: String(platform || "").toLowerCase(),
390
+ // Exploration chooses one runnable surface, but the repository model must retain every detected
391
+ // application target. Otherwise selecting web would silently erase the native app (and vice versa).
392
+ platform: mode === "explore" ? "" : String(platform || "").toLowerCase(),
305
393
  targetValidation: exploration?.targetValidation || null,
394
+ defaultTargetId: selectedTarget?.id || priorModel?.application?.defaultTargetId || "",
306
395
  outDir,
307
396
  maxContracts: Number(maxContracts),
308
397
  });
@@ -1,9 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { LEGACY_TAPP_DIRECTORY, TAPP_DIRECTORY, projectArtifactDirectory } from "./project-paths.js";
3
+ import { TAPP_DIRECTORY, projectArtifactDirectory } from "./project-paths.js";
4
4
 
5
5
  export const PROJECT_CONFIG_RELATIVE_PATH = `${TAPP_DIRECTORY}/project.json`;
6
- export const LEGACY_PROJECT_CONFIG_RELATIVE_PATH = `${LEGACY_TAPP_DIRECTORY}/project.json`;
7
6
 
8
7
  const ACTOR_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
9
8
  const ENV_NAME = /^[A-Z_][A-Z0-9_]{0,127}$/;
@@ -1,32 +1,20 @@
1
- import fs from "node:fs";
2
1
  import path from "node:path";
3
2
 
4
3
  export const TAPP_DIRECTORY = ".tapp";
5
- export const LEGACY_TAPP_DIRECTORY = ".autotap";
6
4
  export const TAPP_CONFIG = ".tapp.yml";
7
- export const LEGACY_TAPP_CONFIG = ".autotap.yml";
8
5
 
9
- export function projectArtifactDirectory(projectDir, requested = TAPP_DIRECTORY) {
10
- const root = path.resolve(projectDir);
11
- if (requested !== TAPP_DIRECTORY) return requested;
12
- const canonical = path.join(root, TAPP_DIRECTORY);
13
- const legacy = path.join(root, LEGACY_TAPP_DIRECTORY);
14
- if (!fs.existsSync(canonical) && fs.existsSync(legacy)) return LEGACY_TAPP_DIRECTORY;
15
- return TAPP_DIRECTORY;
6
+ export function projectArtifactDirectory(_projectDir, requested = TAPP_DIRECTORY) {
7
+ return requested;
16
8
  }
17
9
 
18
10
  export function projectArtifactPath(projectDir, ...parts) {
19
- return path.join(path.resolve(projectDir), projectArtifactDirectory(projectDir), ...parts);
11
+ return path.join(path.resolve(projectDir), TAPP_DIRECTORY, ...parts);
20
12
  }
21
13
 
22
14
  export function existingProjectArtifactPath(projectDir, ...parts) {
23
- const root = path.resolve(projectDir);
24
- const canonical = path.join(root, TAPP_DIRECTORY, ...parts);
25
- if (fs.existsSync(canonical)) return canonical;
26
- const legacy = path.join(root, LEGACY_TAPP_DIRECTORY, ...parts);
27
- return fs.existsSync(legacy) ? legacy : canonical;
15
+ return path.join(path.resolve(projectDir), TAPP_DIRECTORY, ...parts);
28
16
  }
29
17
 
30
18
  export function isProjectArtifactDirectory(name) {
31
- return name === TAPP_DIRECTORY || name === LEGACY_TAPP_DIRECTORY;
19
+ return name === TAPP_DIRECTORY;
32
20
  }
@@ -11,8 +11,8 @@ import { semanticUiKey } from "./ui-map.js";
11
11
 
12
12
  const PLATFORMS = new Set(["ios", "android", "web"]);
13
13
  const CRITICALITIES = new Set(["low", "medium", "high", "critical"]);
14
- const CONTRACT_AUTHORING_SPECIFIERS = new Set(["@aarwitz/tapp/contracts", "runtapp/contracts", "tapp-mcp/contracts"]);
15
- const CONTRACT_AUTHORING_IMPORT = /(["'])(?:@aarwitz\/tapp|runtapp|tapp-mcp)\/contracts\1/g;
14
+ const CONTRACT_AUTHORING_SPECIFIERS = new Set(["@aarwitz/tapp/contracts"]);
15
+ const CONTRACT_AUTHORING_IMPORT = /(["'])@aarwitz\/tapp\/contracts\1/g;
16
16
  const authoringUrl = pathToFileURL(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "contract-authoring.js")).href;
17
17
 
18
18
  function expectationAction(expectation) {
@@ -86,7 +86,7 @@ function rewriteAuthoringImport(source, contractPath) {
86
86
  }
87
87
  const imports = [...source.matchAll(/(?:from\s*|import\s*)["']([^"']+)["']/g)].map((match) => match[1]);
88
88
  const unsupported = imports.filter((specifier) => !CONTRACT_AUTHORING_SPECIFIERS.has(specifier));
89
- if (unsupported.length) throw new Error(`Release contract imports are limited to @aarwitz/tapp/contracts (legacy runtapp/contracts and tapp-mcp/contracts are also accepted; found ${unsupported.join(", ")})`);
89
+ if (unsupported.length) throw new Error(`Release contract imports are limited to @aarwitz/tapp/contracts (found ${unsupported.join(", ")})`);
90
90
  const output = ts.transpileModule(source, {
91
91
  fileName: contractPath,
92
92
  compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022, verbatimModuleSyntax: true },
@@ -1,6 +1,6 @@
1
1
  // Pure report/gate logic shared by the MCP server (index.js) and the CI gate CLI
2
- // (ci-report.js). Turns a capture's OCQA markers into the same ship/no-ship report the Tapp
3
- // app produces, and diffs two runs' findings into the CI regression gate. No shell, no server —
2
+ // (ci-report.js). Turns a capture's OCQA markers into a scoreless exploration observation,
3
+ // and applies explicit policy separately in the CI gate. No shell, no server —
4
4
  // keep it dependency-free so the CI path stays importable and testable.
5
5
  import fs from "fs";
6
6
  import path from "path";
@@ -106,21 +106,36 @@ export function findingEvaluationTier(finding, platform = "ios") {
106
106
  return platform === "web" && WEB_SAMPLED_ISSUE_TYPES.has(finding?.type) ? "sampled" : "deterministic";
107
107
  }
108
108
 
109
- export function verdictBadge(report) {
110
- if (report?.platform === "web" && report?.verdict === "ready") return "🔵 AUTOMATED CHECKS COMPLETE";
111
- return { ready: "🟢 SHIP-READY", caution: "🟡 CAUTION", blocked: "🔴 BLOCKED" }[report?.verdict] || report?.verdict;
109
+ // The deterministic block-by-findings rule, expressed over verdict-tier finding COUNTS — not the
110
+ // score scalar and not the `verdict` label so the CI gate survives removal of verdict/releaseScore
111
+ // from exploration output. It encodes exactly what `verdict === "blocked"` used to: a critical
112
+ // finding always blocks; otherwise, on a CONCLUSIVE run, a risk threshold blocks. The risk threshold
113
+ // is kept as an explicit, chosen rule (ADR-0005) — `riskFromCounts` is the single source of that
114
+ // formula, shared with buildQaReport's verdict label. Inconclusive runs are a separate gate outcome,
115
+ // never a findings-block, so a thin run reports `inconclusive`, not `fail`.
116
+ export function riskFromCounts({ critical = 0, high = 0, medium = 0 } = {}) {
117
+ return Math.max(0, Math.min(100, 100 - critical * 25 - high * 10 - medium * 3));
118
+ }
119
+ export function findingsBlock(deterministicFindingCounts = {}, { inconclusive = false } = {}) {
120
+ if ((deterministicFindingCounts.critical || 0) > 0) return true;
121
+ if (inconclusive) return false;
122
+ return riskFromCounts(deterministicFindingCounts) < 50;
123
+ }
124
+
125
+ // Exploration OBSERVES; it never renders a ship verdict or score (ADR-0005). These label the
126
+ // observation honestly. The release decision (pass/fail/inconclusive) is the gate's, shown separately.
127
+ export function observationBadge(report) {
128
+ return report?.inconclusive ? "🟡 INCONCLUSIVE (exploration)" : "🔭 EXPLORED";
112
129
  }
113
130
 
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";
131
+ export function observationSummary(report) {
132
+ const n = report?.findingCounts?.total || 0;
133
+ return `${report?.screensExplored || 0} screens · ${report?.actionsPerformed || 0} actions · ${n} finding(s) · observation only`;
119
134
  }
120
135
 
121
- // Turn a capture's OCQA markers into the same ship/no-ship report Tapp produces:
122
- // deduped findings + a trustworthy verdict with a coverage floor (mirrors OrchestratorService).
123
- export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
136
+ // Turn a capture's OCQA markers into a scoreless observation with deduped findings and an
137
+ // explicit coverage floor. Release judgment is applied later by evaluateGate.
138
+ export function buildQaReport(markersFilePath, { platform = "ios", target = null } = {}) {
124
139
  const base = parseOcqaMarkers(markersFilePath);
125
140
  if (!base) return null;
126
141
 
@@ -130,6 +145,7 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
130
145
  const inputsByScreen = new Map();
131
146
  const screenElementCounts = {}; // screen -> max elements observed (content-collapse detection)
132
147
  let anySecure = false;
148
+ let loginAttempted = false;
133
149
  let actions = 0;
134
150
 
135
151
  for (const line of raw.split(/\r?\n/)) {
@@ -148,6 +164,17 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
148
164
  }
149
165
  } else if (t.startsWith("OCQA_ACTION:")) {
150
166
  actions += 1;
167
+ try {
168
+ const action = JSON.parse(t.slice("OCQA_ACTION:".length));
169
+ if (action?.type === "login" || String(action?.type || "").startsWith("login_")) loginAttempted = true;
170
+ } catch {
171
+ /* ignore malformed */
172
+ }
173
+ } else if (
174
+ t === "OCQA_STATE:login_preamble_submitted" ||
175
+ t === "OCQA_STATE:login_preamble_two_step_submitted"
176
+ ) {
177
+ loginAttempted = true;
151
178
  } else if (t.startsWith("OCQA_STATE:{")) {
152
179
  try {
153
180
  const s = JSON.parse(t.slice("OCQA_STATE:".length));
@@ -187,7 +214,12 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
187
214
  const missingResources = new Set(normalizedIssues
188
215
  .filter((issue) => issue.type === "missing_asset" && issue.target)
189
216
  .map((issue) => issue.target));
217
+ // Older native captures represented the caller's wall-clock budget as a high-severity app
218
+ // finding. A timeout makes the evidence partial/inconclusive; it does not prove an app
219
+ // performance defect (the harness has a separate app_hang detector for that).
220
+ const timeBudgetExhausted = base.complete?.timedOut === true || normalizedIssues.some((issue) => issue.type === "explore_timeout");
190
221
  const reportIssues = normalizedIssues.filter((issue) =>
222
+ issue.type !== "explore_timeout" &&
191
223
  !(issue.type === "network_error" && issue.target && missingResources.has(issue.target) && /^Request failed:/i.test(String(issue.title || ""))));
192
224
 
193
225
  // Dedup by stable signature (type|screen|target) so repeated detections count once —
@@ -201,14 +233,23 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
201
233
  findings.push({
202
234
  ...i,
203
235
  category: ISSUE_CATEGORY[i.type] || i.type,
236
+ // Structural evidence authority (ADR-0005): marker-derived findings are deterministic. The
237
+ // default gate consumes only deterministic-authority evidence; model-observed findings
238
+ // (vision/assert_ai) carry authority:"model-observed" and are advisory, never gate fails.
239
+ authority: "deterministic",
204
240
  ...(platform === "web" ? { evaluationTier: findingEvaluationTier(i, platform) } : {}),
205
241
  });
206
242
  }
207
243
  findings.sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
208
244
 
209
245
  const screensExplored = screens.size || base.uniqueScreens.length;
210
- const actionsPerformed =
211
- actions || (base.complete && typeof base.complete === "object" ? base.complete.actions || 0 : 0);
246
+ // Some native recovery operations are counted by the harness budget but intentionally do not
247
+ // emit a public action narrative. Preserve the larger authoritative completion count instead of
248
+ // understating coverage whenever at least one narrated action exists.
249
+ const actionsPerformed = Math.max(
250
+ actions,
251
+ base.complete && typeof base.complete === "object" ? base.complete.actions || 0 : 0,
252
+ );
212
253
  const crit = findings.filter((f) => f.severity === "critical").length;
213
254
  const high = findings.filter((f) => f.severity === "high").length;
214
255
  const med = findings.filter((f) => f.severity === "medium").length;
@@ -222,29 +263,33 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
222
263
  const verdictLow = verdictFindings.filter((f) => f.severity === "low").length;
223
264
  const sampledFindings = platform === "web" ? findings.filter((finding) => finding.evaluationTier === "sampled") : [];
224
265
 
225
- // Coverage floor: a verdict is only trustworthy if the app was actually exercised.
226
- const inconclusive = screensExplored < 2 || actionsPerformed < 3;
227
- let riskScore = Math.max(0, Math.min(100, 100 - verdictCrit * 25 - verdictHigh * 10 - verdictMed * 3));
228
- if (inconclusive) riskScore = Math.min(riskScore, 40);
229
-
230
- let verdict;
231
- if (verdictCrit > 0) verdict = "blocked";
232
- else if (inconclusive) verdict = "caution";
233
- else if (riskScore < 50) verdict = "blocked";
234
- else if (verdictHigh > 0 || riskScore < 80) verdict = "caution";
235
- else verdict = "ready";
236
-
237
- const headline = inconclusive
266
+ // Coverage floor: exploration is inconclusive if the app wasn't actually exercised. Exploration
267
+ // OBSERVES it does not render a ship verdict or score (ADR-0005). Judgment (pass/fail/
268
+ // inconclusive) is the gate's job (evaluateGate), computed from these findings + coverage + policy.
269
+ // A one-page web target can still be swept exhaustively: page errors, requests, links, assets,
270
+ // placeholder anchors, and visible controls do not require a second route. Native exploration
271
+ // retains the stronger multi-screen/action floor. A credentialless single-screen login remains
272
+ // inconclusive so a login wall can never turn into a clean pass.
273
+ const coverageFloorMet = platform === "web"
274
+ ? screensExplored >= 1 && actionsPerformed >= 1
275
+ : screensExplored >= 2 && actionsPerformed >= 3;
276
+ const credentiallessLoginWall = anySecure && !loginAttempted && screensExplored <= 1;
277
+ const inconclusive = !coverageFloorMet || credentiallessLoginWall || timeBudgetExhausted;
278
+ const stopReason = credentiallessLoginWall ? "login-wall-no-credentials"
279
+ : timeBudgetExhausted ? "time-budget-exhausted"
280
+ : coverageFloorMet ? "completed" : "coverage-floor-not-met";
281
+
282
+ const headline = timeBudgetExhausted
283
+ ? `Inconclusive — exploration reached its ${base.complete?.timeoutSeconds || "configured"}s time budget after ${actionsPerformed} action(s) across ${screensExplored} screen(s). Findings are partial; this is not an app performance finding. Increase --timeout or request fewer actions.`
284
+ : inconclusive
238
285
  ? `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.`
239
- : verdict === "ready"
286
+ : findings.length === 0
240
287
  ? platform === "web"
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."
242
- : "Ship-ready no release-blocking issues found."
243
- : verdict === "caution"
244
- ? `Proceed with caution — ${findings.length} issue(s) to review.`
245
- : `Not ready — ${findings.length} issue(s): ${crit} critical, ${high} high, ${med} medium, ${low} low.`;
288
+ ? "Automated web checks completed — no deterministic findings in the exercised surfaces. Sampled control probes are advisory. An observation, not a release decision, and not a content, privacy, brand, or business-claim review."
289
+ : "No issues surfaced in the exercised surfaces. An observation, not a release decision."
290
+ : `${findings.length} issue(s) surfaced for review (${crit} critical, ${high} high, ${med} medium, ${low} low). An observation, not a release decision.`;
246
291
 
247
- // The verdict's own honesty label: exactly which defect classes this run checked, which
292
+ // The observation's honesty label: exactly which defect classes this run checked, which
248
293
  // it structurally could NOT check, and which conditions never came up — so "checked" is
249
294
  // never claimed for a state the run didn't reach. Platform-aware: a web run doesn't
250
295
  // inherit iOS keyboard assertions and vice versa.
@@ -290,30 +335,38 @@ export function buildQaReport(markersFilePath, { platform = "ios" } = {}) {
290
335
  "content & reachability regressions require a baseline" ,
291
336
  ];
292
337
  }
293
- // "Failed sign-ins" is only a claim when a sign-in surface was actually encountered.
294
- if (anySecure) checkedFor.splice(2, 0, "failed sign-ins");
338
+ // "Failed sign-ins" is only a claim when credentials were actually submitted. Merely seeing a
339
+ // password field proves that a login surface was reached, not that authentication was exercised.
340
+ if (loginAttempted) checkedFor.splice(2, 0, "failed sign-ins");
341
+ else if (anySecure) notChecked.push("sign-in behavior (login form reached, no test credentials supplied)");
295
342
  else conditionsNotReached.push("sign-in (no login form encountered this run)");
343
+ if (timeBudgetExhausted) notChecked.push("the full requested action budget (run reached its wall-clock timeout)");
296
344
 
297
345
  return {
298
- verdict,
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,
346
+ // An ExplorationRun observation: findings + coverage + evidence, NO ship verdict or score
347
+ // (ADR-0005). The gate (evaluateGate) turns this into a pass/fail/inconclusive release outcome.
348
+ kind: "tapp-exploration-run",
349
+ schemaVersion: 1,
350
+ // Complete ExplorationRun contract (ADR-0005 §4). runStatus/stopReason describe HOW the run
351
+ // ended; coverage/evidence/uiMap/comparison are the structured observation. uiMap and comparison
352
+ // are populated by consumers that build the map / diff a baseline (null in the bare observation).
353
+ runStatus: inconclusive ? "limited" : "completed",
354
+ stopReason,
307
355
  headline,
308
356
  inconclusive,
357
+ coverage: { screensExplored, actionsPerformed, screens: Array.from(screens) },
358
+ evidence: { markers: base.relativeMarkersFilePath },
359
+ uiMap: null,
360
+ comparison: null,
309
361
  checkedFor,
310
362
  notChecked,
311
363
  conditionsNotReached,
312
364
  platform,
365
+ target: typeof target === "string" && target.trim() ? target.trim() : null,
313
366
  screensExplored,
314
367
  actionsPerformed,
315
368
  findingCounts: { critical: crit, high, medium: med, low, total: findings.length },
316
- verdictFindingCounts: {
369
+ deterministicFindingCounts: {
317
370
  critical: verdictCrit,
318
371
  high: verdictHigh,
319
372
  medium: verdictMed,
@@ -355,6 +408,7 @@ export function computeContentCollapse(currentCounts, baselineCounts) {
355
408
  type: "content_collapse",
356
409
  severity: "high",
357
410
  category: "content_collapse",
411
+ authority: "deterministic",
358
412
  title: `Screen lost most of its content (${base} → ${cur} elements)`,
359
413
  screen,
360
414
  step: null,
@@ -383,6 +437,7 @@ export function computeReachabilityLoss(current, baseline) {
383
437
  type: "screen_unreachable",
384
438
  severity: "high",
385
439
  category: "navigation_dead_end",
440
+ authority: "deterministic",
386
441
  title: "Screen explored in the baseline was never reached this run",
387
442
  screen,
388
443
  step: null,
@@ -421,15 +476,94 @@ export function computeRegression(current, baseline) {
421
476
  const newFindings = current.filter((f) => !currentMatches(f));
422
477
  const persisting = current.filter((f) => currentMatches(f));
423
478
  const resolved = baseline.filter((b) => !baselineMatched(b));
424
- const newCritical = newFindings.filter((f) => f.severity === "critical").length;
425
- const newHigh = newFindings.filter((f) => f.severity === "high").length;
426
479
 
480
+ // Comparison ONLY — no gate/pass/fail signal (ADR-0005). Exploration surfaces this diff; the merge
481
+ // decision is the gate's job. evaluateGate derives its regression fail from `newFindings` severities.
427
482
  return {
428
483
  hadBaseline: true,
429
484
  counts: { new: newFindings.length, persisting: persisting.length, resolved: resolved.length },
430
485
  newFindings,
431
486
  resolved,
432
- // CI gate: fail the build when this run introduced new high/critical findings vs. the baseline.
433
- gate: { newCritical, newHigh, failed: newCritical + newHigh > 0 },
487
+ };
488
+ }
489
+
490
+ // The gate's public outcome model (ADR-0005). A merge gate is ultimately block / don't-block, but
491
+ // callers need to distinguish WHY: a deterministic violation is not the same as "we couldn't get
492
+ // the evidence." Exit codes are the CI contract; precedence is fail > inconclusive > pass.
493
+ export const GATE_EXIT = { pass: 0, fail: 1, error: 2, inconclusive: 3 };
494
+ // Bump when the gate's decision semantics change (NOT the npm version). Recorded on every GateRun.
495
+ export const GATE_POLICY_VERSION = "2";
496
+
497
+ // Pure gate evaluator: frozen evidence + policy → a GateRun decision. Extracted verbatim from the
498
+ // former inline logic in ci-report.js so the `[char]` characterization tests keep passing — the
499
+ // merge decision (block/don't-block) is unchanged; this only classifies each reason as a
500
+ // deterministic `fail` or an evidence-absent `inconclusive` and folds them by precedence. Reason
501
+ // MESSAGES are preserved exactly (several are asserted by tests).
502
+ //
503
+ // DECOUPLED FROM THE SCORE (ADR-0005): the block-by-findings decision reads `deterministicFindingCounts` +
504
+ // `inconclusive` via `findingsBlock`, NOT the score scalar or the `verdict` label. `verdict`/
505
+ // `releaseScore` can therefore be removed from exploration output without changing any merge
506
+ // decision. The `riskScore < 50` threshold is retained deliberately (kept explicit, inside
507
+ // `findingsBlock`) and locked by the `[char]` risk-threshold test.
508
+ export function evaluateGate({ report, regression = null, flows = [], scenarios = [], contracts = [], prPlan = null, baseline = null, failOn = "gate" } = {}) {
509
+ const reasons = []; // { kind: "fail" | "inconclusive", message }
510
+ const fail = (message) => reasons.push({ kind: "fail", message });
511
+ const inconclusive = (message) => reasons.push({ kind: "inconclusive", message });
512
+
513
+ // Classify each replayed suite by evidence authority (ADR-0005): a DETERMINISTIC step failure (or
514
+ // a non-model failure like an aborted/missing run) is a real fail; a suite with no deterministic
515
+ // failure that carries a model-observed (assert_ai) assertion cannot be decided by the default
516
+ // deterministic gate → inconclusive/needs-review (it must not silently pass, and a model verdict
517
+ // must not masquerade as a deterministic fail). No `--policy probabilistic` opt-in in 0.17.
518
+ const deterministicFail = (s) => s.deterministicFailed === true || (s.passed === false && !s.modelObserved);
519
+ const classify = (s) => (deterministicFail(s) ? "fail" : s.modelObserved ? "needs-review" : "pass");
520
+ for (const [label, suites] of [["flow", flows], ["multi-actor scenario", scenarios], ["release contract", contracts]]) {
521
+ const failed = suites.filter((s) => classify(s) === "fail");
522
+ if (failed.length) fail(`${failed.length} ${label}(s) failed`);
523
+ const needsReview = suites.filter((s) => classify(s) === "needs-review");
524
+ if (needsReview.length) inconclusive(`${needsReview.length} ${label}(s) contain assert_ai (model-observed); the deterministic gate cannot decide them — review, or add an explicit probabilistic policy`);
525
+ }
526
+ // A submitted sign-in that remains on the login surface is an explicit exercised guarantee,
527
+ // not ordinary pre-existing UI debt. Letting it pass without a baseline would produce the
528
+ // contradictory public result "failed sign-in detected" + gate PASS. Keep sampled probes out,
529
+ // but fail every deterministic auth failure under every gate policy (including with a baseline).
530
+ const authFailures = (report.findings || []).filter((finding) =>
531
+ finding?.type === "auth_failed" &&
532
+ finding?.authority !== "model-observed" &&
533
+ finding?.evaluationTier !== "sampled"
534
+ );
535
+ if (authFailures.length) fail(`${authFailures.length} deterministic sign-in attempt(s) failed`);
536
+ // Selected-but-unexecuted work is missing evidence, not an observed violation → inconclusive.
537
+ if (prPlan?.execution?.notRun) inconclusive(`${prPlan.execution.notRun} selected release contract(s) did not run`);
538
+ if (prPlan?.execution?.explorationFailed) inconclusive(`${prPlan.execution.explorationFailed} planned PR exploration target(s) failed or were not reached`);
539
+
540
+ if (failOn === "any") {
541
+ if (report.findingCounts.total > 0) fail(`${report.findingCounts.total} finding(s) (fail-on: any)`);
542
+ // "any" is the strictest policy — an inconclusive run (evidence not obtained) must never pass it.
543
+ if (report.inconclusive) inconclusive("run was inconclusive (coverage floor not met)");
544
+ } else if (failOn === "absolute" || (failOn === "gate" && !regression)) {
545
+ if (findingsBlock(report.deterministicFindingCounts, { inconclusive: report.inconclusive })) fail("blocking deterministic finding(s)");
546
+ if (report.inconclusive) inconclusive("run was inconclusive (coverage floor not met)");
547
+ } else {
548
+ if (regression?.newFindings?.length) {
549
+ const newCritical = regression.newFindings.filter((f) => f.severity === "critical").length;
550
+ const newHigh = regression.newFindings.filter((f) => f.severity === "high").length;
551
+ if (newCritical + newHigh > 0) fail(`${newCritical} new critical + ${newHigh} new high vs. baseline`);
552
+ }
553
+ if (findingsBlock(report.deterministicFindingCounts, { inconclusive: report.inconclusive })) fail("blocking deterministic finding(s)");
554
+ if (report.inconclusive && !baseline?.inconclusive) inconclusive("run became inconclusive vs. baseline (app may no longer launch/explore)");
555
+ }
556
+
557
+ const outcome = reasons.some((r) => r.kind === "fail") ? "fail"
558
+ : reasons.some((r) => r.kind === "inconclusive") ? "inconclusive"
559
+ : "pass";
560
+ return {
561
+ policy: failOn,
562
+ outcome,
563
+ exitCode: GATE_EXIT[outcome],
564
+ failed: outcome !== "pass", // retained for markdown/JSON consumers during migration
565
+ reasons: reasons.map((r) => r.message),
566
+ reasonDetails: reasons,
567
+ policyVersion: GATE_POLICY_VERSION,
434
568
  };
435
569
  }
@@ -109,7 +109,7 @@ export function loadTaskRegistry({ sourcePath, projectDir = "", taskFiles = [] }
109
109
  // Draft contracts generated under `.tapp/proposals/contracts` may compile
110
110
  // against sibling untrusted Task drafts. Ordinary committed contracts never
111
111
  // see this directory, so a proposal cannot silently enter the release gate.
112
- const proposalSource = [".tapp", ".autotap"].some((directory) => String(path.resolve(sourcePath || "")).includes(`${path.sep}${directory}${path.sep}proposals${path.sep}`));
112
+ const proposalSource = String(path.resolve(sourcePath || "")).includes(`${path.sep}.tapp${path.sep}proposals${path.sep}`);
113
113
  const proposalDir = proposalSource && tappDir ? path.join(tappDir, "proposals", "tasks") : "";
114
114
  const proposed = proposalDir && fs.existsSync(proposalDir)
115
115
  ? fs.readdirSync(proposalDir).filter((name) => /\.ya?ml$|\.json$/i.test(name)).map((name) => path.join(proposalDir, name))
@@ -45,7 +45,7 @@ export function webPlaceholderLinkFindings(links = []) {
45
45
  const seen = new Set();
46
46
  for (const link of links || []) {
47
47
  const rawHref = String(link?.rawHref || "").trim().toLowerCase();
48
- const placeholder = rawHref === "#" || /^javascript:(?:void\(0\);?|;?)$/.test(rawHref);
48
+ const placeholder = rawHref === "" || rawHref === "#" || /^javascript:(?:void\(0\);?|;?)$/.test(rawHref);
49
49
  if (!placeholder || link?.handlerHint) continue;
50
50
  const label = String(link?.label || "").replace(/\s+/g, " ").trim().slice(0, 100);
51
51
  const fingerprint = String(link?.fingerprint || "link").replace(/\s+/g, " ").trim().slice(0, 100) || "link";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.16.5",
3
+ "version": "0.17.0-rc.10",
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",
@@ -87,7 +87,7 @@
87
87
  "mobile"
88
88
  ],
89
89
  "scripts": {
90
- "test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/action.test.js tests/package-surface.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js",
90
+ "test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/android-primitives-protocol.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/cloud-runner.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/action.test.js tests/package-surface.test.js tests/landing-brand.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js",
91
91
  "test:browser-journey": "node --test tests/browser-journey.test.js",
92
92
  "test:browser-native": "TAPP_RUN_NATIVE_BROWSER=1 node --test tests/browser-native-journey.test.js"
93
93
  }