ccqa 1.19.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/ccqa.mjs CHANGED
@@ -6779,18 +6779,16 @@ const AuditStateSchema = z.enum([
6779
6779
  "due",
6780
6780
  "clean",
6781
6781
  "drifted",
6782
- "undecided",
6783
- "cannotTell"
6782
+ "undecided"
6784
6783
  ]);
6785
6784
  /**
6786
- * Axis 2: what happened the last time this spec ran. Deliberately has no
6787
- * "stale pass" value — *which* deploy a run covered is a separate fact
6788
- * (`lastRun.deployedSha`), and collapsing the two is what left the old
6789
- * single-axis state unable to tell a red spec from an up-to-date one.
6785
+ * Axis 2: what happened the last time this spec ran, and whether that result
6786
+ * still covers what is deployed.
6790
6787
  */
6791
6788
  const ExecutionStateSchema = z.enum([
6792
6789
  "passed",
6793
6790
  "failed",
6791
+ "stale",
6794
6792
  "neverRun"
6795
6793
  ]);
6796
6794
  /**
@@ -6803,16 +6801,15 @@ const SpecVerdictSchema = z.enum([
6803
6801
  "inProgress",
6804
6802
  "needsRepair",
6805
6803
  "rerunNeeded",
6806
- "verified",
6807
- "unanswerable"
6804
+ "verified"
6808
6805
  ]);
6809
6806
  /**
6810
- * Why a spec is `unanswerable`. Always carried, so the view can name the
6811
- * missing input ("no deploy log for this profile") instead of shrugging.
6812
- * `unanswerable` is never rendered as `verified`.
6807
+ * Which hole in the deploy log made the hub assume a deploy reached this spec
6808
+ * (ADR-0014). Not a state: the verdict is already decided by the time one of
6809
+ * these is attached, and it exists so the view can say *why* a spec is pending
6810
+ * instead of leaving the reader to guess.
6813
6811
  */
6814
6812
  const RerunUnknownReasonSchema = z.enum([
6815
- "notEvaluated",
6816
6813
  "noSelectionInRange",
6817
6814
  "selectionUnknown",
6818
6815
  "noDeployLog",
@@ -6874,7 +6871,8 @@ const SpecRerunSchema = z.object({
6874
6871
  audit: AuditStateSchema,
6875
6872
  execution: ExecutionStateSchema,
6876
6873
  driftLabel: DriftLabelSchema.exclude(["UNKNOWN"]).optional(),
6877
- reason: RerunUnknownReasonSchema.optional(),
6874
+ auditAssumedReached: RerunUnknownReasonSchema.optional(),
6875
+ executionAssumedReached: RerunUnknownReasonSchema.optional(),
6878
6876
  heldBy: SpecLockSchema.nullable(),
6879
6877
  lastRun: SpecLedgerEntrySchema.nullable(),
6880
6878
  lastGreen: SpecLedgerEntrySchema.nullable(),
@@ -6884,9 +6882,10 @@ const SpecRerunSchema = z.object({
6884
6882
  });
6885
6883
  /**
6886
6884
  * Why this spec does or does not need auditing. Everything but `current` and
6887
- * `held` audits: the audit is cheap, so it errs towards doing the work where
6888
- * the run errs away from it. `held` is not an answer about freshness at all —
6889
- * another job is on it, so this one skips it and asks again next cycle.
6885
+ * `held` audits. `held` is not an answer about freshness at all another job
6886
+ * is on it, so this one skips it and asks again next cycle. `cannotTell`
6887
+ * survives here because it is a real explanation of why an audit is owed; the
6888
+ * re-run axis folds it into `due` (ADR-0014).
6890
6889
  */
6891
6890
  const AuditNeedSchema = z.object({
6892
6891
  because: z.enum([
@@ -7001,12 +7000,13 @@ const CreateLearningJobRequestSchema = z.object({
7001
7000
  * makes either flag dangerous.
7002
7001
  */
7003
7002
  /**
7004
- * The release whose hub serves these endpoints in their current shape. Both
7005
- * were reshaped together, so one number covers both an older hub either
7006
- * 404s (audit-need, which did not exist) or answers a shape the caller now
7007
- * rejects (re-run, whose fields were renamed).
7003
+ * The release whose hub serves these endpoints in their current shape — the
7004
+ * newest reshape of either, since one number is what a reader can act on. An
7005
+ * older hub either 404s (audit-need, which did not exist before 1.16) or
7006
+ * answers a vocabulary the caller now rejects (re-run, whose verdict values
7007
+ * changed in 1.20).
7008
7008
  */
7009
- const MIN_HUB_VERSION = "1.16";
7009
+ const MIN_HUB_VERSION = "1.20";
7010
7010
  /**
7011
7011
  * Which of the two 404s this was. The handlers answer `no_perspectives` when
7012
7012
  * the route exists but the project has no document; any other code on a 404
@@ -7033,7 +7033,7 @@ function requireHubProfile(flag, profile, question) {
7033
7033
  function rankedOrder(rank) {
7034
7034
  return Object.keys(rank).sort((a, b) => rank[a] - rank[b]);
7035
7035
  }
7036
- /** "3 rerunNeeded, 1 unanswerable, 12 verified" — every offered spec accounted for. */
7036
+ /** "3 rerunNeeded, 1 inProgress, 12 verified" — every offered spec accounted for. */
7037
7037
  function formatCounts(order, counts) {
7038
7038
  return order.filter((key) => counts.has(key)).map((key) => `${counts.get(key)} ${key}`).join(", ");
7039
7039
  }
@@ -7077,39 +7077,47 @@ async function fetchRerunReport(hubCtx, profile) {
7077
7077
  const SUMMARY_ORDER$1 = rankedOrder({
7078
7078
  needsRepair: 0,
7079
7079
  rerunNeeded: 1,
7080
- unanswerable: 2,
7081
- inProgress: 3,
7082
- verified: 4
7080
+ inProgress: 2,
7081
+ verified: 3
7083
7082
  });
7084
7083
  /**
7085
7084
  * Narrow `specs` to the ones the hub says are worth running.
7086
7085
  *
7087
- * `rerunNeeded` is always selected, and a spec that has never run is part of
7088
- * it a spec with no result at all is as uncovered as one whose result a
7089
- * deploy invalidated. `unanswerable` is "the question cannot be answered", so
7090
- * it is excluded by default and opted into with
7091
- * `--only-hub-rerun-needed-with-unknown` fail-open on request, never
7092
- * silently. `needsRepair`, `inProgress` and `verified` are never selected:
7093
- * running them repairs nothing, races something already in flight, or repeats
7094
- * work that is still current.
7095
- */
7096
- function selectSpecsNeedingRerun(specs, report, opts) {
7086
+ * `rerunNeeded` is always selected, and it now covers everything the hub could
7087
+ * not place: a spec that never ran, and one whose currency the deploy log
7088
+ * cannot vouch for, are both as uncovered as one a deploy demonstrably
7089
+ * invalidated (ADR-0014). `needsRepair`, `inProgress` and `verified` are never
7090
+ * selected: running them repairs nothing, races something already in flight,
7091
+ * or repeats work that is still current.
7092
+ */
7093
+ function selectSpecsNeedingRerun(specs, report) {
7097
7094
  const counts = /* @__PURE__ */ new Map();
7098
7095
  const selected = [];
7099
- let excludedUnanswerable = 0;
7100
7096
  let excludedInProgress = 0;
7097
+ let excludedUnknownToHub = 0;
7098
+ let excludedAssumedReached = 0;
7099
+ const assumedReachedReasons = /* @__PURE__ */ new Set();
7101
7100
  for (const spec of specs) {
7102
- const verdict = report.specs[specKey(spec)]?.verdict ?? "unanswerable";
7101
+ const entry = report.specs[specKey(spec)];
7102
+ const verdict = entry?.verdict ?? "inProgress";
7103
7103
  counts.set(verdict, (counts.get(verdict) ?? 0) + 1);
7104
- if (verdict === "rerunNeeded" || opts.includeUnknown && verdict === "unanswerable") selected.push(spec);
7105
- else if (verdict === "unanswerable") excludedUnanswerable++;
7106
- else if (verdict === "inProgress") excludedInProgress++;
7104
+ if (verdict === "rerunNeeded") selected.push(spec);
7105
+ else if (verdict === "inProgress") {
7106
+ excludedInProgress++;
7107
+ if (!entry) excludedUnknownToHub++;
7108
+ else if (entry.auditAssumedReached) {
7109
+ excludedAssumedReached++;
7110
+ assumedReachedReasons.add(entry.auditAssumedReached);
7111
+ }
7112
+ }
7107
7113
  }
7108
7114
  return {
7109
7115
  selected,
7110
7116
  summary: formatCounts(SUMMARY_ORDER$1, counts),
7111
- excludedUnanswerable,
7112
- excludedInProgress
7117
+ excludedInProgress,
7118
+ excludedUnknownToHub,
7119
+ excludedAssumedReached,
7120
+ excludedAssumedReachedReasons: [...assumedReachedReasons]
7113
7121
  };
7114
7122
  }
7115
7123
  //#endregion
@@ -8672,7 +8680,7 @@ const promptRm = new Command("rm").description("Delete a prompt from the hub.").
8672
8680
  info(`deleted prompt "${name}" from the hub`);
8673
8681
  }));
8674
8682
  const promptCommand = new Command("prompt").description("Manage prompt assets (per-flow user/agent guidance, triage/audit user guidance, learned calibration prompts) stored on the hub (fetched automatically by `ccqa run` / `ccqa audit` at run time).").addCommand(promptPush).addCommand(promptLs).addCommand(promptRm);
8675
- const deployRecord = new Command("record").description("Tell the hub what a deploy shipped, so it can answer which specs need a re-run (`ccqa run --only-hub-rerun-needed`). Run this from the deploy job, after the deploy succeeds. The changed paths are computed locally with a two-dot `git diff <previous> <sha>`; a job that has only curl and git can POST the same body directly (see docs/hub.md).").requiredOption("--profile <name>", "Environment this deploy landed in (e.g. 'stg'). Required: dev and stg sit at different commits, so the deploy log is per-profile.").requiredOption("--sha <sha>", "Commit that was deployed.").option("--previous <sha>", "Commit this deploy replaced. Defaults to the profile's current deploy-log head on the hub. With neither, there's nothing to diff against: changedPaths is unset and the spec selection is skipped.").option("--ref <ref>", "Ref that was deployed (branch or tag). Recorded for display only.").option("--no-select-specs", "Record the deploy without deciding which specs it reaches. The entry then becomes a hole in the range — every spec behind it answers 'unanswerable' rather than being cleared, and nothing can fill it in later, since the hub has no checkout to diff. Only pass this when no Claude credential is available; it costs one model call to leave the log answerable.").option("-m, --model <name>", "Model for the spec selection. Claude alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option(...hubUrlOption).option(...hubTokenOption).option("--project <name>", "Project whose deploy log this entry joins. Defaults to the current directory's name.").option("--cwd <path>", "Directory the git diff and the default --project name are resolved against.").action(withHubErrors(async (opts) => {
8683
+ const deployRecord = new Command("record").description("Tell the hub what a deploy shipped, so it can answer which specs need a re-run (`ccqa run --only-hub-rerun-needed`). Run this from the deploy job, after the deploy succeeds. The changed paths are computed locally with a two-dot `git diff <previous> <sha>`; a job that has only curl and git can POST the same body directly (see docs/hub.md).").requiredOption("--profile <name>", "Environment this deploy landed in (e.g. 'stg'). Required: dev and stg sit at different commits, so the deploy log is per-profile.").requiredOption("--sha <sha>", "Commit that was deployed.").option("--previous <sha>", "Commit this deploy replaced. Omit it and the hub's current log head is used — the normal case, recording no discontinuity. Pass a sha that differs from the head and the hub records one (gapBefore) in the chain: use this for a first record with a real baseline, or to re-anchor a head that no longer matches reality. With no head and nothing passed, there's nothing to diff against: changedPaths is unset and the spec selection is skipped.").option("--ref <ref>", "Ref that was deployed (branch or tag). Recorded for display only.").option("--no-select-specs", "Record the deploy without deciding which specs it reaches. The entry then becomes a hole in the range — every spec behind it is assumed reached rather than being cleared, and nothing can fill it in later, since the hub has no checkout to diff. Only pass this when no Claude credential is available; it costs one model call to leave the range clearable.").option("-m, --model <name>", "Model for the spec selection. Claude alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option(...hubUrlOption).option(...hubTokenOption).option("--project <name>", "Project whose deploy log this entry joins. Defaults to the current directory's name.").option("--cwd <path>", "Directory the git diff and the default --project name are resolved against.").action(withHubErrors(async (opts) => {
8676
8684
  const cwd = resolveCwd(opts.cwd);
8677
8685
  const project = resolveProject(opts);
8678
8686
  const hub = connect(opts);
@@ -8695,9 +8703,9 @@ const deployRecord = new Command("record").description("Tell the hub what a depl
8695
8703
  meta("previous", previous ? previous.slice(0, 12) : "(none)");
8696
8704
  meta("changed paths", changedPaths === null ? "(not reported)" : String(changedPaths.length));
8697
8705
  if (opts.selectSpecs !== false) meta("selection", describeSelection(selection, diff !== null));
8698
- if (entry.gapBefore) warn("this deploy does not chain onto the log head, so a gap is recorded — specs whose baseline sits behind it report 'unanswerable' rather than 'verified'");
8706
+ if (entry.gapBefore) warn("this deploy does not chain onto the log head, so a gap is recorded — specs whose baseline sits behind it are assumed reached rather than cleared to 'verified'");
8699
8707
  if (selection && !entry.hasSelection) {
8700
- error("the hub recorded this deploy but could not store its spec selection — the range answers 'unanswerable' from here on, and nothing fills it in later. Re-record this deploy.");
8708
+ error("the hub recorded this deploy but could not store its spec selection — every spec behind it is assumed reached from here on, and nothing fills it in later. Re-record this deploy.");
8701
8709
  process.exit(1);
8702
8710
  }
8703
8711
  info(`recorded deploy #${entry.index}`);
@@ -12193,7 +12201,6 @@ async function executeRun(targets, opts) {
12193
12201
  const filtering = Boolean(opts.onlyAffectedBy || opts.onlyHubRerunNeeded);
12194
12202
  if (filtering && targets.length > 0) throw new RunUsageError("a --only-* filter and an explicit spec target cannot be combined");
12195
12203
  const rerunProfile = opts.onlyHubRerunNeeded === true ? requireRerunProfile(opts.hubProfile) : null;
12196
- if (opts.onlyHubRerunNeededWithUnknown && rerunProfile === null) warn("--only-hub-rerun-needed-with-unknown is ignored: it only applies to --only-hub-rerun-needed");
12197
12204
  const forExecution = opts.dryRun !== true;
12198
12205
  const cwd = opts.cwd ?? process.cwd();
12199
12206
  const wantsLastGreen = opts.onFailExplain === true && opts.onFailExplainBase === void 0;
@@ -12281,13 +12288,17 @@ async function executeRun(targets, opts) {
12281
12288
  let specs = dedupeSpecs((await Promise.all((targets.length ? targets : [void 0]).map((t) => resolveSpecTargets(t, enumerateAll, cwd)))).flat());
12282
12289
  if (filtering) {
12283
12290
  const before = specs.length;
12284
- let unanswerable = 0;
12285
12291
  let inProgress = 0;
12292
+ let unknownToHub = 0;
12293
+ let assumedReached = 0;
12294
+ let assumedReachedReasons = [];
12286
12295
  if (rerunReport) {
12287
- const selection = selectSpecsNeedingRerun(specs, rerunReport, { includeUnknown: opts.onlyHubRerunNeededWithUnknown === true });
12296
+ const selection = selectSpecsNeedingRerun(specs, rerunReport);
12288
12297
  specs = selection.selected;
12289
- unanswerable = selection.excludedUnanswerable;
12290
12298
  inProgress = selection.excludedInProgress;
12299
+ unknownToHub = selection.excludedUnknownToHub;
12300
+ assumedReached = selection.excludedAssumedReached;
12301
+ assumedReachedReasons = selection.excludedAssumedReachedReasons;
12291
12302
  meta("stale-base", `deploy ${rerunReport.deployHead.sha.slice(0, 12)} (profile ${rerunReport.profile})`);
12292
12303
  meta("stale-states", selection.summary);
12293
12304
  }
@@ -12297,9 +12308,11 @@ async function executeRun(targets, opts) {
12297
12308
  ...opts.model ? { model: opts.model } : {}
12298
12309
  })).specs;
12299
12310
  meta("selected", `${specs.length} of ${before} spec${before === 1 ? "" : "s"}`);
12300
- if (specs.length === 0 && (unanswerable > 0 || inProgress > 0)) {
12301
- if (unanswerable > 0) hint(`${unanswerable} spec(s) were excluded because the hub could not tell whether they need a re-run; pass --only-hub-rerun-needed-with-unknown to run them anyway`);
12302
- if (inProgress > 0) hint(`${inProgress} spec(s) were excluded because the audit has not answered for the deployed commit yet; run \`ccqa audit --only-hub-audit-needed --report-to-hub\` first`);
12311
+ if (specs.length === 0 && inProgress > 0) {
12312
+ if (unknownToHub > 0) hint(`${unknownToHub} spec(s) were excluded because the hub's perspectives document has never heard of them; run \`ccqa perspectives\` to register them`);
12313
+ if (assumedReached > 0) hint(`${assumedReached} spec(s) were excluded because the audit ran at a commit the deploy log cannot place (${assumedReachedReasons.join(", ")}); re-running \`ccqa audit --only-hub-audit-needed\` at the same commit will not clear it — record the deploy (\`ccqa hub deploy record\`) so the log can place it, or select with --only-affected-by <ref> to make progress meanwhile`);
12314
+ const explained = unknownToHub + assumedReached;
12315
+ if (explained < inProgress) hint(`${inProgress - explained} spec(s) were excluded because the audit has not answered for the deployed commit yet; run \`ccqa audit --only-hub-audit-needed --report-to-hub\` first`);
12303
12316
  throw new RunUsageError("nothing was selected and no spec was cleared to run: exiting non-zero rather than reporting a green run that verified nothing");
12304
12317
  }
12305
12318
  }
@@ -13053,7 +13066,7 @@ function installTeardownSignalHandlers(teardown) {
13053
13066
  }
13054
13067
  //#endregion
13055
13068
  //#region src/cli/run.ts
13056
- const runCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("run").argument("[targets...]", "Specs to run, space-separated: each '<feature>/<spec>', '<feature>', or omit for all. Duplicates are de-duped.").description("Run specs, on any target. Agent-browser specs replay the recorded test.spec.ts under vitest (default), or, with spec.yaml `mode: live`, have Claude drive agent-browser live per step. External-target specs (playwright, runn) run through the target's configured `runCommand`. A structured report (report.json + evidence) is always written; use --report-to-hub to also stream it to a hub.").optionsGroup("Which specs to run:").option("--only-affected-by <ref>", "Only specs `ccqa select-specs` judges reached by the diff against <ref> (e.g. origin/main). In pull_request CI, pass $GITHUB_BASE_REF. Cannot be combined with an explicit spec id.").option("--only-hub-rerun-needed", "Only specs the hub answers `rerunNeeded` for: the audit cleared them, and their last result does not cover what is deployed. A spec whose audit has not caught up answers `inProgress`, and one the audit rejected or whose last run failed answers `needsRepair`; neither is taken, because running them races the audit or repairs nothing. No git diff involved. Requires a hub connection and --hub-profile.").option("--only-hub-rerun-needed-with-unknown", "With --only-hub-rerun-needed: also take specs the hub cannot answer for at all ('unanswerable' — a hole in the deploy log). Off by default: an unanswerable question is reported, not guessed.").option("--dry-run", "Print the specs this invocation would run, then exit 0 without executing anything and without writing a report. Works with every selection flag.").optionsGroup("How to run them:").option("--concurrency <n>", "Run up to N specs in parallel within each phase (deterministic / external-target / live), never across phases. Default 1 (sequential). Specs in the same `serialGroups` entry of .ccqa/config.yaml still take turns. Live specs each get an isolated agent-browser session; high values spawn many headed Chrome instances.", parseConcurrency$1, 1).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--live-step-retry <n>", "(live only) Retry each failed step up to N more times before recording failure. This retries a step, not the whole spec — see --on-fail-explain-rerun for that.", (raw) => {
13069
+ const runCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("run").argument("[targets...]", "Specs to run, space-separated: each '<feature>/<spec>', '<feature>', or omit for all. Duplicates are de-duped.").description("Run specs, on any target. Agent-browser specs replay the recorded test.spec.ts under vitest (default), or, with spec.yaml `mode: live`, have Claude drive agent-browser live per step. External-target specs (playwright, runn) run through the target's configured `runCommand`. A structured report (report.json + evidence) is always written; use --report-to-hub to also stream it to a hub.").optionsGroup("Which specs to run:").option("--only-affected-by <ref>", "Only specs `ccqa select-specs` judges reached by the diff against <ref> (e.g. origin/main). In pull_request CI, pass $GITHUB_BASE_REF. Cannot be combined with an explicit spec id.").option("--only-hub-rerun-needed", "Only specs the hub answers `rerunNeeded` for: the audit cleared them, and their last result does not cover what is deployed — including every spec the deploy log cannot place, which is assumed reached rather than skipped. A spec whose audit has not caught up answers `inProgress`, and one the audit rejected or whose last run failed answers `needsRepair`; neither is taken, because running them races the audit or repairs nothing. No git diff involved. Requires a hub connection and --hub-profile.").option("--dry-run", "Print the specs this invocation would run, then exit 0 without executing anything and without writing a report. Works with every selection flag.").optionsGroup("How to run them:").option("--concurrency <n>", "Run up to N specs in parallel within each phase (deterministic / external-target / live), never across phases. Default 1 (sequential). Specs in the same `serialGroups` entry of .ccqa/config.yaml still take turns. Live specs each get an isolated agent-browser session; high values spawn many headed Chrome instances.", parseConcurrency$1, 1).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--live-step-retry <n>", "(live only) Retry each failed step up to N more times before recording failure. This retries a step, not the whole spec — see --on-fail-explain-rerun for that.", (raw) => {
13057
13070
  const n = Number(raw);
13058
13071
  if (!Number.isFinite(n) || n < 0 || Math.floor(n) !== n) throw new Error(`--live-step-retry must be a non-negative integer, got "${raw}"`);
13059
13072
  return n;
@@ -15466,9 +15479,9 @@ function buildRange(log, touchIndex) {
15466
15479
  }
15467
15480
  function freshness(baselineSha, key, range) {
15468
15481
  const { log, positionBySha, touchIndex } = range;
15469
- if (log.entries.length === 0) return unanswerable$1("noDeployLog");
15482
+ if (log.entries.length === 0) return unanswerable("noDeployLog");
15470
15483
  const baselinePos = positionBySha.get(baselineSha);
15471
- if (baselinePos === void 0) return unanswerable$1("deployedShaNotInLog");
15484
+ if (baselinePos === void 0) return unanswerable("deployedShaNotInLog");
15472
15485
  const baselineIndex = log.entries[baselinePos].index;
15473
15486
  const touch = touchIndex[key];
15474
15487
  const touched = touch?.needed;
@@ -15480,9 +15493,9 @@ function freshness(baselineSha, key, range) {
15480
15493
  touchedByDeploy: entry ? deployRef(entry) : null
15481
15494
  };
15482
15495
  }
15483
- if (range.gapFromPos[baselinePos + 1]) return unanswerable$1("gapInRange");
15484
- if (range.noSelectionFromPos[baselinePos + 1]) return unanswerable$1("noSelectionInRange");
15485
- if (touch?.undecidedIndex !== void 0 && touch.undecidedIndex > baselineIndex) return unanswerable$1("selectionUnknown");
15496
+ if (range.gapFromPos[baselinePos + 1]) return unanswerable("gapInRange");
15497
+ if (range.noSelectionFromPos[baselinePos + 1]) return unanswerable("noSelectionInRange");
15498
+ if (touch?.undecidedIndex !== void 0 && touch.undecidedIndex > baselineIndex) return unanswerable("selectionUnknown");
15486
15499
  return { kind: "current" };
15487
15500
  }
15488
15501
  function deployRef(entry) {
@@ -15492,7 +15505,7 @@ function deployRef(entry) {
15492
15505
  at: entry.at
15493
15506
  };
15494
15507
  }
15495
- function unanswerable$1(reason) {
15508
+ function unanswerable(reason) {
15496
15509
  return {
15497
15510
  kind: "unanswerable",
15498
15511
  reason
@@ -17778,7 +17791,6 @@ function createReleaseLocksHandler(storage) {
17778
17791
  function computeRerun(input) {
17779
17792
  const { specs, ledger, log, touchIndex, drift, locks, now } = input;
17780
17793
  const range = buildRange(log, touchIndex);
17781
- const nothingRecorded = log.entries.length === 0 && Object.keys(ledger.run).length === 0 && Object.keys(ledger.green).length === 0;
17782
17794
  const out = {};
17783
17795
  for (const spec of specs) {
17784
17796
  const coords = {
@@ -17787,16 +17799,12 @@ function computeRerun(input) {
17787
17799
  lastRed: ledger.red[spec.key] ?? null
17788
17800
  };
17789
17801
  const audit = auditState(drift, spec.key, range);
17790
- const execution = executionState(coords);
17802
+ const execution = executionState(coords, (sha) => freshness(sha, spec.key, range));
17791
17803
  const held = heldBy(locks, spec.key, now);
17792
- const derived = nothingRecorded ? {
17793
- verdict: "unanswerable",
17794
- reason: "notEvaluated"
17795
- } : decide(audit, execution, held, coords.lastRun, (sha) => freshness(sha, spec.key, range));
17796
17804
  out[spec.key] = {
17797
- ...derived,
17805
+ verdict: decide(audit.audit, execution.execution, held),
17798
17806
  ...audit,
17799
- execution,
17807
+ ...execution,
17800
17808
  heldBy: held,
17801
17809
  ...coords
17802
17810
  };
@@ -17807,6 +17815,10 @@ function computeRerun(input) {
17807
17815
  * Axis 1, derived from the same freshness answer `--only-hub-audit-needed`
17808
17816
  * reads. The label only speaks once the audit is known to be current: a
17809
17817
  * verdict about an older commit says nothing about the one running now.
17818
+ *
17819
+ * A commit the log cannot place is `due` like any other: a deploy we cannot
17820
+ * rule out is treated as having landed (ADR-0014). The hole is kept as an
17821
+ * annotation so the answer stays explicable.
17810
17822
  */
17811
17823
  function auditState(drift, key, range) {
17812
17824
  const need = auditNeed(drift, key, range);
@@ -17814,8 +17826,8 @@ function auditState(drift, key, range) {
17814
17826
  case "neverAudited":
17815
17827
  case "deployReached": return { audit: "due" };
17816
17828
  case "cannotTell": return {
17817
- audit: "cannotTell",
17818
- ...need.reason ? { reason: need.reason } : {}
17829
+ audit: "due",
17830
+ ...need.reason ? { auditAssumedReached: need.reason } : {}
17819
17831
  };
17820
17832
  case "current": {
17821
17833
  const label = drift.specs[key].label;
@@ -17833,56 +17845,57 @@ function auditState(drift, key, range) {
17833
17845
  }
17834
17846
  }
17835
17847
  /**
17836
- * Axis 2. The red bucket is compared by run id rather than by timestamp
17837
- * because both buckets advance from the same terminal-run trigger, so the run
17838
- * that wrote `run` wrote exactly one of `green` or `red`.
17839
- */
17840
- function executionState(coords) {
17841
- if (!coords.lastRun) return "neverRun";
17842
- if (coords.lastRed && coords.lastRed.runId === coords.lastRun.runId) return "failed";
17843
- return "passed";
17848
+ * Axis 2: how the last run ended, and whether a deploy has overtaken it. The
17849
+ * red bucket is compared by run id rather than by timestamp because both
17850
+ * buckets advance from the same terminal-run trigger, so the run that wrote
17851
+ * `run` wrote exactly one of `green` or `red`.
17852
+ *
17853
+ * `failed` outranks `stale`: a red result is current information whatever has
17854
+ * deployed since, and re-running it teaches nothing until someone repairs it.
17855
+ */
17856
+ function executionState(coords, since) {
17857
+ const { lastRun, lastRed } = coords;
17858
+ if (!lastRun) return { execution: "neverRun" };
17859
+ if (lastRed && lastRed.runId === lastRun.runId) return { execution: "failed" };
17860
+ if (lastRun.deployedShaAmbiguous) return assumedReached("ambiguousDeployedSha");
17861
+ if (!lastRun.deployedSha) return assumedReached("unknownDeployedSha");
17862
+ const answer = since(lastRun.deployedSha);
17863
+ if (answer.kind === "unanswerable") return assumedReached(answer.reason);
17864
+ if (answer.kind === "current") return { execution: "passed" };
17865
+ return {
17866
+ execution: "stale",
17867
+ ...answer.touchedBy ? { touchedBy: answer.touchedBy } : {},
17868
+ touchedByDeploy: answer.touchedByDeploy
17869
+ };
17870
+ }
17871
+ function assumedReached(reason) {
17872
+ return {
17873
+ execution: "stale",
17874
+ executionAssumedReached: reason
17875
+ };
17844
17876
  }
17845
17877
  /**
17846
- * The derived answer, evaluated in order. Order is the whole design: a failed
17847
- * spec is answered before its age is considered, because re-running it teaches
17848
- * nothing until the code it exercises moves.
17878
+ * The derived answer: a total function of the two axes plus whether a job
17879
+ * holds the spec. Nothing else is consulted an axis that needed a third
17880
+ * input to be read is an axis that cannot be shown next to the verdict as its
17881
+ * reason.
17849
17882
  */
17850
- function decide(audit, execution, held, lastRun, since) {
17851
- if (held) return { verdict: "inProgress" };
17852
- switch (audit.audit) {
17853
- case "cannotTell": return unanswerable(audit.reason);
17854
- case "due": return { verdict: "inProgress" };
17883
+ function decide(audit, execution, held) {
17884
+ if (held) return "inProgress";
17885
+ switch (audit) {
17886
+ case "due": return "inProgress";
17855
17887
  case "drifted":
17856
- case "undecided": return { verdict: "needsRepair" };
17888
+ case "undecided": return "needsRepair";
17857
17889
  case "clean": break;
17858
- default: {
17859
- const unreachable = audit.audit;
17860
- throw new Error(`unhandled audit state: ${String(unreachable)}`);
17861
- }
17890
+ default: throw new Error(`unhandled audit state: ${String(audit)}`);
17862
17891
  }
17863
17892
  switch (execution) {
17864
- case "failed": return { verdict: "needsRepair" };
17865
- case "passed":
17866
- case "neverRun": break;
17893
+ case "failed": return "needsRepair";
17894
+ case "stale":
17895
+ case "neverRun": return "rerunNeeded";
17896
+ case "passed": return "verified";
17867
17897
  default: throw new Error(`unhandled execution state: ${String(execution)}`);
17868
17898
  }
17869
- if (!lastRun) return { verdict: "rerunNeeded" };
17870
- if (lastRun.deployedShaAmbiguous) return unanswerable("ambiguousDeployedSha");
17871
- if (!lastRun.deployedSha) return unanswerable("unknownDeployedSha");
17872
- const answer = since(lastRun.deployedSha);
17873
- if (answer.kind === "unanswerable") return unanswerable(answer.reason);
17874
- if (answer.kind === "current") return { verdict: "verified" };
17875
- return {
17876
- verdict: "rerunNeeded",
17877
- ...answer.touchedBy ? { touchedBy: answer.touchedBy } : {},
17878
- touchedByDeploy: answer.touchedByDeploy
17879
- };
17880
- }
17881
- function unanswerable(reason) {
17882
- return {
17883
- verdict: "unanswerable",
17884
- reason
17885
- };
17886
17899
  }
17887
17900
  //#endregion
17888
17901
  //#region src/hub/api/handlers/rerun.ts
@@ -18601,17 +18614,30 @@ const HTML_BODY = `
18601
18614
  <div id="persp-body" hidden>
18602
18615
  <div class="ov" id="persp-ov"></div>
18603
18616
  <div class="note info persp-note" id="persp-rerun-note" hidden></div>
18604
- <div class="note info persp-note" id="persp-drift-note" hidden></div>
18605
18617
  <div class="toolbar">
18606
18618
  <label class="search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg><input id="persp-q" type="search" data-i18n-ph="perspectives.search" aria-label="Search cases"></label>
18607
18619
  <!-- Each chip carries the count of what it would leave behind, so
18608
18620
  data-i18n sits on the inner label span: applyStaticI18n swaps
18609
- textContent, which on the button would delete the count. -->
18610
- <button class="fchip" data-f="all" aria-pressed="true" type="button"><span data-i18n="perspectives.filter.all">All</span><span class="fcount"></span></button>
18611
- <button class="fchip" data-f="deterministic" aria-pressed="false" type="button"><span data-i18n="perspectives.filter.deterministic">Deterministic</span><span class="fcount"></span></button>
18612
- <button class="fchip" data-f="live" aria-pressed="false" type="button"><span data-i18n="perspectives.filter.live">Live</span><span class="fcount"></span></button>
18613
- <button class="fchip" id="persp-chip-rerun" data-f="rerun" aria-pressed="false" type="button" hidden><span data-i18n="perspectives.filter.rerun">Needs re-run only</span><span class="fcount"></span></button>
18614
- <button class="fchip" id="persp-chip-drift" data-f="drift" aria-pressed="false" type="button" hidden><span data-i18n="perspectives.filter.drift">Drift found only</span><span class="fcount"></span></button>
18621
+ textContent, which on the button would delete the count. Two
18622
+ labelled groups rather than one row: mode and verdict are
18623
+ different questions, and mixing their chips together read as
18624
+ one. -->
18625
+ <div class="fgroup">
18626
+ <span class="fgroup-label" data-i18n="perspectives.filter.group.mode">Mode</span>
18627
+ <button class="fchip" data-f="all" aria-pressed="true" type="button"><span data-i18n="perspectives.filter.all">All</span><span class="fcount"></span></button>
18628
+ <button class="fchip" data-f="deterministic" aria-pressed="false" type="button"><span data-i18n="perspectives.filter.deterministic">Deterministic</span><span class="fcount"></span></button>
18629
+ <button class="fchip" data-f="live" aria-pressed="false" type="button"><span data-i18n="perspectives.filter.live">Live</span><span class="fcount"></span></button>
18630
+ </div>
18631
+ <!-- Same words as the 判定 column (perspectives.rerun.state.*) and
18632
+ the same group label as its header (perspectives.col.verdict)
18633
+ — a filter chip must never coin its own name for a verdict. -->
18634
+ <div class="fgroup" id="persp-verdict-chips" hidden>
18635
+ <span class="fgroup-label" data-i18n="perspectives.col.verdict">Verdict</span>
18636
+ <button class="fchip" id="persp-chip-needsrepair" data-f="needsRepair" aria-pressed="false" type="button"><span data-i18n="perspectives.rerun.state.needsRepair">Needs repair</span><span class="fcount"></span></button>
18637
+ <button class="fchip" id="persp-chip-rerunneeded" data-f="rerunNeeded" aria-pressed="false" type="button"><span data-i18n="perspectives.rerun.state.rerunNeeded">Re-run needed</span><span class="fcount"></span></button>
18638
+ <button class="fchip" id="persp-chip-inprogress" data-f="inProgress" aria-pressed="false" type="button"><span data-i18n="perspectives.rerun.state.inProgress">In progress</span><span class="fcount"></span></button>
18639
+ <button class="fchip" id="persp-chip-verified" data-f="verified" aria-pressed="false" type="button"><span data-i18n="perspectives.rerun.state.verified">Verified</span><span class="fcount"></span></button>
18640
+ </div>
18615
18641
  <div class="spacer"></div>
18616
18642
  <span class="muted persp-head" id="persp-deploy-head" hidden></span>
18617
18643
  <div class="sw-wrap" id="persp-profile-wrap">
@@ -18623,7 +18649,7 @@ const HTML_BODY = `
18623
18649
  </div>
18624
18650
  </div>
18625
18651
  <div class="tblcard"><div class="table-wrap"><table>
18626
- <thead><tr><th data-i18n="perspectives.col.case">Case</th><th data-i18n="perspectives.col.mode">Mode</th><th id="persp-th-verdict" data-i18n="perspectives.col.verdict" hidden>Next</th><th id="persp-th-audit" data-i18n="perspectives.col.audit" hidden>Audit</th><th id="persp-th-run" data-i18n="perspectives.col.run" hidden>Execution</th><th id="persp-th-drift" data-i18n="perspectives.col.drift" hidden>Drift audit</th><th></th></tr></thead>
18652
+ <thead><tr><th data-i18n="perspectives.col.case">Case</th><th data-i18n="perspectives.col.mode">Mode</th><th id="persp-th-verdict" data-i18n="perspectives.col.verdict" hidden>Verdict</th><th id="persp-th-run" data-i18n="perspectives.col.run" hidden>Execution</th><th id="persp-th-audit" data-i18n="perspectives.col.audit" hidden>Audit</th><th></th></tr></thead>
18627
18653
  <tbody id="persp-tbody"></tbody>
18628
18654
  </table></div></div>
18629
18655
  <p class="empty-note" id="persp-no-hit" hidden data-i18n="perspectives.noHit">No matching cases.</p>
@@ -19294,13 +19320,15 @@ const CSS = `
19294
19320
  existing badge/chip primitives above, so no new tokens are needed.
19295
19321
 
19296
19322
  The summary row answers the question this tab exists for — which cases
19297
- need re-running — as one inventory line plus one bar segmented by re-run
19298
- state. The mode and recorded-ness counts moved onto the filter chips,
19299
- which is where a count says something actionable. */
19323
+ need attention — as one inventory line plus one bar per axis: verdict,
19324
+ execution, audit, the same three groupings the table's columns show. The
19325
+ mode and recorded-ness counts moved onto the filter chips, which is
19326
+ where a count says something actionable. */
19300
19327
  .ov { display: flex; flex-direction: column; gap: 10px; padding: 14px 18px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); margin-bottom: 16px; }
19301
19328
  .ov-inv { font-size: 13px; color: var(--muted); }
19302
19329
  .ov-inv b { color: var(--fg); font-size: 15px; font-weight: 650; font-variant-numeric: tabular-nums; }
19303
- /* One row per overview axis (re-run, drift): a short label above its own bar+legend. */
19330
+ /* One row per overview axis (verdict, execution, audit): a short label
19331
+ reusing that axis's own column header text — above its own bar+legend. */
19304
19332
  .ov-axis { display: flex; flex-direction: column; gap: 4px; }
19305
19333
  .ov-axis-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted-2); }
19306
19334
  .rrbar { height: 8px; border-radius: 999px; overflow: hidden; display: flex; background: var(--surface-3); }
@@ -19308,22 +19336,25 @@ const CSS = `
19308
19336
  .rrleg span { display: inline-flex; align-items: center; gap: 6px; }
19309
19337
  .rrleg i { width: 8px; height: 8px; border-radius: 50%; flex: none; }
19310
19338
  .rrleg b { color: var(--fg); font-weight: 600; font-variant-numeric: tabular-nums; }
19311
- /* One class per verdict, worn by both the bar segment and its legend dot.
19312
- "unanswerable" takes the info hue: it must never be mistaken for a pass.
19313
- Drift's own three states share these colours rather than inventing a
19314
- second palette same reasoning as the badge classes below. Careful
19315
- writing in here: a star followed by a slash closes the comment early and
19316
- silently eats the next rule, and a backtick ends the template literal this
19317
- CSS lives in. */
19318
- .sg-drift-found, .sg-rerunneeded { background: var(--amber-fill); }
19319
- .sg-needsrepair { background: var(--fail); }
19320
- .sg-unanswerable, .sg-drift-unknown { background: var(--info); }
19321
- .sg-drift-clean, .sg-verified { background: var(--pass); }
19322
- .sg-drift-none, .sg-inprogress { background: var(--muted-2); }
19339
+ /* One class per state, worn by both the bar segment and its legend dot.
19340
+ The execution and audit axes share these colours with the verdict axis
19341
+ rather than inventing two more palettes same reasoning as the badge
19342
+ classes below. Careful writing in here: a star followed by a slash
19343
+ closes the comment early and silently eats the next rule, and a
19344
+ backtick ends the template literal this CSS lives in. */
19345
+ .sg-rerunneeded, .sg-exec-stale { background: var(--amber-fill); }
19346
+ .sg-needsrepair, .sg-audit-drifted, .sg-exec-failed { background: var(--fail); }
19347
+ .sg-audit-undecided { background: var(--info); }
19348
+ .sg-verified, .sg-audit-clean, .sg-exec-passed { background: var(--pass); }
19349
+ .sg-inprogress, .sg-audit-due, .sg-exec-never { background: var(--muted-2); }
19323
19350
 
19324
19351
  .search { flex: 1; min-width: 200px; max-width: 340px; display: flex; align-items: center; gap: 7px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 0 10px; height: 32px; background: var(--surface); }
19325
19352
  .search svg { width: 15px; height: 15px; flex: none; color: var(--muted-2); }
19326
19353
  .search input { border: none; outline: none; font: inherit; font-size: 13px; width: 100%; background: transparent; color: var(--fg); }
19354
+ /* A labelled cluster of chips (mode, verdict) rather than one flat row —
19355
+ the label says what question the chips beside it answer. */
19356
+ .fgroup { display: inline-flex; align-items: center; gap: 6px; }
19357
+ .fgroup-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted-2); margin-right: 2px; }
19327
19358
  .fchip { border: 1px solid var(--border-strong); background: var(--surface); border-radius: 999px; padding: 5px 12px; font-size: 12.5px; color: var(--muted); }
19328
19359
  .fchip[aria-pressed="true"] { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
19329
19360
  .fchip .fcount { margin-left: 6px; font-variant-numeric: tabular-nums; color: var(--muted-2); }
@@ -19335,35 +19366,24 @@ const CSS = `
19335
19366
  .badge.norec { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
19336
19367
  .badge.norec .d { background: var(--amber); }
19337
19368
 
19338
- /* "Needs re-run" (ADR-0010). Four distinct looks on purpose: rr-unknown must
19339
- never be mistaken for rr-notneeded, so it takes the info hue rather than a
19340
- dimmed green, and every badge is paired with a .cellsub saying what the
19341
- verdict rests on.
19342
-
19343
- The drift column (dr-*) answers a different question — does the spec
19344
- still describe the code, not whether the last result is stale — but
19345
- reuses these same three hues so the two columns read as siblings rather
19346
- than inventing a fourth palette: dr-found (a diagnosis exists) shares
19347
- rr-needed's amber, dr-clean (audited, no drift) shares rr-notneeded's
19348
- green, dr-none (never audited) shares rr-none's grey. */
19349
- .badge.rr-needed, .badge.dr-found { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
19350
- .badge.rr-needed .d, .badge.dr-found .d { background: var(--amber); }
19351
- .badge.rr-notneeded, .badge.dr-clean { background: var(--pass-bg); color: var(--pass); border-color: var(--pass-border); }
19352
- .badge.rr-notneeded .d, .badge.dr-clean .d { background: var(--pass); }
19353
- /* Same blue on both axes: "we cannot say" means the same thing whether the
19354
- question is re-run or drift, and a reader should not have to relearn it. */
19355
- .badge.rr-unknown, .badge.dr-unknown { background: var(--info-bg); color: var(--info); border-color: var(--info-border); }
19356
- .badge.rr-unknown .d, .badge.dr-unknown .d { background: var(--info); }
19357
- .badge.rr-none, .badge.dr-none { background: var(--surface-3); color: var(--muted); border-color: var(--border); }
19358
- .badge.rr-none .d, .badge.dr-none .d { background: var(--muted); }
19369
+ /* Shared by the verdict and audit columns (ADR-0010, ADR-0014): rr-unknown
19370
+ must never be mistaken for a clean rr-none, so it takes the info hue
19371
+ rather than a dimmed grey, and every badge is paired with a .cellsub
19372
+ saying what it rests on. */
19373
+ .badge.rr-needed { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
19374
+ .badge.rr-needed .d { background: var(--amber); }
19375
+ .badge.rr-unknown { background: var(--info-bg); color: var(--info); border-color: var(--info-border); }
19376
+ .badge.rr-unknown .d { background: var(--info); }
19377
+ .badge.rr-none { background: var(--surface-3); color: var(--muted); border-color: var(--border); }
19378
+ .badge.rr-none .d { background: var(--muted); }
19359
19379
  .cellsub { display: block; margin-top: 3px; max-width: 260px; color: var(--muted); font-size: 11.5px; line-height: 1.45; }
19360
19380
  .graded-mark { color: var(--fg-dim); font-weight: 600; }
19361
19381
  .cellsub a { color: var(--muted); text-decoration: none; border-bottom: 1px dotted var(--border-strong); }
19362
19382
  .cellsub a:hover { color: var(--fg); }
19363
19383
  .persp-note { margin-bottom: 12px; }
19364
19384
  .persp-head { font-size: 12px; white-space: nowrap; }
19365
- /* The Perspectives toolbar carries search + five chips + the profile
19366
- selector, so it wraps instead of overflowing on a narrow window. */
19385
+ /* The Perspectives toolbar carries search + two filter-chip groups + the
19386
+ profile selector, so it wraps instead of overflowing on a narrow window. */
19367
19387
  #view-perspectives .toolbar { flex-wrap: wrap; }
19368
19388
  .proj-menu.right { left: auto; right: 0; }
19369
19389
  .d-note { margin-top: 12px; max-width: 900px; }
@@ -19510,15 +19530,14 @@ const CLIENT_JS = `
19510
19530
  "perspectives.search": "Search cases…",
19511
19531
  "perspectives.filter.all": "All", "perspectives.filter.deterministic": "Deterministic",
19512
19532
  "perspectives.filter.live": "Live",
19513
- "perspectives.filter.rerun": "Needs re-run only", "perspectives.filter.drift": "Drift found only",
19514
- "perspectives.col.verdict": "Next", "perspectives.col.audit": "Audit",
19515
- "perspectives.col.run": "Execution", "perspectives.col.drift": "Drift audit",
19533
+ "perspectives.filter.group.mode": "Mode",
19534
+ "perspectives.col.verdict": "Verdict", "perspectives.col.audit": "Audit",
19535
+ "perspectives.col.run": "Execution",
19516
19536
  "perspectives.audit.state.due": "Audit due",
19517
19537
  "perspectives.audit.state.clean": "Describes the code",
19518
19538
  "perspectives.audit.state.drifted": "Drifted",
19519
19539
  "perspectives.audit.state.undecided": "Couldn't tell",
19520
- "perspectives.audit.state.cannotTell": "Can't tell",
19521
- "perspectives.run.state.needed": "re-run needed", "perspectives.run.state.unknown": "can't tell",
19540
+ "perspectives.run.state.superseded": "pending",
19522
19541
  "perspectives.run.state.failed": "failed", "perspectives.run.state.passed": "passed",
19523
19542
  "perspectives.run.state.never": "never run",
19524
19543
  "perspectives.col.case": "Case", "perspectives.col.mode": "Mode",
@@ -19528,7 +19547,6 @@ const CLIENT_JS = `
19528
19547
  "perspectives.loadFailed": "Loading perspectives failed",
19529
19548
  "perspectives.mode.deterministic": "deterministic", "perspectives.mode.live": "live",
19530
19549
  "perspectives.ov.cases": "cases", "perspectives.ov.features": "features",
19531
- "perspectives.ov.axis.rerun": "Re-run", "perspectives.ov.axis.drift": "Drift",
19532
19550
  "perspectives.d.preconditions": "Preconditions", "perspectives.d.startScreen": "Start screen",
19533
19551
  "perspectives.d.testCondition": "Condition", "perspectives.d.spec": "spec",
19534
19552
  "perspectives.note.label": "Note",
@@ -19537,12 +19555,11 @@ const CLIENT_JS = `
19537
19555
  "perspectives.note.error": "Could not save — retry",
19538
19556
  "perspectives.d.lastRed": "Most recent failure",
19539
19557
  "perspectives.d.changedSince": "Changes since the last run",
19540
- "perspectives.d.cannotJudge": "Why this cannot be judged",
19558
+ "perspectives.d.whyVerdict": "Why this verdict",
19541
19559
  "perspectives.result.openRun": "Open this run in the hub",
19542
19560
  "perspectives.result.ci": "CI",
19543
19561
  "perspectives.rerun.state.needsRepair": "Needs repair",
19544
19562
  "perspectives.rerun.state.rerunNeeded": "Re-run needed",
19545
- "perspectives.rerun.state.unanswerable": "Can't tell",
19546
19563
  "perspectives.rerun.state.inProgress": "In progress",
19547
19564
  "perspectives.rerun.state.verified": "Verified",
19548
19565
  "perspectives.rerun.vsDeploy": "judged against deploy",
@@ -19553,11 +19570,11 @@ const CLIENT_JS = `
19553
19570
  "perspectives.rerun.touchedCount": "{n} deployed path(s) matched this case",
19554
19571
  "perspectives.rerun.touchedUnknown": "a deploy since the last run matched this case",
19555
19572
  "perspectives.rerun.inProgressHint": "an audit or a run is still going, or the audit has not caught up with the deploy",
19573
+ "perspectives.rerun.heldHint": "another job already holds this spec — acting on it now would race that job",
19556
19574
  "perspectives.rerun.repair.testDrift": "the generated test no longer matches the code — re-record it",
19557
19575
  "perspectives.rerun.repair.specChange": "the spec describes something the code no longer does — a human decides",
19558
19576
  "perspectives.rerun.repair.auditUndecided": "the audit read the code and could not decide — a human looks",
19559
19577
  "perspectives.rerun.repair.runFailed": "the last run failed — re-running it changes nothing until the cause is fixed",
19560
- "perspectives.rerun.why.notEvaluated": "no run and no deploy has ever been recorded for this profile",
19561
19578
  "perspectives.rerun.why.noSelectionInRange": "a deploy in range was recorded without a spec selection",
19562
19579
  "perspectives.rerun.why.selectionUnknown": "the selector could not tell whether this case was affected",
19563
19580
  "perspectives.rerun.why.noDeployLog": "no deploy log for this profile",
@@ -19566,7 +19583,6 @@ const CLIENT_JS = `
19566
19583
  "perspectives.rerun.why.deployedShaNotInLog": "the last run's commit predates the retained deploy log",
19567
19584
  "perspectives.rerun.why.gapInRange": "deploys are missing from the range",
19568
19585
  "perspectives.rerun.why.unrecognized": "this hub reported a reason this UI does not recognise",
19569
- "perspectives.rerun.fix.notEvaluated": "Nothing has been recorded for this profile at all. Wire ccqa hub deploy record into the deploy job and push a run report, so there is something to compare against.",
19570
19586
  "perspectives.rerun.fix.noSelectionInRange": "A deploy in range was recorded without a spec selection, so nothing says whether it affected this case. Run ccqa select-specs in the deploy job and send its verdict with the deploy.",
19571
19587
  "perspectives.rerun.fix.selectionUnknown": "A deploy in range was judged, but the selector could not decide this case. Re-run it to get a clean baseline.",
19572
19588
  "perspectives.rerun.fix.noDeployLog": "Nothing has been recorded in this profile's deploy log. Wire ccqa hub deploy record into the deploy job for this environment so ccqa knows what shipped.",
@@ -19579,12 +19595,7 @@ const CLIENT_JS = `
19579
19595
  "perspectives.rerun.loadFailed": "Loading re-run data failed",
19580
19596
  "perspectives.rerun.noDeployLogBanner": "No deploy has been recorded for profile {profile}, so no case can be judged. Wire ccqa hub deploy record into the deploy job for this environment.",
19581
19597
  "perspectives.rerun.deployHead": "deploy head",
19582
- "perspectives.drift.state.notAudited": "Not audited",
19583
- "perspectives.drift.state.clean": "No drift",
19584
- "perspectives.drift.state.found": "Drift found", "perspectives.drift.state.unknown": "Can't tell",
19585
19598
  "perspectives.drift.graded": "confirmed",
19586
- "perspectives.drift.unsupported": "This hub does not report drift audit results. Upgrade the hub to enable it.",
19587
- "perspectives.drift.loadFailed": "Loading drift data failed",
19588
19599
  "prompt.card.record": "Recording browser actions",
19589
19600
  "prompt.card.live": "Live run (AI-driven)",
19590
19601
  "prompt.card.playwright": "Playwright test generation",
@@ -19676,15 +19687,14 @@ const CLIENT_JS = `
19676
19687
  "perspectives.search": "ケースを検索…",
19677
19688
  "perspectives.filter.all": "すべて", "perspectives.filter.deterministic": "決定的",
19678
19689
  "perspectives.filter.live": "ライブ",
19679
- "perspectives.filter.rerun": "要再実行のみ", "perspectives.filter.drift": "ズレありのみ",
19680
- "perspectives.col.verdict": "次にすること", "perspectives.col.audit": "監査",
19681
- "perspectives.col.run": "実行", "perspectives.col.drift": "ドリフト監査",
19690
+ "perspectives.filter.group.mode": "モード",
19691
+ "perspectives.col.verdict": "判定", "perspectives.col.audit": "監査",
19692
+ "perspectives.col.run": "実行",
19682
19693
  "perspectives.audit.state.due": "監査待ち",
19683
- "perspectives.audit.state.clean": "ずれなし",
19684
- "perspectives.audit.state.drifted": "ずれあり",
19694
+ "perspectives.audit.state.clean": "ズレなし",
19695
+ "perspectives.audit.state.drifted": "ズレあり",
19685
19696
  "perspectives.audit.state.undecided": "判定不能",
19686
- "perspectives.audit.state.cannotTell": "判定できない",
19687
- "perspectives.run.state.needed": "要再実行", "perspectives.run.state.unknown": "判定できない",
19697
+ "perspectives.run.state.superseded": "実行待ち",
19688
19698
  "perspectives.run.state.failed": "失敗", "perspectives.run.state.passed": "合格",
19689
19699
  "perspectives.run.state.never": "未実行",
19690
19700
  "perspectives.col.case": "ケース", "perspectives.col.mode": "モード",
@@ -19694,7 +19704,6 @@ const CLIENT_JS = `
19694
19704
  "perspectives.loadFailed": "テスト観点の読み込みに失敗しました",
19695
19705
  "perspectives.mode.deterministic": "決定的", "perspectives.mode.live": "ライブ",
19696
19706
  "perspectives.ov.cases": "ケース", "perspectives.ov.features": "機能",
19697
- "perspectives.ov.axis.rerun": "実行", "perspectives.ov.axis.drift": "ドリフト",
19698
19707
  "perspectives.d.preconditions": "前提条件", "perspectives.d.startScreen": "開始画面",
19699
19708
  "perspectives.d.testCondition": "実行条件", "perspectives.d.spec": "spec",
19700
19709
  "perspectives.note.label": "note",
@@ -19703,12 +19712,11 @@ const CLIENT_JS = `
19703
19712
  "perspectives.note.error": "保存に失敗しました — 再試行してください",
19704
19713
  "perspectives.d.lastRed": "直近の失敗",
19705
19714
  "perspectives.d.changedSince": "前回実行以降の変更",
19706
- "perspectives.d.cannotJudge": "判定できない理由",
19715
+ "perspectives.d.whyVerdict": "この判定の理由",
19707
19716
  "perspectives.result.openRun": "ハブでこの実行を開く",
19708
19717
  "perspectives.result.ci": "CI",
19709
19718
  "perspectives.rerun.state.needsRepair": "修正待ち",
19710
19719
  "perspectives.rerun.state.rerunNeeded": "要再実行",
19711
- "perspectives.rerun.state.unanswerable": "判定できない",
19712
19720
  "perspectives.rerun.state.inProgress": "進行中",
19713
19721
  "perspectives.rerun.state.verified": "検証済み",
19714
19722
  "perspectives.rerun.vsDeploy": "判定基準: デプロイ",
@@ -19719,11 +19727,11 @@ const CLIENT_JS = `
19719
19727
  "perspectives.rerun.touchedCount": "このケースに一致したデプロイ差分 {n} 件",
19720
19728
  "perspectives.rerun.touchedUnknown": "前回実行以降のデプロイがこのケースに一致する変更を行っています",
19721
19729
  "perspectives.rerun.inProgressHint": "監査か実行がまだ走っているか、監査がデプロイに追いついていません",
19730
+ "perspectives.rerun.heldHint": "このスペックは既に別のジョブが保持しています。今操作するとそのジョブと競合します",
19722
19731
  "perspectives.rerun.repair.testDrift": "生成されたテストが古くなっています。録り直してください",
19723
19732
  "perspectives.rerun.repair.specChange": "spec がコードのやめた動作を書いています。人が判断します",
19724
19733
  "perspectives.rerun.repair.auditUndecided": "監査がコードを読んだうえで判定できませんでした。人が見ます",
19725
19734
  "perspectives.rerun.repair.runFailed": "最後の実行が落ちています。原因を直すまで再実行しても変わりません",
19726
- "perspectives.rerun.why.notEvaluated": "このプロファイルには実行もデプロイも記録がありません",
19727
19735
  "perspectives.rerun.why.noSelectionInRange": "対象範囲に判定を伴わないデプロイがあります",
19728
19736
  "perspectives.rerun.why.selectionUnknown": "影響の有無を判定できませんでした",
19729
19737
  "perspectives.rerun.why.noDeployLog": "このプロファイルのデプロイ記録がありません",
@@ -19732,7 +19740,6 @@ const CLIENT_JS = `
19732
19740
  "perspectives.rerun.why.deployedShaNotInLog": "前回実行のcommitが保持中のデプロイログより古いです",
19733
19741
  "perspectives.rerun.why.gapInRange": "対象範囲のデプロイ記録が欠けています",
19734
19742
  "perspectives.rerun.why.unrecognized": "このUIが認識できない理由がハブから返されました",
19735
- "perspectives.rerun.fix.notEvaluated": "このプロファイルには何も記録がありません。デプロイジョブに ccqa hub deploy record を組み込み、実行レポートを送ってください。比較する対象がそこで初めて生まれます。",
19736
19743
  "perspectives.rerun.fix.noSelectionInRange": "対象範囲に判定を伴わないデプロイがあり、このケースに影響したかどうかを示すものがありません。デプロイジョブで ccqa select-specs を実行し、判定をデプロイと一緒に送ってください。",
19737
19744
  "perspectives.rerun.fix.selectionUnknown": "対象範囲のデプロイは判定されましたが、このケースについては判断がつきませんでした。再実行して基準を取り直してください。",
19738
19745
  "perspectives.rerun.fix.noDeployLog": "このプロファイルのデプロイログに記録がありません。何がデプロイされたかをccqaに伝えるため、この環境のデプロイジョブに ccqa hub deploy record を組み込んでください。",
@@ -19745,12 +19752,7 @@ const CLIENT_JS = `
19745
19752
  "perspectives.rerun.loadFailed": "再実行の要否の読み込みに失敗しました",
19746
19753
  "perspectives.rerun.noDeployLogBanner": "プロファイル {profile} にデプロイの記録がないため、どのケースも判定できません。この環境のデプロイジョブに ccqa hub deploy record を組み込んでください。",
19747
19754
  "perspectives.rerun.deployHead": "最新デプロイ",
19748
- "perspectives.drift.state.notAudited": "未監査",
19749
- "perspectives.drift.state.clean": "ズレなし",
19750
- "perspectives.drift.state.found": "ズレあり", "perspectives.drift.state.unknown": "判定できない",
19751
19755
  "perspectives.drift.graded": "人が確認",
19752
- "perspectives.drift.unsupported": "このハブはdrift監査結果を返しません。利用するにはハブを更新してください。",
19753
- "perspectives.drift.loadFailed": "drift監査結果の読み込みに失敗しました",
19754
19756
  "prompt.card.record": "ブラウザ操作の記録",
19755
19757
  "prompt.card.live": "ライブ実行(AI操作)",
19756
19758
  "prompt.card.playwright": "Playwrightテスト生成",
@@ -21612,13 +21614,14 @@ const CLIENT_JS = `
21612
21614
 
21613
21615
  // "rerun" is the RerunReport for the currently selected profile, or null when
21614
21616
  // this hub can't answer (older hub, or the fetch failed) — in which case the
21615
- // two re-run columns are dropped rather than filled with blanks.
21617
+ // three re-run columns are dropped rather than filled with blanks.
21616
21618
  // "rerunSupported" is tri-state: null until the first answer, then whether
21617
21619
  // this hub answers at all. Chip visibility follows it rather than the report,
21618
21620
  // so switching profile doesn't drop the filter while the next one loads.
21619
21621
  // "drift" is the DriftLedgerResponse, or null when unanswered (older hub, or
21620
21622
  // a failed fetch) — not profile-scoped, so it does not reset when the
21621
- // profile switcher changes (unlike "rerun" above).
21623
+ // profile switcher changes (unlike "rerun" above). Only feeds the audit
21624
+ // column's "audited at" line now; its own finding is superseded by rr.audit.
21622
21625
  var perspState = {
21623
21626
  doc: null, q: "", f: "all",
21624
21627
  rerun: null, rerunSupported: null, runUrls: {}, rerunProfiles: [],
@@ -21655,11 +21658,11 @@ const CLIENT_JS = `
21655
21658
  "/rerun?profile=" + encodeURIComponent(state.profile);
21656
21659
  }
21657
21660
 
21658
- // Shared by rerun and drift, which differ only in path and i18n prefix.
21659
21661
  // Resolves { report } or { note } and never rejects: a hub that predates
21660
- // the endpoint costs only that column, not the whole tab. A 404 here can
21661
- // only mean "no such route" — the endpoint's own 404 is "the project has no
21662
- // perspectives document", and this runs only after that document loaded.
21662
+ // the endpoint costs only the columns it feeds, not the whole tab. A 404
21663
+ // here can only mean "no such route" — the endpoint's own 404 is "the
21664
+ // project has no perspectives document", and this runs only after that
21665
+ // document loaded.
21663
21666
  function fetchLedgerColumn(path, i18nPrefix) {
21664
21667
  return fetch(path, { headers: { Authorization: "Bearer " + state.token } }).then(function (res) {
21665
21668
  if (res.status === 404) return { note: t(i18nPrefix + "unsupported"), kind: "info" };
@@ -21674,7 +21677,10 @@ const CLIENT_JS = `
21674
21677
 
21675
21678
  // ── perspectives: drift ledger ────────────────────────────────────────
21676
21679
  // Not profile-scoped (see perspState.drift above), so unlike rerunPath this
21677
- // takes no ?profile=.
21680
+ // takes no ?profile=. Its own finding is superseded by the audit axis in
21681
+ // the /rerun report (ADR-0014); what survives into the view is only its
21682
+ // coordinate — when a spec was last audited — folded into the audit
21683
+ // column's evidence line (perspAuditCell).
21678
21684
 
21679
21685
  function driftPath() {
21680
21686
  return "/api/v1/projects/" + encodeURIComponent(state.project) + "/drift";
@@ -21726,16 +21732,25 @@ const CLIENT_JS = `
21726
21732
  return text === prefix + reason ? t(prefix + "unrecognized") : text;
21727
21733
  }
21728
21734
 
21729
- // Why the question cannot be answered, in the actionable phrasing the detail
21730
- // panel wants: name the missing input and how to supply it. Only
21731
- // "unanswerable" carries a machine-readable reason (ADR-0010).
21732
- // Every verdict that carries no evidence row explains itself here. A verdict
21733
- // a newer hub invented falls through to the fix lookup, so the row says the
21734
- // UI cannot read it rather than going blank which looks like missing data.
21735
- function rerunCannotJudge(rr) {
21736
- if (rr.verdict === "unanswerable") return rerunReasonText("perspectives.rerun.fix.", rr.reason || "");
21735
+ // Every verdict that carries no evidence row explains itself here, in the
21736
+ // actionable phrasing the detail panel wants. decide() checks heldBy before
21737
+ // the audit axis, so a spec another job already holds must be explained by
21738
+ // that hold, not by the audit-hole annotation below which only applies to
21739
+ // an "inProgress" verdict that decide() reached by falling through to a due
21740
+ // audit (ADR-0014). A verdict a newer hub invented falls through to the fix
21741
+ // lookup, so the row says the UI cannot read it rather than going blank —
21742
+ // which looks like missing data.
21743
+ function rerunWhyVerdict(rr) {
21737
21744
  if (rr.verdict === "needsRepair") return rerunReasonText("perspectives.rerun.repair.", rerunRepairCause(rr));
21738
- if (rr.verdict === "inProgress") return t("perspectives.rerun.inProgressHint");
21745
+ if (rr.verdict === "rerunNeeded" && rr.executionAssumedReached) {
21746
+ return rerunReasonText("perspectives.rerun.fix.", rr.executionAssumedReached);
21747
+ }
21748
+ if (rr.verdict === "inProgress") {
21749
+ if (rr.heldBy) return t("perspectives.rerun.heldHint");
21750
+ return rr.auditAssumedReached
21751
+ ? rerunReasonText("perspectives.rerun.fix.", rr.auditAssumedReached)
21752
+ : t("perspectives.rerun.inProgressHint");
21753
+ }
21739
21754
  return rerunReasonText("perspectives.rerun.fix.", rr.verdict);
21740
21755
  }
21741
21756
 
@@ -21750,10 +21765,12 @@ const CLIENT_JS = `
21750
21765
 
21751
21766
  // The short justification a table cell carries under its badge. Nothing here
21752
21767
  // may collapse to a bare "up to date" — verified names the deploy it was
21753
- // judged against, and unanswerable names the missing input.
21768
+ // judged against, and a spec assumed reached names the hole that made it so
21769
+ // rather than claiming a deploy matched it.
21754
21770
  function rerunCellWhy(rr) {
21755
21771
  var head = perspState.rerun && perspState.rerun.deployHead;
21756
21772
  if (rr.verdict === "rerunNeeded") {
21773
+ if (rr.executionAssumedReached) return rerunReasonText("perspectives.rerun.why.", rr.executionAssumedReached);
21757
21774
  if (!rr.touchedBy || !rr.touchedBy.length) return t("perspectives.rerun.touchedUnknown");
21758
21775
  return t("perspectives.rerun.touchedCount").replace("{n}", String(rr.touchedBy.length));
21759
21776
  }
@@ -21761,8 +21778,7 @@ const CLIENT_JS = `
21761
21778
  if (!head) return t("perspectives.rerun.noDeployHead");
21762
21779
  return t("perspectives.rerun.vsDeploy") + " " + shortSha(head.sha) + " · " + relTime(head.at);
21763
21780
  }
21764
- if (rr.verdict === "unanswerable") return rerunReasonText("perspectives.rerun.why.", rr.reason || "");
21765
- return rerunCannotJudge(rr);
21781
+ return rerunWhyVerdict(rr);
21766
21782
  }
21767
21783
 
21768
21784
 
@@ -21775,27 +21791,26 @@ const CLIENT_JS = `
21775
21791
  // Bar segments in drawing order: what a person must act on first, then what
21776
21792
  // the pipeline still owes, then what needs nothing. "needsRepair" leads
21777
21793
  // because it is the only verdict a run cannot clear — someone has to repair
21778
- // the spec or the product. "unanswerable" keeps its own place and its own
21779
- // colour: folding it into "verified" would turn "we cannot say" into "all
21780
- // clear", which is what ADR-0010 forbids.
21781
- var RERUN_ORDER = ["needsRepair", "rerunNeeded", "unanswerable", "inProgress", "verified"];
21794
+ // the spec or the product.
21795
+ var RERUN_ORDER = ["needsRepair", "rerunNeeded", "inProgress", "verified"];
21782
21796
  var RERUN_SEG_CLASS = {
21783
- needsRepair: "sg-needsrepair", rerunNeeded: "sg-rerunneeded", unanswerable: "sg-unanswerable",
21797
+ needsRepair: "sg-needsrepair", rerunNeeded: "sg-rerunneeded",
21784
21798
  inProgress: "sg-inprogress", verified: "sg-verified"
21785
21799
  };
21786
21800
 
21787
- // One verdict per case, bucketed. A case with no verdict at all this hub
21788
- // does not answer the question, the fetch failed, or the case was added
21789
- // after the report was computed is unanswerable, never "verified". A
21790
- // verdict this UI does not know reads the same way for the same reason: an
21791
- // answer we cannot interpret is not evidence that nothing is needed.
21801
+ // The one rule the summary bar and the verdict filter chips both answer
21802
+ // to: a case with no verdict, or a verdict a newer hub invented, counts as
21803
+ // needing a rununanswered means unverified (ADR-0014). Both call this
21804
+ // rather than each keeping its own copy of the fallback.
21805
+ function rerunVerdictOf(rr) {
21806
+ var v = rr && rr.verdict;
21807
+ return v && RERUN_ORDER.indexOf(v) !== -1 ? v : "rerunNeeded";
21808
+ }
21809
+
21810
+ // One verdict per case, bucketed, via rerunVerdictOf above.
21792
21811
  function rerunComposition(verdicts) {
21793
- var counts = { needsRepair: 0, rerunNeeded: 0, unanswerable: 0, inProgress: 0, verified: 0 };
21794
- verdicts.forEach(function (rr) {
21795
- if (!rr || !rr.verdict) { counts.unanswerable += 1; return; }
21796
- var known = Object.prototype.hasOwnProperty.call(counts, rr.verdict);
21797
- counts[known ? rr.verdict : "unanswerable"] += 1;
21798
- });
21812
+ var counts = { needsRepair: 0, rerunNeeded: 0, inProgress: 0, verified: 0 };
21813
+ verdicts.forEach(function (rr) { counts[rerunVerdictOf(rr)] += 1; });
21799
21814
  return counts;
21800
21815
  }
21801
21816
 
@@ -21811,52 +21826,69 @@ const CLIENT_JS = `
21811
21826
  }
21812
21827
  // --- end pure: rerun composition -----------------------------------------
21813
21828
 
21814
- // --- pure: drift composition ----------------------------------------------
21815
- // Same shape as rerun composition above, and self-contained for the same
21816
- // reason: drift-overview.test.ts lifts this region out and runs it directly.
21817
- // Unlike rerun, drift does not split by label at the overview level — each
21818
- // spec carries at most one diagnosis already. UNKNOWN is the exception, and
21819
- // it earns its own state rather than a label: it is the audit saying it could
21820
- // not tell, so counting it as "drift found" would assert a mismatch nobody
21821
- // established, and counting it as clean would hide one. Four states, three of
21822
- // which the ledger distinguishes structurally:
21823
- //
21824
- // no entry → never audited
21825
- // null label → audited, nothing found
21826
- // UNKNOWN label audited, could not tell
21827
- // other label → audited, drift found
21828
- function driftState(entry) {
21829
- if (!entry) return "notAudited";
21830
- if (!entry.label) return "clean";
21831
- return entry.label === "UNKNOWN" ? "unknown" : "found";
21829
+ // --- pure: execution composition ------------------------------------------
21830
+ // Same shape as rerun composition above, self-contained for the same
21831
+ // reason, and read from the same rr records: bucketed by the execution
21832
+ // axis rather than the derived verdict. The mapping duplicates
21833
+ // perspRunState's two renames (neverRun -> never, stale -> superseded)
21834
+ // rather than calling it, so this region stays independently liftable.
21835
+ var EXEC_ORDER = ["failed", "superseded", "never", "passed"];
21836
+ var EXEC_SEG_CLASS = {
21837
+ failed: "sg-exec-failed", superseded: "sg-exec-stale",
21838
+ never: "sg-exec-never", passed: "sg-exec-passed"
21839
+ };
21840
+
21841
+ // A case with no verdict at all reads as never run — same "unanswered
21842
+ // means unverified" rule rerunComposition applies, and the safe direction:
21843
+ // it never inflates "passed".
21844
+ function executionComposition(records) {
21845
+ var counts = { failed: 0, superseded: 0, never: 0, passed: 0 };
21846
+ records.forEach(function (rr) {
21847
+ var exec = rr && rr.execution;
21848
+ var key = !exec || exec === "neverRun" ? "never" : exec === "stale" ? "superseded" : exec;
21849
+ counts[Object.prototype.hasOwnProperty.call(counts, key) ? key : "never"] += 1;
21850
+ });
21851
+ return counts;
21832
21852
  }
21833
21853
 
21834
- // Drawing order: what needs a look first, then what could not be judged, then
21835
- // what was never audited, then confirmed clean — same reasoning as
21836
- // RERUN_ORDER above.
21837
- var DRIFT_ORDER = ["found", "unknown", "notAudited", "clean"];
21838
- var DRIFT_SEG_CLASS = {
21839
- found: "sg-drift-found",
21840
- unknown: "sg-drift-unknown",
21841
- notAudited: "sg-drift-none",
21842
- clean: "sg-drift-clean",
21854
+ // Only states with cases in them get drawn see rerunSegments' comment above.
21855
+ function executionSegments(counts) {
21856
+ var out = [];
21857
+ EXEC_ORDER.forEach(function (key) {
21858
+ if (counts[key] > 0) out.push({ state: key, count: counts[key], cls: EXEC_SEG_CLASS[key] });
21859
+ });
21860
+ return out;
21861
+ }
21862
+ // --- end pure: execution composition --------------------------------------
21863
+
21864
+ // --- pure: audit composition -----------------------------------------------
21865
+ // Same shape again, bucketed by the audit axis (ADR-0014).
21866
+ var AUDIT_ORDER = ["drifted", "undecided", "due", "clean"];
21867
+ var AUDIT_SEG_CLASS = {
21868
+ drifted: "sg-audit-drifted", undecided: "sg-audit-undecided",
21869
+ due: "sg-audit-due", clean: "sg-audit-clean"
21843
21870
  };
21844
21871
 
21845
- function driftComposition(entries) {
21846
- var counts = { found: 0, unknown: 0, notAudited: 0, clean: 0 };
21847
- entries.forEach(function (entry) { counts[driftState(entry)] += 1; });
21872
+ // A case with no audit axis at all reads as due — the same "unanswered
21873
+ // means not yet cleared" rule as the other two axes.
21874
+ function auditComposition(records) {
21875
+ var counts = { drifted: 0, undecided: 0, due: 0, clean: 0 };
21876
+ records.forEach(function (rr) {
21877
+ var key = (rr && rr.audit) || "due";
21878
+ counts[Object.prototype.hasOwnProperty.call(counts, key) ? key : "due"] += 1;
21879
+ });
21848
21880
  return counts;
21849
21881
  }
21850
21882
 
21851
21883
  // Only states with cases in them get drawn — see rerunSegments' comment above.
21852
- function driftSegments(counts) {
21884
+ function auditSegments(counts) {
21853
21885
  var out = [];
21854
- DRIFT_ORDER.forEach(function (key) {
21855
- if (counts[key] > 0) out.push({ state: key, count: counts[key], cls: DRIFT_SEG_CLASS[key] });
21886
+ AUDIT_ORDER.forEach(function (key) {
21887
+ if (counts[key] > 0) out.push({ state: key, count: counts[key], cls: AUDIT_SEG_CLASS[key] });
21856
21888
  });
21857
21889
  return out;
21858
21890
  }
21859
- // --- end pure: drift composition -------------------------------------------
21891
+ // --- end pure: audit composition --------------------------------------------
21860
21892
 
21861
21893
  // One ledger entry as "<short sha> · <when>", linking to the hub's run detail
21862
21894
  // and, when that run recorded one, to the CI run. Clicks must not bubble: the
@@ -21914,30 +21946,34 @@ const CLIENT_JS = `
21914
21946
  // still true" are two faces of one question, so the row answers it once and
21915
21947
  // the detail panel keeps the coordinates.
21916
21948
  //
21917
- // A needed re-run outranks a recorded failure: once the last result is known
21918
- // to be stale it is no longer a verdict, and what to do next is the same
21919
- // either way run it. Reporting "failed" there would be describing a result
21920
- // nobody should still be acting on.
21949
+ // --- pure: run-state labels ----------------------------------------------
21950
+ // Self-contained (no DOM, no closures) so rerun-view.test.ts can lift it and
21951
+ // check that every execution value the hub can send has a badge and wording.
21952
+
21953
+ // A recorded failure outranks the deploy that landed after it: a red result
21954
+ // is current information, and repeating it teaches nothing until someone
21955
+ // repairs it.
21921
21956
  //
21922
- // "unknown" keeps its own state rather than folding into the last result:
21923
- // it means the hub cannot say whether that result still holds, and
21924
- // --only-hub-rerun-needed does not re-run it without --only-hub-rerun-needed-with-unknown. Showing
21925
- // it as passed or failed would claim a confidence nothing supports.
21957
+ // The wording keys are not the axis values: ADR-0010 reserves the freshness
21958
+ // adjectives for drift, so what the schema calls "stale" is shown as what it
21959
+ // means for this case — it has not run since the deploy.
21926
21960
  function perspRunState(rr) {
21927
21961
  if (!rr || !rr.execution) return null;
21928
- return rr.execution === "neverRun" ? "never" : rr.execution;
21962
+ if (rr.execution === "neverRun") return "never";
21963
+ return rr.execution === "stale" ? "superseded" : rr.execution;
21929
21964
  }
21930
21965
 
21931
21966
  // Reuses the badge classes that already mean these things elsewhere rather
21932
- // than minting a parallel palette: amber for "act on this", info for "cannot
21933
- // say", and the run status colours for a result that still stands.
21934
- var RUN_STATE_BADGE = { failed: "failed", passed: "passed", never: "rr-none" };
21967
+ // than minting a parallel palette: amber for "act on this", and the run
21968
+ // status colours for a result that still stands.
21969
+ var RUN_STATE_BADGE = { failed: "failed", passed: "passed", superseded: "rr-needed", never: "rr-none" };
21970
+ // --- end pure: run-state labels -------------------------------------------
21935
21971
 
21936
21972
  // The primary column. The two axes beside it answer "why"; this one answers
21937
21973
  // "who acts next", which is the only question a reader scanning a list of
21938
21974
  // specs has. Exactly one value, needsRepair, asks for a person.
21939
21975
  var VERDICT_BADGE = {
21940
- needsRepair: "rr-needed", rerunNeeded: "rr-needed", unanswerable: "rr-unknown",
21976
+ needsRepair: "rr-needed", rerunNeeded: "rr-needed",
21941
21977
  inProgress: "rr-none", verified: "passed"
21942
21978
  };
21943
21979
 
@@ -21957,15 +21993,28 @@ const CLIENT_JS = `
21957
21993
  return td;
21958
21994
  }
21959
21995
 
21960
- // Axis 1, as the hub derived it against the deployed commit which is not
21961
- // the same as the raw drift ledger beside it, whose verdict may predate the
21962
- // deploy.
21996
+ // A reason that pinned a spec to a pending state without a demonstrable
21997
+ // deploy touch (ADR-0014, auditAssumedReached / executionAssumedReached).
21998
+ // The short form is always visible as a .cellsub; the title attribute
21999
+ // carries the longer, actionable form for hover, so what closes the hole
22000
+ // is available without depending on a pointer to find it.
22001
+ function assumedReachedSub(reason) {
22002
+ var sub = el("span", "cellsub", rerunReasonText("perspectives.rerun.why.", reason));
22003
+ sub.title = rerunReasonText("perspectives.rerun.fix.", reason);
22004
+ return sub;
22005
+ }
22006
+
22007
+ // Axis 1, as the hub derived it against the deployed commit — fresher than
22008
+ // the raw drift ledger, whose entry may predate the deploy. The ledger
22009
+ // survives here only as the "audited at" coordinate (ledgerLine below):
22010
+ // its own finding is superseded by rr.audit/rr.driftLabel above, so
22011
+ // showing both would repeat one answer in two vocabularies — the bug this
22012
+ // column used to have paired with a since-removed fourth column.
21963
22013
  var AUDIT_BADGE = {
21964
- due: "rr-none", clean: "passed", drifted: "failed",
21965
- undecided: "rr-unknown", cannotTell: "rr-unknown"
22014
+ due: "rr-none", clean: "passed", drifted: "failed", undecided: "rr-unknown"
21966
22015
  };
21967
22016
 
21968
- function perspAuditCell(rr) {
22017
+ function perspAuditCell(rr, driftEntry) {
21969
22018
  var td = el("td");
21970
22019
  if (!rr || !rr.audit) {
21971
22020
  td.appendChild(el("span", "muted", "\u2014"));
@@ -21977,6 +22026,23 @@ const CLIENT_JS = `
21977
22026
  td.appendChild(badge);
21978
22027
  if (rr.audit === "drifted" && rr.driftLabel) {
21979
22028
  td.appendChild(el("span", "cellsub", labelText(rr.driftLabel)));
22029
+ } else if (rr.auditAssumedReached) {
22030
+ // Due because the log could not place the audit, not because a deploy
22031
+ // demonstrably reached it. Say which, or the whole column reads "due"
22032
+ // with no cause anywhere on the page.
22033
+ td.appendChild(assumedReachedSub(rr.auditAssumedReached));
22034
+ }
22035
+ // "audited at sha · when" — the same freshness evidence the execution
22036
+ // column gives its own last result, below. SpecRerun does not carry the
22037
+ // audit's own coordinate, so it comes from the drift ledger.
22038
+ if (driftEntry) {
22039
+ var sub = el("span", "cellsub");
22040
+ sub.appendChild(ledgerLine(driftEntry));
22041
+ if (driftEntry.graded) {
22042
+ sub.appendChild(document.createTextNode(" · "));
22043
+ sub.appendChild(el("span", "graded-mark", t("perspectives.drift.graded")));
22044
+ }
22045
+ td.appendChild(sub);
21980
22046
  }
21981
22047
  return td;
21982
22048
  }
@@ -21987,7 +22053,7 @@ const CLIENT_JS = `
21987
22053
  // No verdict at all: this case is in the document but not in the report
21988
22054
  // (added since it was computed). Not the same statement as "never run".
21989
22055
  if (!runState) {
21990
- td.appendChild(el("span", "muted", ""));
22056
+ td.appendChild(el("span", "muted", "\u2014"));
21991
22057
  return td;
21992
22058
  }
21993
22059
  var badge = el("span", "badge " + RUN_STATE_BADGE[runState]);
@@ -21995,8 +22061,11 @@ const CLIENT_JS = `
21995
22061
  badge.appendChild(document.createTextNode(" " + t("perspectives.run.state." + runState)));
21996
22062
  td.appendChild(badge);
21997
22063
 
21998
- // The sub-line is the coordinate of the result being reported. Why it
21999
- // matters belongs to the verdict column, not here.
22064
+ // The sub-line is the coordinate of the result being reported the same
22065
+ // "when is this from" evidence the audit column carries above, so the two
22066
+ // axes read as siblings. Why the currency matters belongs to the verdict
22067
+ // column, except for the hole that pinned this to "pending" with no
22068
+ // deploy to point at.
22000
22069
  if (runState !== "never") {
22001
22070
  var last = lastResult(rr);
22002
22071
  if (last) {
@@ -22005,53 +22074,7 @@ const CLIENT_JS = `
22005
22074
  td.appendChild(sub);
22006
22075
  }
22007
22076
  }
22008
- return td;
22009
- }
22010
-
22011
- // ── perspectives: drift ledger ────────────────────────────────────────
22012
- // "Does this case still describe the product?" — a different question from
22013
- // re-run above, and shown as its own column rather than folded into it.
22014
- // Unlike re-run, drift carries no profile: an audit is a property of the
22015
- // code at a commit, not of an environment, so this column never changes
22016
- // when the profile switcher above it does.
22017
-
22018
- var DRIFT_BADGE_CLASS = { notAudited: "dr-none", clean: "dr-clean", found: "dr-found", unknown: "dr-unknown" };
22019
-
22020
- // driftState() lives with driftComposition/driftSegments above (the "pure:
22021
- // drift composition" region) — the badge must not collapse "never audited"
22022
- // and "audited, no drift found" into one grey, same reasoning either way.
22023
- function driftBadge(state) {
22024
- var span = el("span", "badge " + (DRIFT_BADGE_CLASS[state] || "dr-none"));
22025
- span.appendChild(el("span", "d"));
22026
- span.appendChild(document.createTextNode(" " + t("perspectives.drift.state." + state)));
22027
- return span;
22028
- }
22029
-
22030
- function perspDriftCell(entry) {
22031
- var td = el("td");
22032
- td.appendChild(driftBadge(driftState(entry)));
22033
- if (entry) {
22034
- var sub = el("span", "cellsub");
22035
- // The label and surface go here only when they add to the badge. A clean
22036
- // audit has neither. An UNKNOWN one has both on paper, but the badge
22037
- // already says "could not tell" — repeating it as a label says the same
22038
- // thing in a second wording, and naming the surface it could not judge
22039
- // claims more than the audit found.
22040
- if (entry.label && driftState(entry) === "found") {
22041
- sub.appendChild(document.createTextNode(
22042
- labelText(entry.label) + (entry.surface ? " (" + t("diag.surface." + entry.surface) + ")" : "") + " · ",
22043
- ));
22044
- }
22045
- sub.appendChild(ledgerLine(entry));
22046
- // A verdict someone has looked at is worth more than one nobody has, in
22047
- // both directions: a confirmed finding is real, and a confirmed clean is
22048
- // not just "the audit found nothing".
22049
- if (entry.graded) {
22050
- sub.appendChild(document.createTextNode(" · "));
22051
- sub.appendChild(el("span", "graded-mark", t("perspectives.drift.graded")));
22052
- }
22053
- td.appendChild(sub);
22054
- }
22077
+ if (rr.executionAssumedReached) td.appendChild(assumedReachedSub(rr.executionAssumedReached));
22055
22078
  return td;
22056
22079
  }
22057
22080
 
@@ -22105,45 +22128,47 @@ const CLIENT_JS = `
22105
22128
  return row;
22106
22129
  }
22107
22130
 
22108
- // Summary: the inventory as one line, then one row per axis — the two
22109
- // questions this tab answers, needs-re-run and drift, each segmented on its
22110
- // own bar rather than blended into one. The mode and recorded-ness counts
22111
- // are not lost; they moved onto the filter chips, where a count states what
22112
- // that filter would leave behind.
22131
+ // Summary: the inventory as one line, then one row per axis — verdict,
22132
+ // execution, audit, the same three groupings the table's columns show, so
22133
+ // the bars and the table never disagree about what a word means. The mode
22134
+ // and recorded-ness counts are not lost; they moved onto the filter chips,
22135
+ // where a count states what that filter would leave behind.
22113
22136
  //
22114
22137
  // With no re-run data (an older hub, a failed fetch, or a profile nothing
22115
- // has been recorded on) every case is "not evaluated" and the bar says so in
22116
- // one neutral segment, rather than showing a composition that reads as
22117
- // "nothing to do". Drift has no profile and loads separately (loadDrift):
22118
- // when it hasn't (older hub, failed fetch), its row is omitted entirely
22119
- // rather than drawn as "all not audited" — that would read as a finding
22120
- // instead of missing data.
22138
+ // has been recorded on) every case is "not evaluated" and all three bars
22139
+ // are omitted together, rather than showing a composition that reads as
22140
+ // "nothing to do".
22121
22141
  function renderPerspOverview(doc) {
22122
22142
  var host = document.getElementById("persp-ov");
22123
22143
  clear(host);
22124
- var verdicts = [];
22125
- var driftEntries = perspState.drift ? [] : null;
22144
+ var records = [];
22126
22145
  doc.features.forEach(function (feature) {
22127
22146
  feature.specs.forEach(function (spec) {
22128
- verdicts.push(ledgerEntryFor(perspState.rerun, feature, spec));
22129
- if (driftEntries) driftEntries.push(ledgerEntryFor(perspState.drift, feature, spec));
22147
+ records.push(ledgerEntryFor(perspState.rerun, feature, spec));
22130
22148
  });
22131
22149
  });
22132
22150
 
22133
22151
  var inv = el("div", "ov-inv");
22134
- inv.appendChild(el("b", null, String(verdicts.length)));
22152
+ inv.appendChild(el("b", null, String(records.length)));
22135
22153
  inv.appendChild(document.createTextNode(" " + t("perspectives.ov.cases") + " / "));
22136
22154
  inv.appendChild(el("b", null, String(doc.features.length)));
22137
22155
  inv.appendChild(document.createTextNode(" " + t("perspectives.ov.features")));
22138
22156
  host.appendChild(inv);
22139
- if (!verdicts.length) return;
22157
+ if (!records.length) return;
22140
22158
 
22141
- host.appendChild(ovAxisRow(
22142
- t("perspectives.ov.axis.rerun"), rerunSegments(rerunComposition(verdicts)), "perspectives.rerun.state.", verdicts.length,
22143
- ));
22144
- if (driftEntries) {
22159
+ // Gated on the report as a whole: with none, none of the three axes has
22160
+ // anything to compose, and an un-composed bar would paint every case as
22161
+ // needing work — an overstatement about the hub rather than about the
22162
+ // specs.
22163
+ if (perspState.rerun) {
22145
22164
  host.appendChild(ovAxisRow(
22146
- t("perspectives.ov.axis.drift"), driftSegments(driftComposition(driftEntries)), "perspectives.drift.state.", driftEntries.length,
22165
+ t("perspectives.col.verdict"), rerunSegments(rerunComposition(records)), "perspectives.rerun.state.", records.length,
22166
+ ));
22167
+ host.appendChild(ovAxisRow(
22168
+ t("perspectives.col.run"), executionSegments(executionComposition(records)), "perspectives.run.state.", records.length,
22169
+ ));
22170
+ host.appendChild(ovAxisRow(
22171
+ t("perspectives.col.audit"), auditSegments(auditComposition(records)), "perspectives.audit.state.", records.length,
22147
22172
  ));
22148
22173
  }
22149
22174
  }
@@ -22155,15 +22180,16 @@ const CLIENT_JS = `
22155
22180
  function perspMatches(feature, spec, f) {
22156
22181
  if (f === "deterministic" && perspMode(spec) !== "deterministic") return false;
22157
22182
  if (f === "live" && perspMode(spec) !== "live") return false;
22158
- if (f === "rerun") {
22183
+ // The verdict chips (same words as RERUN_ORDER/the 判定 column) share
22184
+ // rerunVerdictOf with the summary bar, so an unrecognised verdict is
22185
+ // "rerunNeeded" in both places rather than matching no chip. With no
22186
+ // report at all there is nothing to filter on, so every chip yields
22187
+ // nothing.
22188
+ if (RERUN_ORDER.indexOf(f) !== -1) {
22189
+ if (!perspState.rerun) return false;
22159
22190
  var rr = ledgerEntryFor(perspState.rerun, feature, spec);
22160
- // Only "needed": "unknown" is not a weaker "probably needed", and
22161
- // folding it in here would be exactly the overstatement ADR-0010 forbids.
22162
- if (!rr || rr.verdict !== "rerunNeeded") return false;
22191
+ if (rerunVerdictOf(rr) !== f) return false;
22163
22192
  }
22164
- // Same asymmetry: an unaudited spec is not a quiet "probably clean", so it
22165
- // does not belong under a chip that claims to list what drifted.
22166
- if (f === "drift" && driftState(ledgerEntryFor(perspState.drift, feature, spec)) !== "found") return false;
22167
22193
  if (perspState.q) {
22168
22194
  var hay = (spec.title + " " + (spec.summary || "") + " " + spec.specName).toLowerCase();
22169
22195
  if (hay.indexOf(perspState.q) === -1) return false;
@@ -22186,14 +22212,19 @@ const CLIENT_JS = `
22186
22212
  // lift this region out of the rendered page and run it: which label the
22187
22213
  // panel's evidence row wears, and whether it has a failure row at all.
22188
22214
 
22189
- // needed/notNeeded put evidence in that row, so it is labelled by the
22190
- // timeframe the evidence covers. The other three have no evidence to show,
22191
- // only a missing input to name a different kind of content, and forcing one
22192
- // label over both would make one of the two read as a lie.
22193
- function rerunEvidenceLabelKey(rerunState) {
22194
- return rerunState === "rerunNeeded" || rerunState === "verified"
22195
- ? "perspectives.d.changedSince"
22196
- : "perspectives.d.cannotJudge";
22215
+ // The deploy log answered for this case: the row can show what it holds.
22216
+ // A case the log could not place has no evidence to show even though its
22217
+ // verdict is the samethe assumption is the answer, not a finding.
22218
+ function rerunHasEvidence(rr) {
22219
+ if (rr.verdict === "verified") return true;
22220
+ return rr.verdict === "rerunNeeded" && !rr.executionAssumedReached;
22221
+ }
22222
+
22223
+ // Evidence is labelled by the timeframe it covers; everything else names why
22224
+ // the verdict landed — a different kind of content, and forcing one label
22225
+ // over both would make one of the two read as a lie.
22226
+ function rerunEvidenceLabelKey(rr) {
22227
+ return rerunHasEvidence(rr) ? "perspectives.d.changedSince" : "perspectives.d.whyVerdict";
22197
22228
  }
22198
22229
 
22199
22230
  // The failure row points at a run. With no failure there is nothing to point
@@ -22229,8 +22260,8 @@ const CLIENT_JS = `
22229
22260
  // The label already states the timeframe, so the value never repeats it.
22230
22261
  function rerunEvidenceValue(rr) {
22231
22262
  var wrap = el("div");
22232
- if (rr.verdict !== "rerunNeeded" && rr.verdict !== "verified") {
22233
- wrap.appendChild(el("div", "d-prose", rerunCannotJudge(rr)));
22263
+ if (!rerunHasEvidence(rr)) {
22264
+ wrap.appendChild(el("div", "d-prose", rerunWhyVerdict(rr)));
22234
22265
  return wrap;
22235
22266
  }
22236
22267
  // Both states require a non-empty deploy log, so a head-less report
@@ -22280,7 +22311,7 @@ const CLIENT_JS = `
22280
22311
 
22281
22312
  var rr = ledgerEntryFor(perspState.rerun, feature, spec);
22282
22313
  if (rr) {
22283
- row(rerunEvidenceLabelKey(rr.verdict), rerunEvidenceValue(rr));
22314
+ row(rerunEvidenceLabelKey(rr), rerunEvidenceValue(rr));
22284
22315
  if (rerunHasFailure(rr)) row("perspectives.d.lastRed", ledgerLine(rr.lastRed));
22285
22316
  }
22286
22317
  frag.appendChild(dl);
@@ -22325,14 +22356,12 @@ const CLIENT_JS = `
22325
22356
  var tbody = document.getElementById("persp-tbody");
22326
22357
  clear(tbody);
22327
22358
  // Hiding the <th>s (rather than emitting empty cells) leaves the table
22328
- // exactly as it was on a hub that cannot answer the re-run/drift question.
22359
+ // exactly as it was on a hub that cannot answer the re-run question.
22329
22360
  var showRerun = perspState.rerun != null;
22330
- var showDrift = perspState.drift != null;
22331
22361
  document.getElementById("persp-th-verdict").hidden = !showRerun;
22332
22362
  document.getElementById("persp-th-audit").hidden = !showRerun;
22333
22363
  document.getElementById("persp-th-run").hidden = !showRerun;
22334
- document.getElementById("persp-th-drift").hidden = !showDrift;
22335
- var cols = 3 + (showRerun ? 3 : 0) + (showDrift ? 1 : 0);
22364
+ var cols = 3 + (showRerun ? 3 : 0);
22336
22365
  var hits = 0;
22337
22366
  doc.features.forEach(function (feature) {
22338
22367
  var specs = feature.specs.filter(function (s) { return perspMatches(feature, s, perspState.f); });
@@ -22364,10 +22393,9 @@ const CLIENT_JS = `
22364
22393
  if (showRerun) {
22365
22394
  var rr = ledgerEntryFor(perspState.rerun, feature, spec);
22366
22395
  row.appendChild(perspVerdictCell(rr));
22367
- row.appendChild(perspAuditCell(rr));
22368
22396
  row.appendChild(perspRunCell(rr));
22397
+ row.appendChild(perspAuditCell(rr, ledgerEntryFor(perspState.drift, feature, spec)));
22369
22398
  }
22370
- if (showDrift) row.appendChild(perspDriftCell(ledgerEntryFor(perspState.drift, feature, spec)));
22371
22399
 
22372
22400
  var chevTd = el("td", "c-chev");
22373
22401
  chevTd.appendChild(el("span", "chev-i", "\\u25b6"));
@@ -22410,21 +22438,17 @@ const CLIENT_JS = `
22410
22438
  renderPerspTable(doc);
22411
22439
  }
22412
22440
 
22413
- // The needs-re-run chip only exists while the hub answers the question;
22414
- // otherwise it would filter everything away. Drop back to "all" if it was
22415
- // the active filter when the answer came back "not supported".
22441
+ // The verdict chip group only exists while the hub answers the re-run
22442
+ // question; otherwise every one of them would filter everything away. Drop
22443
+ // back to "all" if one was the active filter when the answer came back
22444
+ // "not supported".
22416
22445
  //
22417
22446
  // Each chip also carries what it would yield — the mode breakdown the
22418
22447
  // summary row used to spend four tiles on.
22419
22448
  function syncPerspChips() {
22420
- var chip = document.getElementById("persp-chip-rerun");
22421
- chip.hidden = perspState.rerunSupported !== true;
22422
- if (perspState.rerunSupported === false && perspState.f === "rerun") perspState.f = "all";
22423
- // Same rule as the drift column: no ledger, no chip. A chip reading "0"
22424
- // would say "nothing drifted" when the hub was never asked.
22425
- var driftChip = document.getElementById("persp-chip-drift");
22426
- driftChip.hidden = perspState.drift == null;
22427
- if (perspState.drift == null && perspState.f === "drift") perspState.f = "all";
22449
+ var group = document.getElementById("persp-verdict-chips");
22450
+ group.hidden = perspState.rerunSupported !== true;
22451
+ if (perspState.rerunSupported !== true && RERUN_ORDER.indexOf(perspState.f) !== -1) perspState.f = "all";
22428
22452
  document.querySelectorAll("#view-perspectives .fchip").forEach(function (b) {
22429
22453
  var f = b.getAttribute("data-f");
22430
22454
  b.setAttribute("aria-pressed", String(f === perspState.f));
@@ -22432,7 +22456,8 @@ const CLIENT_JS = `
22432
22456
  });
22433
22457
  }
22434
22458
 
22435
- // Shared by the rerun and drift notes, which differ only in which box they fill.
22459
+ // boxId is a parameter rather than hardcoded so a future note box can reuse
22460
+ // this without copying it — today only "persp-rerun-note" calls it.
22436
22461
  function setPerspNote(boxId, text, kind) {
22437
22462
  var box = document.getElementById(boxId);
22438
22463
  box.hidden = !text;
@@ -22468,7 +22493,6 @@ const CLIENT_JS = `
22468
22493
  setPerspDeployHead(null);
22469
22494
  perspState.rerun = null;
22470
22495
  perspState.rerunSupported = null;
22471
- setPerspNote("persp-drift-note", "");
22472
22496
  perspState.drift = null;
22473
22497
  syncPerspChips();
22474
22498
  fetchPerspectives()
@@ -22491,9 +22515,9 @@ const CLIENT_JS = `
22491
22515
  loadRerun().catch(function (err) {
22492
22516
  setPerspNote("persp-rerun-note", t("perspectives.rerun.loadFailed") + ": " + err.message, "warn");
22493
22517
  }),
22494
- loadDrift().catch(function (err) {
22495
- setPerspNote("persp-drift-note", t("perspectives.drift.loadFailed") + ": " + err.message, "warn");
22496
- }),
22518
+ // Evidence-only (the "audited at" line in the audit column) — a
22519
+ // failed or unsupported fetch just omits that line, no banner.
22520
+ loadDrift().catch(function () {}),
22497
22521
  ]);
22498
22522
  })
22499
22523
  .catch(function (err) {
@@ -22506,6 +22530,82 @@ const CLIENT_JS = `
22506
22530
  return state.project + "/" + state.profile;
22507
22531
  }
22508
22532
 
22533
+ // --- pure: data-profile pick ---------------------------------------------
22534
+ // Self-contained on purpose (no DOM, no closures): the network probing this
22535
+ // feeds is what makes "has deploy data" answerable at all, but which
22536
+ // candidate wins from the results is a plain function worth pinning without
22537
+ // mocking a fetch.
22538
+
22539
+ // "default" is always offered (the API guarantees it) but usually holds no
22540
+ // deploys, so opening straight into it reads as broken: every row pending,
22541
+ // a "no deploy log" banner. Fallback for when no candidate's deploy log can
22542
+ // be confirmed (a fresh project): prefer a profile the run index shows has
22543
+ // runs, with exactly one such profile the only reasonable pick.
22544
+ function pickDataProfile(current, dataProfiles) {
22545
+ if (!dataProfiles.length || dataProfiles.indexOf(current) !== -1) return current;
22546
+ return dataProfiles.length === 1 ? dataProfiles[0] : dataProfiles.slice().sort()[0];
22547
+ }
22548
+
22549
+ // Every profile worth checking for deploy data, in preference order: the
22550
+ // current profile first (so an already-fine pick is left alone), then
22551
+ // run-index profiles (a profile with runs is a maintained environment),
22552
+ // then the project's full profile set. That last tier is what makes a
22553
+ // profile deploys are recorded under but nothing has ever run against
22554
+ // reachable at all — the run index alone has no way to see it.
22555
+ function dataProfileCandidates(current, dataProfiles, projectProfiles) {
22556
+ var seen = {};
22557
+ var out = [];
22558
+ [current].concat(dataProfiles, projectProfiles).forEach(function (p) {
22559
+ if (p && !seen[p]) { seen[p] = true; out.push(p); }
22560
+ });
22561
+ return out;
22562
+ }
22563
+
22564
+ // The deterministic half of resolveDataProfile below: given which
22565
+ // candidates actually have a deploy log (probed in parallel, so answers can
22566
+ // arrive in any order), pick the first by candidate order, not by response
22567
+ // order — the result must not depend on network timing. Falls back to
22568
+ // pickDataProfile's answer when nothing confirms (no project has deploy
22569
+ // data yet), leaving that case's behaviour unchanged.
22570
+ function pickFirstWithDeployLog(candidates, hasLog, current, dataProfiles) {
22571
+ for (var i = 0; i < candidates.length; i++) {
22572
+ if (hasLog[i]) return candidates[i];
22573
+ }
22574
+ return pickDataProfile(current, dataProfiles);
22575
+ }
22576
+ // --- end pure: data-profile pick ------------------------------------------
22577
+
22578
+ // Confirms a candidate actually has a deploy log, rather than merely being
22579
+ // known to the run index or the secrets tab — the two facts pickDataProfile
22580
+ // used to conflate (a profile can hold runs, or secrets, with no deploy log
22581
+ // at all). limit=1 makes this a cheap existence check.
22582
+ function hasDeployLog(profile) {
22583
+ return apiFetch(
22584
+ "/api/v1/projects/" + encodeURIComponent(state.project) + "/deploys?profile=" + encodeURIComponent(profile) + "&limit=1",
22585
+ ).then(function (data) { return !!(data && data.entries && data.entries.length); })
22586
+ .catch(function () { return false; });
22587
+ }
22588
+
22589
+ // The candidate set from /profiles is broader than the run index (see
22590
+ // dataProfileCandidates), so probe every candidate's deploy log in
22591
+ // parallel before letting pickFirstWithDeployLog decide.
22592
+ function resolveDataProfile(current, dataProfiles, projectProfiles) {
22593
+ var candidates = dataProfileCandidates(current, dataProfiles, projectProfiles);
22594
+ return Promise.all(candidates.map(hasDeployLog))
22595
+ .then(function (hasLog) { return pickFirstWithDeployLog(candidates, hasLog, current, dataProfiles); });
22596
+ }
22597
+
22598
+ // The full profile set (secrets tab's universe) is broader than the run
22599
+ // index: a profile deploys are recorded under but nothing has ever run
22600
+ // against is invisible to fetchRunIndex. Fetched independently from
22601
+ // loadProfiles, which mutates knownProfiles/state.profile as a side effect
22602
+ // the Secrets tab depends on and this must not trigger.
22603
+ function fetchProjectProfiles() {
22604
+ return apiFetch("/api/v1/projects/" + encodeURIComponent(state.project) + "/profiles")
22605
+ .then(function (data) { return (data && data.profiles) || []; })
22606
+ .catch(function () { return []; });
22607
+ }
22608
+
22509
22609
  // Project-scoped (see fetchRunIndex) — never rejects, so this always
22510
22610
  // re-renders once the run index settles, whichever of the three loads in
22511
22611
  // loadPerspectives is slowest.
@@ -22513,7 +22613,22 @@ const CLIENT_JS = `
22513
22613
  return fetchRunIndex().then(function (result) {
22514
22614
  perspState.runUrls = result.urls;
22515
22615
  perspState.rerunProfiles = result.profiles;
22516
- renderPerspectives();
22616
+ if (storedProfileForProject(state.project)) {
22617
+ renderPerspectives();
22618
+ return;
22619
+ }
22620
+ // Never overrides a choice the user made explicitly (the check above)
22621
+ // — this only fills in the very first, unopinionated default.
22622
+ return fetchProjectProfiles()
22623
+ .then(function (projectProfiles) { return resolveDataProfile(state.profile, result.profiles, projectProfiles); })
22624
+ .then(function (pick) {
22625
+ if (pick !== state.profile) {
22626
+ setProfile(pick);
22627
+ renderPerspectives();
22628
+ return reloadRerun();
22629
+ }
22630
+ renderPerspectives();
22631
+ });
22517
22632
  });
22518
22633
  }
22519
22634
 
@@ -22529,8 +22644,9 @@ const CLIENT_JS = `
22529
22644
  if (rerun.note) {
22530
22645
  setPerspNote("persp-rerun-note", rerun.note, rerun.kind);
22531
22646
  } else if (perspState.rerun && !perspState.rerun.deployHead) {
22532
- // Every case is "unknown" in this state, so say once, at the top, what
22533
- // is missing and how to supply it, rather than only per row.
22647
+ // With no deploy log nothing can be placed, so every case is assumed
22648
+ // reached. Say once, at the top, what is missing and how to supply it,
22649
+ // rather than repeating it on every row.
22534
22650
  setPerspNote("persp-rerun-note", t("perspectives.rerun.noDeployLogBanner").replace("{profile}", state.profile), "warn");
22535
22651
  } else {
22536
22652
  setPerspNote("persp-rerun-note", "");
@@ -22540,13 +22656,18 @@ const CLIENT_JS = `
22540
22656
  }
22541
22657
 
22542
22658
  // Loaded once per project open — never re-run on a profile switch, since
22543
- // drift carries no profile (unlike loadRerun/reloadRerun below).
22659
+ // drift carries no profile (unlike loadRerun/reloadRerun below). Only the
22660
+ // ledger's own coordinate (when a spec was last audited) survives into the
22661
+ // view now — its finding is superseded by the fresher, deploy-aware audit
22662
+ // axis in the /rerun report — so there is nothing here worth a banner on
22663
+ // an older or unreachable hub.
22544
22664
  function loadDrift() {
22545
- return fetchLedgerColumn(driftPath(), "perspectives.drift.").then(function (result) {
22546
- perspState.drift = result.report || null;
22547
- setPerspNote("persp-drift-note", result.note || "", result.kind);
22548
- renderPerspectives();
22549
- });
22665
+ return fetch(driftPath(), { headers: { Authorization: "Bearer " + state.token } })
22666
+ .then(function (res) { return res.ok ? res.json() : null; }, function () { return null; })
22667
+ .then(function (report) {
22668
+ perspState.drift = report || null;
22669
+ renderPerspectives();
22670
+ });
22550
22671
  }
22551
22672
 
22552
22673
  // Switching profile re-asks only the profile-scoped question: the