@aarwitz/tapp 0.17.0-rc.8 → 0.17.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.
@@ -90,6 +90,7 @@ function applyRuntimeTargetValidation(root, targets, validation) {
90
90
  const bundleId = String(validation.target || validation.resolution.bundleId || "").trim();
91
91
  if (!container || !scheme || !bundleId) return targets;
92
92
  const captureId = String(validation.evidence?.captureId || "").trim();
93
+ const explored = !!captureId;
93
94
  return targets.map((target) => {
94
95
  if (target.platform !== "ios" || posix(target.sourcePath) !== container) return target;
95
96
  return {
@@ -109,7 +110,7 @@ function applyRuntimeTargetValidation(root, targets, validation) {
109
110
  runtimeValidation: {
110
111
  status: "validated",
111
112
  basis: "runtime-observed",
112
- operation: "xcode-build-install-explore",
113
+ operation: explored ? "xcode-build-install-explore" : "xcode-build-install",
113
114
  target: bundleId,
114
115
  build: { container, scheme, configuration },
115
116
  evidence: {
@@ -118,7 +119,9 @@ function applyRuntimeTargetValidation(root, targets, validation) {
118
119
  findingCount: Number(validation.evidence?.findingCount || 0),
119
120
  ...(validation.evidence?.observedAt ? { observedAt: String(validation.evidence.observedAt) } : {}),
120
121
  },
121
- detail: "Tapp built this repository target with the recorded scheme, installed it, launched it, and produced UI Map evidence.",
122
+ detail: explored
123
+ ? "Tapp built this repository target with the recorded scheme, installed it, launched it, and produced UI Map evidence."
124
+ : "Tapp built this repository target with the recorded scheme and installed it on an iOS simulator.",
122
125
  },
123
126
  };
124
127
  });
@@ -133,7 +136,7 @@ function persistedTargetValidations(root, outDir) {
133
136
  if (prior?.schemaVersion !== 1 || prior.kind !== "tapp-application-model" || !Array.isArray(prior.targets)) return [];
134
137
  return prior.targets.flatMap((target) => {
135
138
  const validation = target?.runtimeValidation;
136
- if (target?.platform !== "ios" || validation?.status !== "validated" || validation?.basis !== "runtime-observed" || validation?.operation !== "xcode-build-install-explore") return [];
139
+ if (target?.platform !== "ios" || validation?.status !== "validated" || validation?.basis !== "runtime-observed" || !["xcode-build-install", "xcode-build-install-explore"].includes(validation?.operation)) return [];
137
140
  const capture = String(validation.evidence?.capture || "");
138
141
  return [{
139
142
  platform: "ios",
@@ -307,6 +310,31 @@ function loadTargetUiMaps(root, targets) {
307
310
  const scopeRoot = path.join(root, scope === "." ? "" : scope);
308
311
  const expectedPath = posix(path.join(scope === "." ? "" : scope, projectArtifactDirectory(scopeRoot), "ui-map.json"));
309
312
  let loaded = expectedPath === rootMap.summary.path ? rootMap : loadUiMapAt(root, expectedPath);
313
+ // Multiple targets can legitimately share an artifact directory (for example, a root Xcode
314
+ // project beside a root Vite app). A file being at the target's expected path does not prove
315
+ // it describes that target. Never attach a web-only map to iOS merely because both resolve to
316
+ // `.tapp/ui-map.json`; that duplicates proposals and, worse, claims runtime evidence for the
317
+ // wrong application surface.
318
+ if (loaded.map && targets.length > 1 && !uiMapTargetsTarget(loaded.map, target, targets)) {
319
+ loaded = {
320
+ map: null,
321
+ summary: {
322
+ path: expectedPath,
323
+ status: "missing",
324
+ nodeCount: 0,
325
+ edgeCount: 0,
326
+ controlCount: 0,
327
+ uncoveredNodeIds: [],
328
+ uncoveredEdgeIds: [],
329
+ platforms: [],
330
+ rejectedArtifact: {
331
+ path: loaded.summary.path,
332
+ platforms: loaded.summary.platforms || [],
333
+ reason: "artifact metadata does not identify this target",
334
+ },
335
+ },
336
+ };
337
+ }
310
338
  if (!loaded.map && rootMap.map && (targets.length === 1 || uiMapTargetsTarget(rootMap.map, target, targets))) loaded = rootMap;
311
339
  return {
312
340
  targetId: target.id,
@@ -424,7 +452,7 @@ function applicationName(root, targets) {
424
452
  return pkg?.name || (targets.length === 1 ? targets[0].name : path.basename(root));
425
453
  }
426
454
 
427
- export async function inspectApplicationRepository({ projectDir, ownedUrl = "", platform = "", targetValidation = null, outDir = ".tapp" } = {}) {
455
+ export async function inspectApplicationRepository({ projectDir, ownedUrl = "", platform = "", targetValidation = null, defaultTargetId = "", outDir = ".tapp" } = {}) {
428
456
  const root = fs.realpathSync(path.resolve(projectDir || process.cwd()));
429
457
  const inventory = walk(root);
430
458
  let targets = [
@@ -541,10 +569,10 @@ export async function inspectApplicationRepository({ projectDir, ownedUrl = "",
541
569
  const requirements = [];
542
570
  if (!targets.length) requirements.push({ id: "target", severity: "blocking", status: "missing", message: "No iOS application project, Android application module, or browser application target was detected.", remediation: "Pass the representative target explicitly or add its unavoidable build/runtime configuration." });
543
571
  for (const target of targets) {
544
- if (target.platform === "ios" && target.status !== "configured") requirements.push({ id: `${target.id}:scheme`, severity: "blocking", status: "needs-confirmation", message: `Confirm a shared build scheme for ${target.name}.`, remediation: `Run tapp build ${target.sourcePath} or provide the scheme during init/build.` });
572
+ if (target.platform === "ios" && target.status !== "configured") requirements.push({ id: `${target.id}:scheme`, severity: "blocking", status: "needs-confirmation", message: `Confirm a shared build scheme for ${target.name}.`, remediation: `Run npx -y @aarwitz/tapp@latest build ${target.sourcePath} or provide the scheme during init/build.` });
545
573
  if (target.platform === "android" && !target.runtime.applicationId) requirements.push({ id: `${target.id}:application-id`, severity: "blocking", status: "missing", message: `Android application id was not statically detected for ${target.name}.`, remediation: "Provide --app-id or expose applicationId in the application module." });
546
- if (target.platform === "web" && !target.runtime.ownedUrl && target.runtime.management !== "tapp-managed") requirements.push({ id: `${target.id}:owned-url`, severity: "blocking", status: "missing", message: `No safe managed runtime or owned URL is available for ${target.name}.`, remediation: "Add a deterministic start/static target or start the app and rerun tapp init with --url http://127.0.0.1:<port>." });
547
- if (target.platform === "web" && target.build.dependencyStatus === "missing-lockfile") requirements.push({ id: `${target.id}:dependency-lock`, severity: "blocking", status: "missing", message: `${target.name} declares browser dependencies without an observed dependency lockfile.`, remediation: "Commit the package-manager lockfile so Tapp can install dependencies reproducibly, then rerun tapp init." });
574
+ if (target.platform === "web" && !target.runtime.ownedUrl && target.runtime.management !== "tapp-managed") requirements.push({ id: `${target.id}:owned-url`, severity: "blocking", status: "missing", message: `No safe managed runtime or owned URL is available for ${target.name}.`, remediation: "Add a deterministic start/static target or start the app and rerun npx -y @aarwitz/tapp@latest init with --url http://127.0.0.1:<port>." });
575
+ if (target.platform === "web" && target.build.dependencyStatus === "missing-lockfile") requirements.push({ id: `${target.id}:dependency-lock`, severity: "blocking", status: "missing", message: `${target.name} declares browser dependencies without an observed dependency lockfile.`, remediation: "Commit the package-manager lockfile so Tapp can install dependencies reproducibly, then rerun npx -y @aarwitz/tapp@latest init." });
548
576
  }
549
577
  const incompleteMaps = uiMaps.filter((record) => record.summary.status !== "observed");
550
578
  for (const record of incompleteMaps.length ? incompleteMaps : (!targets.length && uiMap.status !== "observed" ? [{ summary: uiMap }] : [])) {
@@ -553,7 +581,7 @@ export async function inspectApplicationRepository({ projectDir, ownedUrl = "",
553
581
  requirements.push({
554
582
  id: targets.length <= 1 ? "ui-map" : `${target.id}:ui-map`, severity: "blocking", status: summary.status === "inconclusive" ? "inconclusive" : "missing",
555
583
  message: target ? (summary.status === "inconclusive" ? `The UI Map for ${target.name} is inconclusive.` : `No grounded UI Map exists for ${target.name}.`) : "No repository UI Map has been grounded in a real run.",
556
- remediation: target ? `Build/launch ${target.name}, explore the real target, and retain its map at ${summary.expectedPath || summary.path}.` : "Build/launch the target and run tapp init --explore so real exploration evidence is merged into .tapp/ui-map.json.",
584
+ remediation: target ? `Build/launch ${target.name}, explore the real target, and retain its map at ${summary.expectedPath || summary.path}.` : "Build/launch the target and run npx -y @aarwitz/tapp@latest init --explore so real exploration evidence is merged into .tapp/ui-map.json.",
557
585
  });
558
586
  }
559
587
  if (!contracts.length) requirements.push({ id: "contracts", severity: "warning", status: "missing", message: "No reviewed release contracts exist yet.", remediation: "Review the proposed release plan, then generate and validate a compact set of contracts." });
@@ -561,17 +589,20 @@ export async function inspectApplicationRepository({ projectDir, ownedUrl = "",
561
589
  for (const error of projectConfiguration.errors) requirements.push({ id: stableId("project-config-error", error), severity: "blocking", status: "invalid", message: error, remediation: `Fix ${projectConfiguration.relativePath}; actor configuration must contain environment-variable bindings, never credential values.` });
562
590
  for (const actor of actors) {
563
591
  const missingBindings = actor.credentialRequirements.filter((credential) => !actor.credentialBindings[credential]);
564
- if (missingBindings.length) requirements.push({ id: `actor:${actor.name}:credential-bindings`, severity: "blocking", status: "missing", message: `Actor '${actor.name}' has unbound credential requirements: ${missingBindings.join(", ")}.`, remediation: `Run tapp actor set ${actor.name} --credential <name>=<ENV_NAME> for each credential, then replace any literal contract credentials with $ENV_NAME placeholders.` });
592
+ if (missingBindings.length) requirements.push({ id: `actor:${actor.name}:credential-bindings`, severity: "blocking", status: "missing", message: `Actor '${actor.name}' has unbound credential requirements: ${missingBindings.join(", ")}.`, remediation: `Run npx -y @aarwitz/tapp@latest actor set ${actor.name} --credential <name>=<ENV_NAME> for each credential, then replace any literal contract credentials with $ENV_NAME placeholders.` });
565
593
  if (actor.bindingConflicts.length) requirements.push({ id: `actor:${actor.name}:credential-conflicts`, severity: "blocking", status: "conflict", message: `Actor '${actor.name}' has conflicting credential environment bindings.`, remediation: `Align ${projectConfiguration.relativePath} and reviewed contracts; Tapp will not guess which secret binding is correct.` });
566
594
  }
567
595
  for (const error of taskErrors) requirements.push({ id: stableId("task-error", error.path), severity: "blocking", status: "invalid", message: error.error, remediation: `Fix ${error.path} before generation.` });
568
596
  for (const error of contractErrors) requirements.push({ id: stableId("contract-error", error.path), severity: "blocking", status: "invalid", message: error.error, remediation: `Fix ${error.path} before trusting the release plan.` });
569
597
 
598
+ const recordedDefaultTargetId = targets.some((target) => target.id === defaultTargetId)
599
+ ? defaultTargetId
600
+ : targets.length === 1 ? targets[0].id : "";
570
601
  const model = {
571
602
  schemaVersion: 1, kind: "tapp-application-model",
572
- // defaultTargetId: which target a bare `tapp explore` prepares when the repo has several (ADR-0005
573
- // §5 resolution ladder). First detected; an explicit --target/--platform always overrides it.
574
- application: { name: applicationName(root, targets), repositoryRoot: ".", platforms: [...new Set(targets.map((target) => target.platform))].sort(), targetIds: targets.map((target) => target.id), defaultTargetId: targets[0]?.id || "" },
603
+ // defaultTargetId is recorded only when selection is unambiguous or the user explicitly chose
604
+ // a target during source-connected exploration. Detection order is not user intent.
605
+ application: { name: applicationName(root, targets), repositoryRoot: ".", platforms: [...new Set(targets.map((target) => target.platform))].sort(), targetIds: targets.map((target) => target.id), defaultTargetId: recordedDefaultTargetId },
575
606
  targets,
576
607
  actors,
577
608
  entities,
@@ -956,6 +987,45 @@ export function writeInitArtifacts({ root, model, plan, outDir = ".tapp", refres
956
987
  return { modelPath, planPath, plan: mergedPlan };
957
988
  }
958
989
 
990
+ export function findApplicationModelRoot(startPath) {
991
+ let current = path.resolve(startPath || process.cwd());
992
+ try {
993
+ if (!fs.statSync(current).isDirectory() || /\.(?:xcodeproj|xcworkspace)$/.test(current)) current = path.dirname(current);
994
+ } catch {
995
+ current = path.dirname(current);
996
+ }
997
+ while (true) {
998
+ const modelPath = path.join(current, projectArtifactDirectory(current), "application-model.json");
999
+ if (fs.existsSync(modelPath)) return current;
1000
+ const parent = path.dirname(current);
1001
+ if (parent === current) return null;
1002
+ current = parent;
1003
+ }
1004
+ }
1005
+
1006
+ // A successful standalone `tapp build` is runtime evidence that a proposed Xcode scheme is real.
1007
+ // Refresh an existing model immediately so a later `tapp init --refresh` does not forget it. This
1008
+ // deliberately does nothing before init: build remains a build command and does not mint a product
1009
+ // model unless the repository already opted into one.
1010
+ export async function persistIosBuildValidation({ projectDir, bundleId, container, scheme, configuration = "Debug" } = {}) {
1011
+ return refreshExistingInitArtifacts({
1012
+ projectDir: container || projectDir,
1013
+ targetValidation: {
1014
+ platform: "ios",
1015
+ target: bundleId,
1016
+ resolution: { kind: "xcode-build-installed", bundleId, build: { container, scheme, configuration } },
1017
+ evidence: { observedAt: new Date().toISOString() },
1018
+ },
1019
+ });
1020
+ }
1021
+
1022
+ export async function refreshExistingInitArtifacts({ projectDir, ...options } = {}) {
1023
+ const root = findApplicationModelRoot(projectDir);
1024
+ if (!root) return null;
1025
+ const built = await buildInitArtifacts({ projectDir: root, ...options });
1026
+ return { root, ...writeInitArtifacts({ ...built, refresh: true }) };
1027
+ }
1028
+
959
1029
  export function reviewReleasePlan(plan, { approve = [], reject = [], defer = [] } = {}) {
960
1030
  const choices = new Map();
961
1031
  for (const [decision, values] of Object.entries({ approved: approve, rejected: reject, deferred: defer })) {
@@ -994,7 +1064,7 @@ function edgeSupportsPlatform(edge, nodes, platform) {
994
1064
  function shortestObservedPath(map, platform, targetId, { startNodeId } = {}) {
995
1065
  const nodes = new Map((map.nodes || []).map((node) => [node.id, node]));
996
1066
  const entryId = startNodeId || map.app?.navigationRoots?.[platform] || map.app?.entryNodes?.[platform];
997
- if (!entryId || !nodes.has(entryId)) throw new Error(`UI Map has no observed ${platform} navigation root; rerun tapp init --refresh --explore before generating Tasks`);
1067
+ if (!entryId || !nodes.has(entryId)) throw new Error(`UI Map has no observed ${platform} navigation root; rerun npx -y @aarwitz/tapp@latest init --refresh --explore before generating Tasks`);
998
1068
  if (!nodes.has(targetId)) throw new Error(`UI Map proposal target '${targetId}' is no longer present`);
999
1069
  if (entryId === targetId) return [];
1000
1070
  const outgoing = new Map();
@@ -422,10 +422,10 @@ function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan,
422
422
  ? ` at \`${target.navigation.route}\``
423
423
  : target.navigation?.mode === "ui-map-path" ? ` through ${(target.navigation.steps || []).length} observed map edge(s)` : "";
424
424
  lines.push(`- ${icon} PR exploration **${target.node?.name || target.id}** — ${target.execution?.status || target.status}${navigation}`);
425
- 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 .\``);
425
+ if (target.coverageProposal?.status === "awaiting-explicit-adoption") lines.push(` - Reviewable coverage proposal ready; never auto-applied. Next: \`npx -y @aarwitz/tapp@latest pr adopt <pr-plan.json> --item ${target.id} --project-dir .\``);
426
426
  if (target.existingReleasePlanItem) {
427
427
  lines.push(` - Existing release-plan item \`${target.existingReleasePlanItem.name}\` remains ${target.existingReleasePlanItem.decision}; no duplicate or decision change was made.`);
428
- lines.push(` - Optional explicit evidence reconciliation: \`tapp pr adopt <pr-plan.json> --item ${target.id} --project-dir .\``);
428
+ lines.push(` - Optional explicit evidence reconciliation: \`npx -y @aarwitz/tapp@latest pr adopt <pr-plan.json> --item ${target.id} --project-dir .\``);
429
429
  }
430
430
  }
431
431
  for (const candidate of prPlan.maintenanceCandidates || []) {
@@ -17,7 +17,7 @@ export function targetSlug(value) {
17
17
 
18
18
  export function selectApplicationTarget(model, { platform = "", target = "", useDefault = false } = {}) {
19
19
  if (model?.kind !== "tapp-application-model" || !Array.isArray(model.targets)) {
20
- throw new Error("Expected a Tapp application model; run tapp init first");
20
+ throw new Error("Expected a Tapp application model; run npx -y @aarwitz/tapp@latest init first");
21
21
  }
22
22
  const selectedPlatform = String(platform || "").toLowerCase();
23
23
  let candidates = model.targets.filter((item) => !selectedPlatform || item.platform === selectedPlatform);
@@ -215,7 +215,7 @@ function credentialConfiguration(model, targetContracts = []) {
215
215
  if (requirements.has("email")) bindings.add("TAPP_TEST_EMAIL");
216
216
  if (requirements.has("password")) bindings.add("TAPP_TEST_PASSWORD");
217
217
  }
218
- for (const name of bindings) if (!/^[A-Z_][A-Z0-9_]{0,127}$/.test(String(name))) throw new Error(`Actor credential binding '${name}' is not a safe environment-variable name; rerun tapp init from valid .tapp/project.json`);
218
+ for (const name of bindings) if (!/^[A-Z_][A-Z0-9_]{0,127}$/.test(String(name))) throw new Error(`Actor credential binding '${name}' is not a safe environment-variable name; rerun npx -y @aarwitz/tapp@latest init from valid .tapp/project.json`);
219
219
  const inputs = {};
220
220
  const primaryEmail = primary.credentialBindings?.email || (!primary.credentialBindings && requirements.has("email") ? "TAPP_TEST_EMAIL" : "");
221
221
  const primaryPassword = primary.credentialBindings?.password || (!primary.credentialBindings && requirements.has("password") ? "TAPP_TEST_PASSWORD" : "");
@@ -241,7 +241,7 @@ function targetInputs(root, model, target) {
241
241
  const androidProjectPath = path.resolve(root, androidProject);
242
242
  const androidProjectRelative = path.relative(root, androidProjectPath);
243
243
  if (path.isAbsolute(androidProjectRelative) || androidProjectRelative === ".." || androidProjectRelative.startsWith(`..${path.sep}`)) unresolved.push("Android Gradle project resolves outside the repository");
244
- else if (!fs.existsSync(path.join(androidProjectPath, "gradlew"))) unresolved.push(`Gradle wrapper is missing from ${androidProject}; commit gradlew or rerun tapp init from the Gradle repository root`);
244
+ else if (!fs.existsSync(path.join(androidProjectPath, "gradlew"))) unresolved.push(`Gradle wrapper is missing from ${androidProject}; commit gradlew or rerun npx -y @aarwitz/tapp@latest init from the Gradle repository root`);
245
245
  inputs["android-project"] = androidProject;
246
246
  inputs["android-task"] = target.build?.task || "assembleDebug";
247
247
  if (target.runtime?.applicationId) inputs["android-app-id"] = target.runtime.applicationId;
@@ -282,7 +282,7 @@ export function renderGithubWorkflow({ projectDir, model, actionRef, defaultBran
282
282
  const root = fs.realpathSync(path.resolve(projectDir || process.cwd()));
283
283
  if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+@[A-Za-z0-9_.-]+$/.test(String(actionRef || ""))) throw new Error("--action-ref must be owner/repository@release-tag-or-sha");
284
284
  if (!/^[A-Za-z0-9._\/-]+$/.test(defaultBranch) || defaultBranch.startsWith("/")) throw new Error("--default-branch contains unsupported characters");
285
- if (model?.kind !== "tapp-application-model" || !Array.isArray(model.targets) || !model.targets.length) throw new Error("Application model has no targets; run tapp init first");
285
+ if (model?.kind !== "tapp-application-model" || !Array.isArray(model.targets) || !model.targets.length) throw new Error("Application model has no targets; run npx -y @aarwitz/tapp@latest init first");
286
286
  const occupied = new Set();
287
287
  const jobs = [];
288
288
  const manifestTargets = [];
@@ -0,0 +1,43 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export const STORAGE_BLOCK_BYTES = 256 * 1024 * 1024;
5
+ export const STORAGE_WARN_BYTES = 5 * 1024 * 1024 * 1024;
6
+
7
+ function existingAncestor(candidate) {
8
+ let current = path.resolve(candidate || process.cwd());
9
+ while (!fs.existsSync(current)) {
10
+ const parent = path.dirname(current);
11
+ if (parent === current) return null;
12
+ current = parent;
13
+ }
14
+ return current;
15
+ }
16
+
17
+ export function formatStorage(bytes) {
18
+ const gib = Number(bytes || 0) / (1024 ** 3);
19
+ return gib >= 1 ? `${gib.toFixed(gib >= 10 ? 0 : 1)} GiB` : `${Math.round(Number(bytes || 0) / (1024 ** 2))} MiB`;
20
+ }
21
+
22
+ export function storagePreflight(candidate, { statfs = fs.statfsSync, blockBytes = STORAGE_BLOCK_BYTES, warnBytes = STORAGE_WARN_BYTES } = {}) {
23
+ const checkedPath = existingAncestor(candidate);
24
+ if (!checkedPath || typeof statfs !== "function") return { ok: true, level: "unknown", path: checkedPath || path.resolve(candidate || process.cwd()), freeBytes: null };
25
+ try {
26
+ const stats = statfs(checkedPath);
27
+ const freeBytes = Number(stats.bavail ?? stats.bfree ?? 0) * Number(stats.bsize ?? 0);
28
+ const level = freeBytes < blockBytes ? "blocked" : freeBytes < warnBytes ? "warning" : "ok";
29
+ return {
30
+ ok: level !== "blocked",
31
+ level,
32
+ path: checkedPath,
33
+ freeBytes,
34
+ message: level === "blocked"
35
+ ? `Only ${formatStorage(freeBytes)} is free where Tapp writes evidence. Free disk space before testing; the run was not started and no app-crash finding was created.`
36
+ : level === "warning"
37
+ ? `Only ${formatStorage(freeBytes)} is free where Tapp writes builds and evidence; iOS builds can require several GiB.`
38
+ : `${formatStorage(freeBytes)} free for Tapp builds and evidence`,
39
+ };
40
+ } catch (error) {
41
+ return { ok: true, level: "unknown", path: checkedPath, freeBytes: null, message: `Free disk space could not be checked: ${error.message || String(error)}` };
42
+ }
43
+ }
@@ -18,7 +18,7 @@ const VALID_PLATFORMS = new Set(["web", "android", "ios"]);
18
18
 
19
19
  // Recover a capture's platform when no in-memory report is available. Prefer the run's persisted
20
20
  // metadata (ui-map.json → app.platforms) — it is authoritative and survives a renamed folder — and
21
- // fall back to the capture-id prefix (web-*/android-*, ios unprefixed) only for legacy captures that
21
+ // fall back to the capture-id prefix (ios-*/web-*/android-*, with unprefixed iOS as a legacy case) only for captures that
22
22
  // predate the map. Defaults to ios if nothing is resolvable, matching buildQaReport's own default.
23
23
  export function capturePlatform(captureDir) {
24
24
  try {
@@ -81,7 +81,7 @@ export function writeHtmlReport(captureDir, { report, label = "", recordingWarni
81
81
  .map(
82
82
  (f) => `<li>
83
83
  <span class="sev" style="background:${SEV_COLOR[f.severity] || "#57606a"}">${esc(f.severity)}</span>
84
- ${esc(f.title)}${f.screen ? ` <span class="dim">— on ${esc(f.screen)}</span>` : ""}
84
+ ${esc(f.title)}${f.screen ? ` <span class="dim">— on ${esc(f.screen)}</span>` : ""}${f.url ? ` <span class="dim">— ${esc(f.url)}</span>` : ""}
85
85
  ${f.aiAnalysis ? `<div class="ai">why: ${esc(f.aiAnalysis)}</div>` : ""}
86
86
  ${f.suggestedFix ? `<div class="ai">fix: ${esc(f.suggestedFix)}</div>` : ""}
87
87
  </li>`
@@ -138,6 +138,7 @@ ${r.conditionsNotReached?.length ? `<div><h2>Conditions not reached</h2><ul>${sc
138
138
  <div class="meta">${esc(label)} · evidence page (observation, not a release decision)</div>
139
139
  ${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>` : ""}
140
140
  <div class="headline">${esc(r.headline)}</div>
141
+ ${r.credentialWarning ? `<div class="warning">${esc(r.credentialWarning)}</div>` : ""}
141
142
  ${scopeHtml}
142
143
  <h2>Findings</h2>
143
144
  <ul class="findings">