@aarwitz/tapp 0.16.4 → 0.17.0-rc.1

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.
@@ -109,7 +109,7 @@ function assetPath(pathname) {
109
109
  }
110
110
 
111
111
  function captureRoot() {
112
- return path.join(process.env.TAPP_HOME || process.env.AUTOTAP_HOME || path.join(os.homedir(), ".tapp"), "captures");
112
+ return path.join(process.env.TAPP_HOME || path.join(os.homedir(), ".tapp"), "captures");
113
113
  }
114
114
 
115
115
  function capturePath(captureId, relative = "report.html") {
@@ -16,17 +16,19 @@
16
16
  // [--pr-plan <plan.json>] # selected PR contract execution manifest
17
17
  // [--project-dir <repo> --maintenance-url <url>]
18
18
  // # optional disposable web patch replay
19
- // [--fail-on <gate|blocked|any>] # default: gate
19
+ // [--fail-on <gate|absolute|any>] # default: gate
20
20
  //
21
21
  // Gate policy (--fail-on):
22
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.
23
+ // (no baseline ⇒ falls back to `absolute`), or when any suite failed. The default:
24
+ // pre-existing debt doesn't block, regressions and broken suites do.
25
+ // absolute fail on any current-run deterministic findings-block (critical / risk threshold) or an
26
+ // inconclusive run, or any failed suite no baseline needed.
27
+ // any fail on any finding at all, or any suite failure. Strictest.
27
28
  import fs from "fs";
28
29
  import path from "node:path";
29
- import { buildQaReport, computeRegression, computeContentCollapse, computeReachabilityLoss, verdictBadge } from "./report.js";
30
+ import { execSync } from "node:child_process";
31
+ import { buildQaReport, computeRegression, computeContentCollapse, computeReachabilityLoss, evaluateGate, GATE_EXIT } from "./report.js";
30
32
  import { writeHtmlReport } from "./html-report.js";
31
33
  import { buildUiMapFromMarkers, writeUiMap } from "./ui-map.js";
32
34
  import { proposeSelectorMaintenance, validateWebMaintenanceProposal } from "./maintenance-proposal.js";
@@ -59,13 +61,31 @@ function parseArgs(argv) {
59
61
  console.error("Required: --markers <ocqa-markers.txt>");
60
62
  process.exit(2);
61
63
  }
62
- if (!["gate", "blocked", "any"].includes(args.failOn)) {
63
- console.error(`--fail-on must be gate|blocked|any, got: ${args.failOn}`);
64
+ if (!["gate", "absolute", "any"].includes(args.failOn)) {
65
+ console.error(`--fail-on must be gate|absolute|any, got: ${args.failOn}`);
64
66
  process.exit(2);
65
67
  }
66
68
  return args;
67
69
  }
68
70
 
71
+ // A GateRun records the revision it judged. A commit SHA alone doesn't represent a dirty local
72
+ // checkout, so record both. Best-effort: CI env → git → nulls (never throws / fails the gate).
73
+ function gitRevision(repoDir) {
74
+ // Inspect the TARGET repository (--project-dir) when given, not the process cwd, so a gate run
75
+ // launched from elsewhere doesn't record the wrong repo's SHA. GITHUB_SHA still wins in CI.
76
+ const opts = { stdio: ["ignore", "pipe", "ignore"], ...(repoDir ? { cwd: repoDir } : {}) };
77
+ const sh = (cmd) => execSync(cmd, opts).toString().trim();
78
+ let sha = process.env.GITHUB_SHA || null;
79
+ let dirty = false;
80
+ try {
81
+ if (!sha) sha = sh("git rev-parse HEAD") || null;
82
+ dirty = sh("git status --porcelain").length > 0;
83
+ } catch {
84
+ /* not a git checkout / git unavailable */
85
+ }
86
+ return { sha, dirty };
87
+ }
88
+
69
89
  async function validateMaintenancePlan(plan, args) {
70
90
  if (!plan || plan.platform !== "web" || !args.projectDir || !args.maintenanceUrl) return plan;
71
91
  let remaining = 3;
@@ -101,7 +121,7 @@ async function validateMaintenancePlan(plan, args) {
101
121
  // Port of flow_lib.py report() / FlowRunnerService.parseReport — kept in sync deliberately.
102
122
  function parseFlowLog(logPath) {
103
123
  const name = logPath.split("/").pop().replace(/\.log$/, "");
104
- if (!fs.existsSync(logPath)) return { name, passed: false, total: 0, failed: 0, steps: [], missing: true };
124
+ if (!fs.existsSync(logPath)) return { name, passed: false, total: 0, failed: 0, steps: [], missing: true, modelObserved: false, deterministicFailed: false };
105
125
  const steps = [];
106
126
  let total = 0, executed = 0, failed = 0, passed = false, sawResult = false, flowName = null, kind = "flow", contract = "", criticality = "";
107
127
  for (const raw of fs.readFileSync(logPath, "utf8").split(/\r?\n/)) {
@@ -132,7 +152,12 @@ function parseFlowLog(logPath) {
132
152
  failed = steps.filter((s) => s.status === "fail").length;
133
153
  passed = steps.length > 0 && failed === 0;
134
154
  }
135
- return { name: flowName || name, kind, ...(contract ? { contract, criticality } : {}), passed, total, executed, failed, steps };
155
+ // Structural evidence authority (ADR-0005): assert_ai steps are model-observed. A deterministic
156
+ // step failure is real; a suite that only carries a model assertion cannot be decided by the
157
+ // default deterministic gate. evaluateGate reads these flags, never the raw action string.
158
+ const modelObserved = steps.some((s) => s.action === "assert_ai");
159
+ const deterministicFailed = steps.some((s) => s.action !== "assert_ai" && s.status === "fail");
160
+ return { name: flowName || name, kind, ...(contract ? { contract, criticality } : {}), passed, total, executed, failed, steps, modelObserved, deterministicFailed };
136
161
  }
137
162
 
138
163
  function loadBaseline(baselinePath) {
@@ -338,14 +363,20 @@ function enrichPrPlan(plan, contracts, currentUiMap = null, markersPath = "", pr
338
363
  }
339
364
 
340
365
  const SEV_ICON = { critical: "🟥", high: "🟧", medium: "🟨", low: "🟩" };
366
+ const GATE_BADGE = { pass: "🟢 PASS", fail: "🔴 FAIL", inconclusive: "🟡 INCONCLUSIVE" };
341
367
 
342
368
  function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan, gate) {
343
369
  const lines = [];
344
- lines.push(`## tapp release check${verdictBadge(report)}`);
370
+ // The header shows the GATE decision (pass/fail/inconclusive), not a ship verdict exploration
371
+ // only observes; the gate judges (ADR-0005).
372
+ lines.push(`## tapp release check — ${GATE_BADGE[gate.outcome] || gate.outcome}`);
345
373
  lines.push("");
346
374
  lines.push(report.headline);
347
375
  lines.push("");
348
- lines.push(`**release score ${report.confidence}/100** · ${report.screensExplored} screens · ${report.actionsPerformed} actions · ${report.findingCounts.total} finding(s)`);
376
+ lines.push(`${report.screensExplored} screens · ${report.actionsPerformed} actions · ${report.findingCounts.total} finding(s)`);
377
+ if (report.platform === "web") {
378
+ lines.push(`**Deterministic basis:** ${report.deterministicFindingCounts?.total || 0} deterministic finding(s); ${report.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory.`);
379
+ }
349
380
  if (report.uiMap) lines.push(`**UI Map:** ${report.uiMap.nodeCount} states · ${report.uiMap.edgeCount} transitions · ${report.uiMap.controlCount} semantic controls`);
350
381
  if (report.findings.length) {
351
382
  lines.push("");
@@ -356,11 +387,15 @@ function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan,
356
387
  }
357
388
  }
358
389
  if (regression) {
359
- const g = regression.gate;
390
+ // This is the GATE's report, so it may judge the regression. computeRegression is comparison-only,
391
+ // so derive the new-high/critical count here (mirrors evaluateGate).
392
+ const newCritical = regression.newFindings.filter((f) => f.severity === "critical").length;
393
+ const newHigh = regression.newFindings.filter((f) => f.severity === "high").length;
394
+ const regFailed = newCritical + newHigh > 0;
360
395
  lines.push("");
361
- lines.push(`### Since baseline — ${g.failed ? "🔴 regression gate FAILED" : "🟢 regression gate passed"}`);
396
+ lines.push(`### Since baseline — ${regFailed ? "🔴 regression gate FAILED" : "🟢 regression gate passed"}`);
362
397
  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**` : ""));
398
+ (regFailed ? ` — **${newCritical} new critical, ${newHigh} new high**` : ""));
364
399
  for (const f of regression.newFindings) {
365
400
  lines.push(`- NEW ${SEV_ICON[f.severity] || ""} ${f.severity}: ${f.title} (${f.screen ?? "—"})`);
366
401
  }
@@ -441,15 +476,23 @@ function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan,
441
476
  }
442
477
  }
443
478
  lines.push("");
444
- lines.push(`**Gate (${gate.policy}): ${gate.failed ? "🔴 FAIL" : "🟢 PASS"}**${gate.reasons.length ? " — " + gate.reasons.join("; ") : ""}`);
479
+ const badge = GATE_BADGE[gate.outcome] || (gate.failed ? "🔴 FAIL" : "🟢 PASS");
480
+ lines.push(`**Gate (${gate.policy}): ${badge}**${gate.reasons.length ? " — " + gate.reasons.join("; ") : ""}`);
481
+ // The gate is only authoritative about what it actually ran — record the scope explicitly.
482
+ const rev = gate.revision?.sha ? `${String(gate.revision.sha).slice(0, 12)}${gate.revision.dirty ? "-dirty" : ""}` : "unknown";
483
+ lines.push(`_target: ${gate.target || "—"} · revision: ${rev} · policy: ${gate.policy} v${gate.policyVersion || "?"}_`);
484
+ if (Array.isArray(gate.checked) && gate.checked.length) lines.push(`_Checked: ${gate.checked.join(" · ")}_`);
485
+ if (Array.isArray(gate.notChecked) && gate.notChecked.length) lines.push(`_Not checked: ${gate.notChecked.join(" · ")}_`);
445
486
  return lines.join("\n");
446
487
  }
447
488
 
448
489
  const args = parseArgs(process.argv.slice(2));
449
490
  const report = buildQaReport(args.markers, { platform: args.platform || "ios" });
450
491
  if (!report) {
451
- console.error(`No OCQA markers found at ${args.markers}the exploration did not run.`);
452
- process.exit(1);
492
+ // Required evidence could not be obtained this is inconclusive (fails closed), not a gate FAIL
493
+ // and not a usage error. See the outcome model in report.js (GATE_EXIT).
494
+ console.error(`No OCQA markers found at ${args.markers} — the exploration did not run (inconclusive).`);
495
+ process.exit(GATE_EXIT.inconclusive);
453
496
  }
454
497
  let currentUiMap = null;
455
498
  if (args.htmlDir) {
@@ -488,12 +531,12 @@ if (collapsed.length) {
488
531
  report.findings.push(...collapsed);
489
532
  report.findingCounts.high += collapsed.length;
490
533
  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).`;
534
+ report.deterministicFindingCounts.high += collapsed.length;
535
+ report.deterministicFindingCounts.total += collapsed.length;
536
+ // Collapsed/unreachable screens are deterministic regressions: they raise the deterministic finding
537
+ // counts (so findingsBlock sees them) and count as new-vs-baseline (so the gate fails on them).
538
+ // No score/verdict to mutate exploration is scoreless; the gate renders the outcome.
539
+ report.headline = `${collapsed.length} screen(s) regressed vs. baseline (content collapsed or became unreachable).`;
497
540
  }
498
541
  const regression = computeRegression(report.findings, baseline?.findings ?? null);
499
542
  const runs = args.flowLogs.map(parseFlowLog);
@@ -510,34 +553,19 @@ try {
510
553
  process.exit(2);
511
554
  }
512
555
 
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 };
556
+ // The gate decision now lives in a pure, unit-tested evaluator (report.js). A regression gate
557
+ // must catch regressions in EXPLORABILITY, not just findings: a change that makes the app crash at
558
+ // launch (or reintroduces a login wall) produces an inconclusive run with zero new findings — that
559
+ // must never pass (found via corpus bug-seeding). evaluateGate encodes that as an `inconclusive`
560
+ // outcome (exit 3), distinct from a deterministic `fail` (exit 1).
561
+ const decision = evaluateGate({ report, regression, flows, scenarios, contracts, prPlan, baseline, failOn: args.failOn });
562
+ const gate = {
563
+ ...decision,
564
+ target: args.targetKey || null,
565
+ revision: gitRevision(args.projectDir),
566
+ checked: report.checkedFor,
567
+ notChecked: report.notChecked,
568
+ };
541
569
 
542
570
  const md = renderMarkdown(report, regression, flows, scenarios, contracts, prPlan, gate);
543
571
  console.log(md);
@@ -554,4 +582,7 @@ if (args.htmlDir) {
554
582
  const html = writeHtmlReport(args.htmlDir, { report, label: args.label || "CI run" });
555
583
  if (html) console.log(`\nEvidence report: ${html}`);
556
584
  }
557
- process.exit(gate.failed ? 1 : 0);
585
+ // Outcome → exit code (ADR-0005): pass 0 · fail 1 · error 2 · inconclusive 3. fail and inconclusive
586
+ // both block a merge; distinct codes let CI tell "a regression was observed" from "we couldn't be
587
+ // sure" and keep infra/usage errors (2) separate.
588
+ process.exit(gate.exitCode);
@@ -15,7 +15,7 @@ export function targetSlug(value) {
15
15
  return slug || "target";
16
16
  }
17
17
 
18
- export function selectApplicationTarget(model, { platform = "", target = "" } = {}) {
18
+ export function selectApplicationTarget(model, { platform = "", target = "", useDefault = false } = {}) {
19
19
  if (model?.kind !== "tapp-application-model" || !Array.isArray(model.targets)) {
20
20
  throw new Error("Expected a Tapp application model; run tapp init first");
21
21
  }
@@ -26,6 +26,14 @@ export function selectApplicationTarget(model, { platform = "", target = "" } =
26
26
  const normalized = requested.replaceAll("\\", "/").replace(/^\.\//, "");
27
27
  candidates = candidates.filter((item) => [item.id, item.name, item.sourcePath].some((value) => String(value || "").replaceAll("\\", "/") === normalized));
28
28
  }
29
+ // Target-resolution ladder (ADR-0005 §5): explicit narrowing (above) → exactly one candidate →
30
+ // the model's recorded default target (opt-in: `explore` uses it, but the gate/baseline stay
31
+ // strict so CI never silently picks a target) → otherwise list the choices and stop.
32
+ if (candidates.length > 1 && useDefault && !requested) {
33
+ const def = String(model.application?.defaultTargetId || "").trim();
34
+ const chosen = def && candidates.find((item) => item.id === def);
35
+ if (chosen) return chosen;
36
+ }
29
37
  if (candidates.length !== 1) {
30
38
  const summary = candidates.length ? candidates : model.targets.filter((item) => !selectedPlatform || item.platform === selectedPlatform);
31
39
  throw new Error(candidates.length
@@ -35,6 +43,22 @@ export function selectApplicationTarget(model, { platform = "", target = "" } =
35
43
  return candidates[0];
36
44
  }
37
45
 
46
+ // For a bare `tapp explore` in a repo: if the model's default target is a web target with a recorded
47
+ // owned URL, return that URL so exploration hits it directly (the `init --url X` → `explore` path).
48
+ // Targets that must be built/started from source need the prepare pipeline (wired separately), so
49
+ // this returns null and the caller falls back to its normal target resolution.
50
+ export function defaultWebExploreUrl(model) {
51
+ let target;
52
+ try {
53
+ target = selectApplicationTarget(model, { useDefault: true });
54
+ } catch {
55
+ return null; // no model, or ambiguous with no recorded default
56
+ }
57
+ if (target?.platform !== "web") return null;
58
+ const url = String(target.runtime?.ownedUrl || "");
59
+ return /^https?:\/\//i.test(url) ? url : null;
60
+ }
61
+
38
62
  export function baselinePathForTarget(projectDir, target) {
39
63
  const root = fs.realpathSync(path.resolve(projectDir));
40
64
  return path.join(root, ".tapp", "baselines", target.platform, `${targetSlug(target.id)}.json`);
@@ -52,9 +76,15 @@ export function validateBaselineReport(report, { platform, targetId } = {}) {
52
76
  const reportTarget = String(report.targetKey || report.baselineIdentity?.targetId || "").trim();
53
77
  if (!reportTarget) throw new Error("Baseline source is missing its targetKey; Tapp will not guess which same-platform application produced the evidence");
54
78
  if (reportTarget !== targetId) throw new Error(`Baseline target '${reportTarget}' does not match application-model target '${targetId}'`);
55
- if (report.inconclusive === true) throw new Error("An inconclusive run cannot become a trusted baseline");
56
- if (report.verdict === "blocked") throw new Error("A blocked run cannot become a trusted baseline");
57
- if (!report.gate || report.gate.failed !== false) throw new Error("Baseline creation requires a successful portable gate report (gate.failed must be false)");
79
+ // A trusted baseline must be a clean PASS. The gate outcome is the single authoritative signal
80
+ // (ADR-0005) reject fail, inconclusive, error, or a missing/unknown outcome, naming which.
81
+ const outcome = report.gate?.outcome;
82
+ if (outcome !== "pass") {
83
+ const why = outcome === "inconclusive" ? "An inconclusive run"
84
+ : outcome === "fail" ? "A failing run"
85
+ : `A non-passing run (${outcome || "no gate outcome"})`;
86
+ throw new Error(`${why} cannot become a trusted baseline; only a passing gate run can`);
87
+ }
58
88
  for (const collection of ["flows", "scenarios", "contracts"]) {
59
89
  const failed = (report[collection] || []).filter((item) => item.passed !== true);
60
90
  if (failed.length) throw new Error(`Baseline source contains ${failed.length} failed ${collection}`);
@@ -64,7 +94,7 @@ export function validateBaselineReport(report, { platform, targetId } = {}) {
64
94
  platform,
65
95
  targetId,
66
96
  conclusive: true,
67
- verdict: report.verdict,
97
+ outcome: report.gate?.outcome ?? null,
68
98
  screensExplored: Number(report.screensExplored || report.screens.length),
69
99
  actionsPerformed: Number(report.actionsPerformed || 0),
70
100
  suite: {
@@ -40,7 +40,7 @@ export async function enrichFindings(findings, { backend, callModel, screens = [
40
40
  `Title: ${f.title}`;
41
41
  try {
42
42
  const res = await Promise.race([
43
- callModel(backend, { system, userText, model: process.env.TAPP_FINDING_MODEL || process.env.AUTOTAP_FINDING_MODEL || "claude-haiku-4-5-20251001", maxTokens: 300 }),
43
+ callModel(backend, { system, userText, model: process.env.TAPP_FINDING_MODEL || "claude-haiku-4-5-20251001", maxTokens: 300 }),
44
44
  new Promise((r) => setTimeout(() => r({ error: "timeout" }), TIMEOUT_MS)),
45
45
  ]);
46
46
  const parsed = res && !res.error ? parseEnrichment(res.text) : 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, observationBadge, observationSummary } 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>
105
- <div class="meta">${esc(label)} · ${r.screensExplored} screens · ${r.actionsPerformed} actions · ${r.findingCounts.total} finding(s)</div>
104
+ <h1>${esc(observationBadge(r))} <span class="dim">· ${esc(observationSummary(r))}</span></h1>
105
+ <div class="meta">${esc(label)} · evidence page (observation, not a release decision)</div>
106
+ ${r.platform === "web" ? `<div class="meta">Deterministic basis: ${r.deterministicFindingCounts?.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">