ccqa 1.19.0 → 1.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,26 @@ 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-repair { background: var(--fail-bg); color: var(--fail); border-color: var(--fail-border); }
19376
+ .badge.rr-repair .d { background: var(--fail); }
19377
+ .badge.rr-unknown { background: var(--info-bg); color: var(--info); border-color: var(--info-border); }
19378
+ .badge.rr-unknown .d { background: var(--info); }
19379
+ .badge.rr-none { background: var(--surface-3); color: var(--muted); border-color: var(--border); }
19380
+ .badge.rr-none .d { background: var(--muted); }
19359
19381
  .cellsub { display: block; margin-top: 3px; max-width: 260px; color: var(--muted); font-size: 11.5px; line-height: 1.45; }
19360
19382
  .graded-mark { color: var(--fg-dim); font-weight: 600; }
19361
19383
  .cellsub a { color: var(--muted); text-decoration: none; border-bottom: 1px dotted var(--border-strong); }
19362
19384
  .cellsub a:hover { color: var(--fg); }
19363
19385
  .persp-note { margin-bottom: 12px; }
19364
19386
  .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. */
19387
+ /* The Perspectives toolbar carries search + two filter-chip groups + the
19388
+ profile selector, so it wraps instead of overflowing on a narrow window. */
19367
19389
  #view-perspectives .toolbar { flex-wrap: wrap; }
19368
19390
  .proj-menu.right { left: auto; right: 0; }
19369
19391
  .d-note { margin-top: 12px; max-width: 900px; }
@@ -19510,15 +19532,14 @@ const CLIENT_JS = `
19510
19532
  "perspectives.search": "Search cases…",
19511
19533
  "perspectives.filter.all": "All", "perspectives.filter.deterministic": "Deterministic",
19512
19534
  "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",
19535
+ "perspectives.filter.group.mode": "Mode",
19536
+ "perspectives.col.verdict": "Verdict", "perspectives.col.audit": "Audit",
19537
+ "perspectives.col.run": "Execution",
19516
19538
  "perspectives.audit.state.due": "Audit due",
19517
19539
  "perspectives.audit.state.clean": "Describes the code",
19518
19540
  "perspectives.audit.state.drifted": "Drifted",
19519
19541
  "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",
19542
+ "perspectives.run.state.superseded": "pending",
19522
19543
  "perspectives.run.state.failed": "failed", "perspectives.run.state.passed": "passed",
19523
19544
  "perspectives.run.state.never": "never run",
19524
19545
  "perspectives.col.case": "Case", "perspectives.col.mode": "Mode",
@@ -19528,7 +19549,6 @@ const CLIENT_JS = `
19528
19549
  "perspectives.loadFailed": "Loading perspectives failed",
19529
19550
  "perspectives.mode.deterministic": "deterministic", "perspectives.mode.live": "live",
19530
19551
  "perspectives.ov.cases": "cases", "perspectives.ov.features": "features",
19531
- "perspectives.ov.axis.rerun": "Re-run", "perspectives.ov.axis.drift": "Drift",
19532
19552
  "perspectives.d.preconditions": "Preconditions", "perspectives.d.startScreen": "Start screen",
19533
19553
  "perspectives.d.testCondition": "Condition", "perspectives.d.spec": "spec",
19534
19554
  "perspectives.note.label": "Note",
@@ -19537,12 +19557,11 @@ const CLIENT_JS = `
19537
19557
  "perspectives.note.error": "Could not save — retry",
19538
19558
  "perspectives.d.lastRed": "Most recent failure",
19539
19559
  "perspectives.d.changedSince": "Changes since the last run",
19540
- "perspectives.d.cannotJudge": "Why this cannot be judged",
19560
+ "perspectives.d.whyVerdict": "Why this verdict",
19541
19561
  "perspectives.result.openRun": "Open this run in the hub",
19542
19562
  "perspectives.result.ci": "CI",
19543
19563
  "perspectives.rerun.state.needsRepair": "Needs repair",
19544
19564
  "perspectives.rerun.state.rerunNeeded": "Re-run needed",
19545
- "perspectives.rerun.state.unanswerable": "Can't tell",
19546
19565
  "perspectives.rerun.state.inProgress": "In progress",
19547
19566
  "perspectives.rerun.state.verified": "Verified",
19548
19567
  "perspectives.rerun.vsDeploy": "judged against deploy",
@@ -19553,11 +19572,11 @@ const CLIENT_JS = `
19553
19572
  "perspectives.rerun.touchedCount": "{n} deployed path(s) matched this case",
19554
19573
  "perspectives.rerun.touchedUnknown": "a deploy since the last run matched this case",
19555
19574
  "perspectives.rerun.inProgressHint": "an audit or a run is still going, or the audit has not caught up with the deploy",
19575
+ "perspectives.rerun.heldHint": "another job already holds this spec — acting on it now would race that job",
19556
19576
  "perspectives.rerun.repair.testDrift": "the generated test no longer matches the code — re-record it",
19557
19577
  "perspectives.rerun.repair.specChange": "the spec describes something the code no longer does — a human decides",
19558
19578
  "perspectives.rerun.repair.auditUndecided": "the audit read the code and could not decide — a human looks",
19559
19579
  "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
19580
  "perspectives.rerun.why.noSelectionInRange": "a deploy in range was recorded without a spec selection",
19562
19581
  "perspectives.rerun.why.selectionUnknown": "the selector could not tell whether this case was affected",
19563
19582
  "perspectives.rerun.why.noDeployLog": "no deploy log for this profile",
@@ -19566,7 +19585,6 @@ const CLIENT_JS = `
19566
19585
  "perspectives.rerun.why.deployedShaNotInLog": "the last run's commit predates the retained deploy log",
19567
19586
  "perspectives.rerun.why.gapInRange": "deploys are missing from the range",
19568
19587
  "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
19588
  "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
19589
  "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
19590
  "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 +19597,7 @@ const CLIENT_JS = `
19579
19597
  "perspectives.rerun.loadFailed": "Loading re-run data failed",
19580
19598
  "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
19599
  "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
19600
  "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
19601
  "prompt.card.record": "Recording browser actions",
19589
19602
  "prompt.card.live": "Live run (AI-driven)",
19590
19603
  "prompt.card.playwright": "Playwright test generation",
@@ -19676,15 +19689,14 @@ const CLIENT_JS = `
19676
19689
  "perspectives.search": "ケースを検索…",
19677
19690
  "perspectives.filter.all": "すべて", "perspectives.filter.deterministic": "決定的",
19678
19691
  "perspectives.filter.live": "ライブ",
19679
- "perspectives.filter.rerun": "要再実行のみ", "perspectives.filter.drift": "ズレありのみ",
19680
- "perspectives.col.verdict": "次にすること", "perspectives.col.audit": "監査",
19681
- "perspectives.col.run": "実行", "perspectives.col.drift": "ドリフト監査",
19692
+ "perspectives.filter.group.mode": "モード",
19693
+ "perspectives.col.verdict": "判定", "perspectives.col.audit": "監査",
19694
+ "perspectives.col.run": "実行",
19682
19695
  "perspectives.audit.state.due": "監査待ち",
19683
- "perspectives.audit.state.clean": "ずれなし",
19684
- "perspectives.audit.state.drifted": "ずれあり",
19696
+ "perspectives.audit.state.clean": "ズレなし",
19697
+ "perspectives.audit.state.drifted": "ズレあり",
19685
19698
  "perspectives.audit.state.undecided": "判定不能",
19686
- "perspectives.audit.state.cannotTell": "判定できない",
19687
- "perspectives.run.state.needed": "要再実行", "perspectives.run.state.unknown": "判定できない",
19699
+ "perspectives.run.state.superseded": "実行待ち",
19688
19700
  "perspectives.run.state.failed": "失敗", "perspectives.run.state.passed": "合格",
19689
19701
  "perspectives.run.state.never": "未実行",
19690
19702
  "perspectives.col.case": "ケース", "perspectives.col.mode": "モード",
@@ -19694,7 +19706,6 @@ const CLIENT_JS = `
19694
19706
  "perspectives.loadFailed": "テスト観点の読み込みに失敗しました",
19695
19707
  "perspectives.mode.deterministic": "決定的", "perspectives.mode.live": "ライブ",
19696
19708
  "perspectives.ov.cases": "ケース", "perspectives.ov.features": "機能",
19697
- "perspectives.ov.axis.rerun": "実行", "perspectives.ov.axis.drift": "ドリフト",
19698
19709
  "perspectives.d.preconditions": "前提条件", "perspectives.d.startScreen": "開始画面",
19699
19710
  "perspectives.d.testCondition": "実行条件", "perspectives.d.spec": "spec",
19700
19711
  "perspectives.note.label": "note",
@@ -19703,12 +19714,11 @@ const CLIENT_JS = `
19703
19714
  "perspectives.note.error": "保存に失敗しました — 再試行してください",
19704
19715
  "perspectives.d.lastRed": "直近の失敗",
19705
19716
  "perspectives.d.changedSince": "前回実行以降の変更",
19706
- "perspectives.d.cannotJudge": "判定できない理由",
19717
+ "perspectives.d.whyVerdict": "この判定の理由",
19707
19718
  "perspectives.result.openRun": "ハブでこの実行を開く",
19708
19719
  "perspectives.result.ci": "CI",
19709
19720
  "perspectives.rerun.state.needsRepair": "修正待ち",
19710
19721
  "perspectives.rerun.state.rerunNeeded": "要再実行",
19711
- "perspectives.rerun.state.unanswerable": "判定できない",
19712
19722
  "perspectives.rerun.state.inProgress": "進行中",
19713
19723
  "perspectives.rerun.state.verified": "検証済み",
19714
19724
  "perspectives.rerun.vsDeploy": "判定基準: デプロイ",
@@ -19719,11 +19729,11 @@ const CLIENT_JS = `
19719
19729
  "perspectives.rerun.touchedCount": "このケースに一致したデプロイ差分 {n} 件",
19720
19730
  "perspectives.rerun.touchedUnknown": "前回実行以降のデプロイがこのケースに一致する変更を行っています",
19721
19731
  "perspectives.rerun.inProgressHint": "監査か実行がまだ走っているか、監査がデプロイに追いついていません",
19732
+ "perspectives.rerun.heldHint": "このスペックは既に別のジョブが保持しています。今操作するとそのジョブと競合します",
19722
19733
  "perspectives.rerun.repair.testDrift": "生成されたテストが古くなっています。録り直してください",
19723
19734
  "perspectives.rerun.repair.specChange": "spec がコードのやめた動作を書いています。人が判断します",
19724
19735
  "perspectives.rerun.repair.auditUndecided": "監査がコードを読んだうえで判定できませんでした。人が見ます",
19725
19736
  "perspectives.rerun.repair.runFailed": "最後の実行が落ちています。原因を直すまで再実行しても変わりません",
19726
- "perspectives.rerun.why.notEvaluated": "このプロファイルには実行もデプロイも記録がありません",
19727
19737
  "perspectives.rerun.why.noSelectionInRange": "対象範囲に判定を伴わないデプロイがあります",
19728
19738
  "perspectives.rerun.why.selectionUnknown": "影響の有無を判定できませんでした",
19729
19739
  "perspectives.rerun.why.noDeployLog": "このプロファイルのデプロイ記録がありません",
@@ -19732,7 +19742,6 @@ const CLIENT_JS = `
19732
19742
  "perspectives.rerun.why.deployedShaNotInLog": "前回実行のcommitが保持中のデプロイログより古いです",
19733
19743
  "perspectives.rerun.why.gapInRange": "対象範囲のデプロイ記録が欠けています",
19734
19744
  "perspectives.rerun.why.unrecognized": "このUIが認識できない理由がハブから返されました",
19735
- "perspectives.rerun.fix.notEvaluated": "このプロファイルには何も記録がありません。デプロイジョブに ccqa hub deploy record を組み込み、実行レポートを送ってください。比較する対象がそこで初めて生まれます。",
19736
19745
  "perspectives.rerun.fix.noSelectionInRange": "対象範囲に判定を伴わないデプロイがあり、このケースに影響したかどうかを示すものがありません。デプロイジョブで ccqa select-specs を実行し、判定をデプロイと一緒に送ってください。",
19737
19746
  "perspectives.rerun.fix.selectionUnknown": "対象範囲のデプロイは判定されましたが、このケースについては判断がつきませんでした。再実行して基準を取り直してください。",
19738
19747
  "perspectives.rerun.fix.noDeployLog": "このプロファイルのデプロイログに記録がありません。何がデプロイされたかをccqaに伝えるため、この環境のデプロイジョブに ccqa hub deploy record を組み込んでください。",
@@ -19745,12 +19754,7 @@ const CLIENT_JS = `
19745
19754
  "perspectives.rerun.loadFailed": "再実行の要否の読み込みに失敗しました",
19746
19755
  "perspectives.rerun.noDeployLogBanner": "プロファイル {profile} にデプロイの記録がないため、どのケースも判定できません。この環境のデプロイジョブに ccqa hub deploy record を組み込んでください。",
19747
19756
  "perspectives.rerun.deployHead": "最新デプロイ",
19748
- "perspectives.drift.state.notAudited": "未監査",
19749
- "perspectives.drift.state.clean": "ズレなし",
19750
- "perspectives.drift.state.found": "ズレあり", "perspectives.drift.state.unknown": "判定できない",
19751
19757
  "perspectives.drift.graded": "人が確認",
19752
- "perspectives.drift.unsupported": "このハブはdrift監査結果を返しません。利用するにはハブを更新してください。",
19753
- "perspectives.drift.loadFailed": "drift監査結果の読み込みに失敗しました",
19754
19758
  "prompt.card.record": "ブラウザ操作の記録",
19755
19759
  "prompt.card.live": "ライブ実行(AI操作)",
19756
19760
  "prompt.card.playwright": "Playwrightテスト生成",
@@ -21612,13 +21616,14 @@ const CLIENT_JS = `
21612
21616
 
21613
21617
  // "rerun" is the RerunReport for the currently selected profile, or null when
21614
21618
  // 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.
21619
+ // three re-run columns are dropped rather than filled with blanks.
21616
21620
  // "rerunSupported" is tri-state: null until the first answer, then whether
21617
21621
  // this hub answers at all. Chip visibility follows it rather than the report,
21618
21622
  // so switching profile doesn't drop the filter while the next one loads.
21619
21623
  // "drift" is the DriftLedgerResponse, or null when unanswered (older hub, or
21620
21624
  // a failed fetch) — not profile-scoped, so it does not reset when the
21621
- // profile switcher changes (unlike "rerun" above).
21625
+ // profile switcher changes (unlike "rerun" above). Only feeds the audit
21626
+ // column's "audited at" line now; its own finding is superseded by rr.audit.
21622
21627
  var perspState = {
21623
21628
  doc: null, q: "", f: "all",
21624
21629
  rerun: null, rerunSupported: null, runUrls: {}, rerunProfiles: [],
@@ -21655,11 +21660,11 @@ const CLIENT_JS = `
21655
21660
  "/rerun?profile=" + encodeURIComponent(state.profile);
21656
21661
  }
21657
21662
 
21658
- // Shared by rerun and drift, which differ only in path and i18n prefix.
21659
21663
  // 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.
21664
+ // the endpoint costs only the columns it feeds, not the whole tab. A 404
21665
+ // here can only mean "no such route" — the endpoint's own 404 is "the
21666
+ // project has no perspectives document", and this runs only after that
21667
+ // document loaded.
21663
21668
  function fetchLedgerColumn(path, i18nPrefix) {
21664
21669
  return fetch(path, { headers: { Authorization: "Bearer " + state.token } }).then(function (res) {
21665
21670
  if (res.status === 404) return { note: t(i18nPrefix + "unsupported"), kind: "info" };
@@ -21674,7 +21679,10 @@ const CLIENT_JS = `
21674
21679
 
21675
21680
  // ── perspectives: drift ledger ────────────────────────────────────────
21676
21681
  // Not profile-scoped (see perspState.drift above), so unlike rerunPath this
21677
- // takes no ?profile=.
21682
+ // takes no ?profile=. Its own finding is superseded by the audit axis in
21683
+ // the /rerun report (ADR-0014); what survives into the view is only its
21684
+ // coordinate — when a spec was last audited — folded into the audit
21685
+ // column's evidence line (perspAuditCell).
21678
21686
 
21679
21687
  function driftPath() {
21680
21688
  return "/api/v1/projects/" + encodeURIComponent(state.project) + "/drift";
@@ -21726,16 +21734,25 @@ const CLIENT_JS = `
21726
21734
  return text === prefix + reason ? t(prefix + "unrecognized") : text;
21727
21735
  }
21728
21736
 
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 || "");
21737
+ // Every verdict that carries no evidence row explains itself here, in the
21738
+ // actionable phrasing the detail panel wants. decide() checks heldBy before
21739
+ // the audit axis, so a spec another job already holds must be explained by
21740
+ // that hold, not by the audit-hole annotation below which only applies to
21741
+ // an "inProgress" verdict that decide() reached by falling through to a due
21742
+ // audit (ADR-0014). A verdict a newer hub invented falls through to the fix
21743
+ // lookup, so the row says the UI cannot read it rather than going blank —
21744
+ // which looks like missing data.
21745
+ function rerunWhyVerdict(rr) {
21737
21746
  if (rr.verdict === "needsRepair") return rerunReasonText("perspectives.rerun.repair.", rerunRepairCause(rr));
21738
- if (rr.verdict === "inProgress") return t("perspectives.rerun.inProgressHint");
21747
+ if (rr.verdict === "rerunNeeded" && rr.executionAssumedReached) {
21748
+ return rerunReasonText("perspectives.rerun.fix.", rr.executionAssumedReached);
21749
+ }
21750
+ if (rr.verdict === "inProgress") {
21751
+ if (rr.heldBy) return t("perspectives.rerun.heldHint");
21752
+ return rr.auditAssumedReached
21753
+ ? rerunReasonText("perspectives.rerun.fix.", rr.auditAssumedReached)
21754
+ : t("perspectives.rerun.inProgressHint");
21755
+ }
21739
21756
  return rerunReasonText("perspectives.rerun.fix.", rr.verdict);
21740
21757
  }
21741
21758
 
@@ -21750,10 +21767,12 @@ const CLIENT_JS = `
21750
21767
 
21751
21768
  // The short justification a table cell carries under its badge. Nothing here
21752
21769
  // may collapse to a bare "up to date" — verified names the deploy it was
21753
- // judged against, and unanswerable names the missing input.
21770
+ // judged against, and a spec assumed reached names the hole that made it so
21771
+ // rather than claiming a deploy matched it.
21754
21772
  function rerunCellWhy(rr) {
21755
21773
  var head = perspState.rerun && perspState.rerun.deployHead;
21756
21774
  if (rr.verdict === "rerunNeeded") {
21775
+ if (rr.executionAssumedReached) return rerunReasonText("perspectives.rerun.why.", rr.executionAssumedReached);
21757
21776
  if (!rr.touchedBy || !rr.touchedBy.length) return t("perspectives.rerun.touchedUnknown");
21758
21777
  return t("perspectives.rerun.touchedCount").replace("{n}", String(rr.touchedBy.length));
21759
21778
  }
@@ -21761,8 +21780,7 @@ const CLIENT_JS = `
21761
21780
  if (!head) return t("perspectives.rerun.noDeployHead");
21762
21781
  return t("perspectives.rerun.vsDeploy") + " " + shortSha(head.sha) + " · " + relTime(head.at);
21763
21782
  }
21764
- if (rr.verdict === "unanswerable") return rerunReasonText("perspectives.rerun.why.", rr.reason || "");
21765
- return rerunCannotJudge(rr);
21783
+ return rerunWhyVerdict(rr);
21766
21784
  }
21767
21785
 
21768
21786
 
@@ -21775,27 +21793,26 @@ const CLIENT_JS = `
21775
21793
  // Bar segments in drawing order: what a person must act on first, then what
21776
21794
  // the pipeline still owes, then what needs nothing. "needsRepair" leads
21777
21795
  // 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"];
21796
+ // the spec or the product.
21797
+ var RERUN_ORDER = ["needsRepair", "rerunNeeded", "inProgress", "verified"];
21782
21798
  var RERUN_SEG_CLASS = {
21783
- needsRepair: "sg-needsrepair", rerunNeeded: "sg-rerunneeded", unanswerable: "sg-unanswerable",
21799
+ needsRepair: "sg-needsrepair", rerunNeeded: "sg-rerunneeded",
21784
21800
  inProgress: "sg-inprogress", verified: "sg-verified"
21785
21801
  };
21786
21802
 
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.
21803
+ // The one rule the summary bar and the verdict filter chips both answer
21804
+ // to: a case with no verdict, or a verdict a newer hub invented, counts as
21805
+ // needing a rununanswered means unverified (ADR-0014). Both call this
21806
+ // rather than each keeping its own copy of the fallback.
21807
+ function rerunVerdictOf(rr) {
21808
+ var v = rr && rr.verdict;
21809
+ return v && RERUN_ORDER.indexOf(v) !== -1 ? v : "rerunNeeded";
21810
+ }
21811
+
21812
+ // One verdict per case, bucketed, via rerunVerdictOf above.
21792
21813
  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
- });
21814
+ var counts = { needsRepair: 0, rerunNeeded: 0, inProgress: 0, verified: 0 };
21815
+ verdicts.forEach(function (rr) { counts[rerunVerdictOf(rr)] += 1; });
21799
21816
  return counts;
21800
21817
  }
21801
21818
 
@@ -21811,52 +21828,69 @@ const CLIENT_JS = `
21811
21828
  }
21812
21829
  // --- end pure: rerun composition -----------------------------------------
21813
21830
 
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";
21831
+ // --- pure: execution composition ------------------------------------------
21832
+ // Same shape as rerun composition above, self-contained for the same
21833
+ // reason, and read from the same rr records: bucketed by the execution
21834
+ // axis rather than the derived verdict. The mapping duplicates
21835
+ // perspRunState's two renames (neverRun -> never, stale -> superseded)
21836
+ // rather than calling it, so this region stays independently liftable.
21837
+ var EXEC_ORDER = ["failed", "superseded", "never", "passed"];
21838
+ var EXEC_SEG_CLASS = {
21839
+ failed: "sg-exec-failed", superseded: "sg-exec-stale",
21840
+ never: "sg-exec-never", passed: "sg-exec-passed"
21841
+ };
21842
+
21843
+ // A case with no verdict at all reads as never run — same "unanswered
21844
+ // means unverified" rule rerunComposition applies, and the safe direction:
21845
+ // it never inflates "passed".
21846
+ function executionComposition(records) {
21847
+ var counts = { failed: 0, superseded: 0, never: 0, passed: 0 };
21848
+ records.forEach(function (rr) {
21849
+ var exec = rr && rr.execution;
21850
+ var key = !exec || exec === "neverRun" ? "never" : exec === "stale" ? "superseded" : exec;
21851
+ counts[Object.prototype.hasOwnProperty.call(counts, key) ? key : "never"] += 1;
21852
+ });
21853
+ return counts;
21832
21854
  }
21833
21855
 
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",
21856
+ // Only states with cases in them get drawn see rerunSegments' comment above.
21857
+ function executionSegments(counts) {
21858
+ var out = [];
21859
+ EXEC_ORDER.forEach(function (key) {
21860
+ if (counts[key] > 0) out.push({ state: key, count: counts[key], cls: EXEC_SEG_CLASS[key] });
21861
+ });
21862
+ return out;
21863
+ }
21864
+ // --- end pure: execution composition --------------------------------------
21865
+
21866
+ // --- pure: audit composition -----------------------------------------------
21867
+ // Same shape again, bucketed by the audit axis (ADR-0014).
21868
+ var AUDIT_ORDER = ["drifted", "undecided", "due", "clean"];
21869
+ var AUDIT_SEG_CLASS = {
21870
+ drifted: "sg-audit-drifted", undecided: "sg-audit-undecided",
21871
+ due: "sg-audit-due", clean: "sg-audit-clean"
21843
21872
  };
21844
21873
 
21845
- function driftComposition(entries) {
21846
- var counts = { found: 0, unknown: 0, notAudited: 0, clean: 0 };
21847
- entries.forEach(function (entry) { counts[driftState(entry)] += 1; });
21874
+ // A case with no audit axis at all reads as due — the same "unanswered
21875
+ // means not yet cleared" rule as the other two axes.
21876
+ function auditComposition(records) {
21877
+ var counts = { drifted: 0, undecided: 0, due: 0, clean: 0 };
21878
+ records.forEach(function (rr) {
21879
+ var key = (rr && rr.audit) || "due";
21880
+ counts[Object.prototype.hasOwnProperty.call(counts, key) ? key : "due"] += 1;
21881
+ });
21848
21882
  return counts;
21849
21883
  }
21850
21884
 
21851
21885
  // Only states with cases in them get drawn — see rerunSegments' comment above.
21852
- function driftSegments(counts) {
21886
+ function auditSegments(counts) {
21853
21887
  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] });
21888
+ AUDIT_ORDER.forEach(function (key) {
21889
+ if (counts[key] > 0) out.push({ state: key, count: counts[key], cls: AUDIT_SEG_CLASS[key] });
21856
21890
  });
21857
21891
  return out;
21858
21892
  }
21859
- // --- end pure: drift composition -------------------------------------------
21893
+ // --- end pure: audit composition --------------------------------------------
21860
21894
 
21861
21895
  // One ledger entry as "<short sha> · <when>", linking to the hub's run detail
21862
21896
  // and, when that run recorded one, to the CI run. Clicks must not bubble: the
@@ -21914,30 +21948,37 @@ const CLIENT_JS = `
21914
21948
  // still true" are two faces of one question, so the row answers it once and
21915
21949
  // the detail panel keeps the coordinates.
21916
21950
  //
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.
21951
+ // --- pure: run-state labels ----------------------------------------------
21952
+ // Self-contained (no DOM, no closures) so rerun-view.test.ts can lift it and
21953
+ // check that every execution value the hub can send has a badge and wording.
21954
+
21955
+ // A recorded failure outranks the deploy that landed after it: a red result
21956
+ // is current information, and repeating it teaches nothing until someone
21957
+ // repairs it.
21921
21958
  //
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.
21959
+ // The wording keys are not the axis values: ADR-0010 reserves the freshness
21960
+ // adjectives for drift, so what the schema calls "stale" is shown as what it
21961
+ // means for this case — it has not run since the deploy.
21926
21962
  function perspRunState(rr) {
21927
21963
  if (!rr || !rr.execution) return null;
21928
- return rr.execution === "neverRun" ? "never" : rr.execution;
21964
+ if (rr.execution === "neverRun") return "never";
21965
+ return rr.execution === "stale" ? "superseded" : rr.execution;
21929
21966
  }
21930
21967
 
21931
21968
  // 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" };
21969
+ // than minting a parallel palette: amber for "act on this", and the run
21970
+ // status colours for a result that still stands.
21971
+ var RUN_STATE_BADGE = { failed: "failed", passed: "passed", superseded: "rr-needed", never: "rr-none" };
21972
+ // --- end pure: run-state labels -------------------------------------------
21935
21973
 
21936
21974
  // The primary column. The two axes beside it answer "why"; this one answers
21937
21975
  // "who acts next", which is the only question a reader scanning a list of
21938
21976
  // specs has. Exactly one value, needsRepair, asks for a person.
21977
+ // Same colours the summary bar uses, so a row and the bar above it cannot
21978
+ // disagree. needsRepair is the only verdict that asks for a person, so it
21979
+ // takes the attention colour and re-running — machine work — does not.
21939
21980
  var VERDICT_BADGE = {
21940
- needsRepair: "rr-needed", rerunNeeded: "rr-needed", unanswerable: "rr-unknown",
21981
+ needsRepair: "rr-repair", rerunNeeded: "rr-needed",
21941
21982
  inProgress: "rr-none", verified: "passed"
21942
21983
  };
21943
21984
 
@@ -21957,15 +21998,28 @@ const CLIENT_JS = `
21957
21998
  return td;
21958
21999
  }
21959
22000
 
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.
22001
+ // A reason that pinned a spec to a pending state without a demonstrable
22002
+ // deploy touch (ADR-0014, auditAssumedReached / executionAssumedReached).
22003
+ // The short form is always visible as a .cellsub; the title attribute
22004
+ // carries the longer, actionable form for hover, so what closes the hole
22005
+ // is available without depending on a pointer to find it.
22006
+ function assumedReachedSub(reason) {
22007
+ var sub = el("span", "cellsub", rerunReasonText("perspectives.rerun.why.", reason));
22008
+ sub.title = rerunReasonText("perspectives.rerun.fix.", reason);
22009
+ return sub;
22010
+ }
22011
+
22012
+ // Axis 1, as the hub derived it against the deployed commit — fresher than
22013
+ // the raw drift ledger, whose entry may predate the deploy. The ledger
22014
+ // survives here only as the "audited at" coordinate (ledgerLine below):
22015
+ // its own finding is superseded by rr.audit/rr.driftLabel above, so
22016
+ // showing both would repeat one answer in two vocabularies — the bug this
22017
+ // column used to have paired with a since-removed fourth column.
21963
22018
  var AUDIT_BADGE = {
21964
- due: "rr-none", clean: "passed", drifted: "failed",
21965
- undecided: "rr-unknown", cannotTell: "rr-unknown"
22019
+ due: "rr-none", clean: "passed", drifted: "failed", undecided: "rr-unknown"
21966
22020
  };
21967
22021
 
21968
- function perspAuditCell(rr) {
22022
+ function perspAuditCell(rr, driftEntry) {
21969
22023
  var td = el("td");
21970
22024
  if (!rr || !rr.audit) {
21971
22025
  td.appendChild(el("span", "muted", "\u2014"));
@@ -21977,6 +22031,23 @@ const CLIENT_JS = `
21977
22031
  td.appendChild(badge);
21978
22032
  if (rr.audit === "drifted" && rr.driftLabel) {
21979
22033
  td.appendChild(el("span", "cellsub", labelText(rr.driftLabel)));
22034
+ } else if (rr.auditAssumedReached) {
22035
+ // Due because the log could not place the audit, not because a deploy
22036
+ // demonstrably reached it. Say which, or the whole column reads "due"
22037
+ // with no cause anywhere on the page.
22038
+ td.appendChild(assumedReachedSub(rr.auditAssumedReached));
22039
+ }
22040
+ // "audited at sha · when" — the same freshness evidence the execution
22041
+ // column gives its own last result, below. SpecRerun does not carry the
22042
+ // audit's own coordinate, so it comes from the drift ledger.
22043
+ if (driftEntry) {
22044
+ var sub = el("span", "cellsub");
22045
+ sub.appendChild(ledgerLine(driftEntry));
22046
+ if (driftEntry.graded) {
22047
+ sub.appendChild(document.createTextNode(" · "));
22048
+ sub.appendChild(el("span", "graded-mark", t("perspectives.drift.graded")));
22049
+ }
22050
+ td.appendChild(sub);
21980
22051
  }
21981
22052
  return td;
21982
22053
  }
@@ -21987,7 +22058,7 @@ const CLIENT_JS = `
21987
22058
  // No verdict at all: this case is in the document but not in the report
21988
22059
  // (added since it was computed). Not the same statement as "never run".
21989
22060
  if (!runState) {
21990
- td.appendChild(el("span", "muted", ""));
22061
+ td.appendChild(el("span", "muted", "\u2014"));
21991
22062
  return td;
21992
22063
  }
21993
22064
  var badge = el("span", "badge " + RUN_STATE_BADGE[runState]);
@@ -21995,8 +22066,11 @@ const CLIENT_JS = `
21995
22066
  badge.appendChild(document.createTextNode(" " + t("perspectives.run.state." + runState)));
21996
22067
  td.appendChild(badge);
21997
22068
 
21998
- // The sub-line is the coordinate of the result being reported. Why it
21999
- // matters belongs to the verdict column, not here.
22069
+ // The sub-line is the coordinate of the result being reported the same
22070
+ // "when is this from" evidence the audit column carries above, so the two
22071
+ // axes read as siblings. Why the currency matters belongs to the verdict
22072
+ // column, except for the hole that pinned this to "pending" with no
22073
+ // deploy to point at.
22000
22074
  if (runState !== "never") {
22001
22075
  var last = lastResult(rr);
22002
22076
  if (last) {
@@ -22005,53 +22079,7 @@ const CLIENT_JS = `
22005
22079
  td.appendChild(sub);
22006
22080
  }
22007
22081
  }
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
- }
22082
+ if (rr.executionAssumedReached) td.appendChild(assumedReachedSub(rr.executionAssumedReached));
22055
22083
  return td;
22056
22084
  }
22057
22085
 
@@ -22105,45 +22133,47 @@ const CLIENT_JS = `
22105
22133
  return row;
22106
22134
  }
22107
22135
 
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.
22136
+ // Summary: the inventory as one line, then one row per axis — verdict,
22137
+ // execution, audit, the same three groupings the table's columns show, so
22138
+ // the bars and the table never disagree about what a word means. The mode
22139
+ // and recorded-ness counts are not lost; they moved onto the filter chips,
22140
+ // where a count states what that filter would leave behind.
22113
22141
  //
22114
22142
  // 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.
22143
+ // has been recorded on) every case is "not evaluated" and all three bars
22144
+ // are omitted together, rather than showing a composition that reads as
22145
+ // "nothing to do".
22121
22146
  function renderPerspOverview(doc) {
22122
22147
  var host = document.getElementById("persp-ov");
22123
22148
  clear(host);
22124
- var verdicts = [];
22125
- var driftEntries = perspState.drift ? [] : null;
22149
+ var records = [];
22126
22150
  doc.features.forEach(function (feature) {
22127
22151
  feature.specs.forEach(function (spec) {
22128
- verdicts.push(ledgerEntryFor(perspState.rerun, feature, spec));
22129
- if (driftEntries) driftEntries.push(ledgerEntryFor(perspState.drift, feature, spec));
22152
+ records.push(ledgerEntryFor(perspState.rerun, feature, spec));
22130
22153
  });
22131
22154
  });
22132
22155
 
22133
22156
  var inv = el("div", "ov-inv");
22134
- inv.appendChild(el("b", null, String(verdicts.length)));
22157
+ inv.appendChild(el("b", null, String(records.length)));
22135
22158
  inv.appendChild(document.createTextNode(" " + t("perspectives.ov.cases") + " / "));
22136
22159
  inv.appendChild(el("b", null, String(doc.features.length)));
22137
22160
  inv.appendChild(document.createTextNode(" " + t("perspectives.ov.features")));
22138
22161
  host.appendChild(inv);
22139
- if (!verdicts.length) return;
22162
+ if (!records.length) return;
22140
22163
 
22141
- host.appendChild(ovAxisRow(
22142
- t("perspectives.ov.axis.rerun"), rerunSegments(rerunComposition(verdicts)), "perspectives.rerun.state.", verdicts.length,
22143
- ));
22144
- if (driftEntries) {
22164
+ // Gated on the report as a whole: with none, none of the three axes has
22165
+ // anything to compose, and an un-composed bar would paint every case as
22166
+ // needing work — an overstatement about the hub rather than about the
22167
+ // specs.
22168
+ if (perspState.rerun) {
22145
22169
  host.appendChild(ovAxisRow(
22146
- t("perspectives.ov.axis.drift"), driftSegments(driftComposition(driftEntries)), "perspectives.drift.state.", driftEntries.length,
22170
+ t("perspectives.col.verdict"), rerunSegments(rerunComposition(records)), "perspectives.rerun.state.", records.length,
22171
+ ));
22172
+ host.appendChild(ovAxisRow(
22173
+ t("perspectives.col.run"), executionSegments(executionComposition(records)), "perspectives.run.state.", records.length,
22174
+ ));
22175
+ host.appendChild(ovAxisRow(
22176
+ t("perspectives.col.audit"), auditSegments(auditComposition(records)), "perspectives.audit.state.", records.length,
22147
22177
  ));
22148
22178
  }
22149
22179
  }
@@ -22155,15 +22185,16 @@ const CLIENT_JS = `
22155
22185
  function perspMatches(feature, spec, f) {
22156
22186
  if (f === "deterministic" && perspMode(spec) !== "deterministic") return false;
22157
22187
  if (f === "live" && perspMode(spec) !== "live") return false;
22158
- if (f === "rerun") {
22188
+ // The verdict chips (same words as RERUN_ORDER/the 判定 column) share
22189
+ // rerunVerdictOf with the summary bar, so an unrecognised verdict is
22190
+ // "rerunNeeded" in both places rather than matching no chip. With no
22191
+ // report at all there is nothing to filter on, so every chip yields
22192
+ // nothing.
22193
+ if (RERUN_ORDER.indexOf(f) !== -1) {
22194
+ if (!perspState.rerun) return false;
22159
22195
  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;
22196
+ if (rerunVerdictOf(rr) !== f) return false;
22163
22197
  }
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
22198
  if (perspState.q) {
22168
22199
  var hay = (spec.title + " " + (spec.summary || "") + " " + spec.specName).toLowerCase();
22169
22200
  if (hay.indexOf(perspState.q) === -1) return false;
@@ -22186,14 +22217,19 @@ const CLIENT_JS = `
22186
22217
  // lift this region out of the rendered page and run it: which label the
22187
22218
  // panel's evidence row wears, and whether it has a failure row at all.
22188
22219
 
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";
22220
+ // The deploy log answered for this case: the row can show what it holds.
22221
+ // A case the log could not place has no evidence to show even though its
22222
+ // verdict is the samethe assumption is the answer, not a finding.
22223
+ function rerunHasEvidence(rr) {
22224
+ if (rr.verdict === "verified") return true;
22225
+ return rr.verdict === "rerunNeeded" && !rr.executionAssumedReached;
22226
+ }
22227
+
22228
+ // Evidence is labelled by the timeframe it covers; everything else names why
22229
+ // the verdict landed — a different kind of content, and forcing one label
22230
+ // over both would make one of the two read as a lie.
22231
+ function rerunEvidenceLabelKey(rr) {
22232
+ return rerunHasEvidence(rr) ? "perspectives.d.changedSince" : "perspectives.d.whyVerdict";
22197
22233
  }
22198
22234
 
22199
22235
  // The failure row points at a run. With no failure there is nothing to point
@@ -22229,8 +22265,8 @@ const CLIENT_JS = `
22229
22265
  // The label already states the timeframe, so the value never repeats it.
22230
22266
  function rerunEvidenceValue(rr) {
22231
22267
  var wrap = el("div");
22232
- if (rr.verdict !== "rerunNeeded" && rr.verdict !== "verified") {
22233
- wrap.appendChild(el("div", "d-prose", rerunCannotJudge(rr)));
22268
+ if (!rerunHasEvidence(rr)) {
22269
+ wrap.appendChild(el("div", "d-prose", rerunWhyVerdict(rr)));
22234
22270
  return wrap;
22235
22271
  }
22236
22272
  // Both states require a non-empty deploy log, so a head-less report
@@ -22280,7 +22316,7 @@ const CLIENT_JS = `
22280
22316
 
22281
22317
  var rr = ledgerEntryFor(perspState.rerun, feature, spec);
22282
22318
  if (rr) {
22283
- row(rerunEvidenceLabelKey(rr.verdict), rerunEvidenceValue(rr));
22319
+ row(rerunEvidenceLabelKey(rr), rerunEvidenceValue(rr));
22284
22320
  if (rerunHasFailure(rr)) row("perspectives.d.lastRed", ledgerLine(rr.lastRed));
22285
22321
  }
22286
22322
  frag.appendChild(dl);
@@ -22325,14 +22361,12 @@ const CLIENT_JS = `
22325
22361
  var tbody = document.getElementById("persp-tbody");
22326
22362
  clear(tbody);
22327
22363
  // 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.
22364
+ // exactly as it was on a hub that cannot answer the re-run question.
22329
22365
  var showRerun = perspState.rerun != null;
22330
- var showDrift = perspState.drift != null;
22331
22366
  document.getElementById("persp-th-verdict").hidden = !showRerun;
22332
22367
  document.getElementById("persp-th-audit").hidden = !showRerun;
22333
22368
  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);
22369
+ var cols = 3 + (showRerun ? 3 : 0);
22336
22370
  var hits = 0;
22337
22371
  doc.features.forEach(function (feature) {
22338
22372
  var specs = feature.specs.filter(function (s) { return perspMatches(feature, s, perspState.f); });
@@ -22364,10 +22398,9 @@ const CLIENT_JS = `
22364
22398
  if (showRerun) {
22365
22399
  var rr = ledgerEntryFor(perspState.rerun, feature, spec);
22366
22400
  row.appendChild(perspVerdictCell(rr));
22367
- row.appendChild(perspAuditCell(rr));
22368
22401
  row.appendChild(perspRunCell(rr));
22402
+ row.appendChild(perspAuditCell(rr, ledgerEntryFor(perspState.drift, feature, spec)));
22369
22403
  }
22370
- if (showDrift) row.appendChild(perspDriftCell(ledgerEntryFor(perspState.drift, feature, spec)));
22371
22404
 
22372
22405
  var chevTd = el("td", "c-chev");
22373
22406
  chevTd.appendChild(el("span", "chev-i", "\\u25b6"));
@@ -22410,21 +22443,17 @@ const CLIENT_JS = `
22410
22443
  renderPerspTable(doc);
22411
22444
  }
22412
22445
 
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".
22446
+ // The verdict chip group only exists while the hub answers the re-run
22447
+ // question; otherwise every one of them would filter everything away. Drop
22448
+ // back to "all" if one was the active filter when the answer came back
22449
+ // "not supported".
22416
22450
  //
22417
22451
  // Each chip also carries what it would yield — the mode breakdown the
22418
22452
  // summary row used to spend four tiles on.
22419
22453
  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";
22454
+ var group = document.getElementById("persp-verdict-chips");
22455
+ group.hidden = perspState.rerunSupported !== true;
22456
+ if (perspState.rerunSupported !== true && RERUN_ORDER.indexOf(perspState.f) !== -1) perspState.f = "all";
22428
22457
  document.querySelectorAll("#view-perspectives .fchip").forEach(function (b) {
22429
22458
  var f = b.getAttribute("data-f");
22430
22459
  b.setAttribute("aria-pressed", String(f === perspState.f));
@@ -22432,7 +22461,8 @@ const CLIENT_JS = `
22432
22461
  });
22433
22462
  }
22434
22463
 
22435
- // Shared by the rerun and drift notes, which differ only in which box they fill.
22464
+ // boxId is a parameter rather than hardcoded so a future note box can reuse
22465
+ // this without copying it — today only "persp-rerun-note" calls it.
22436
22466
  function setPerspNote(boxId, text, kind) {
22437
22467
  var box = document.getElementById(boxId);
22438
22468
  box.hidden = !text;
@@ -22468,7 +22498,6 @@ const CLIENT_JS = `
22468
22498
  setPerspDeployHead(null);
22469
22499
  perspState.rerun = null;
22470
22500
  perspState.rerunSupported = null;
22471
- setPerspNote("persp-drift-note", "");
22472
22501
  perspState.drift = null;
22473
22502
  syncPerspChips();
22474
22503
  fetchPerspectives()
@@ -22491,9 +22520,9 @@ const CLIENT_JS = `
22491
22520
  loadRerun().catch(function (err) {
22492
22521
  setPerspNote("persp-rerun-note", t("perspectives.rerun.loadFailed") + ": " + err.message, "warn");
22493
22522
  }),
22494
- loadDrift().catch(function (err) {
22495
- setPerspNote("persp-drift-note", t("perspectives.drift.loadFailed") + ": " + err.message, "warn");
22496
- }),
22523
+ // Evidence-only (the "audited at" line in the audit column) — a
22524
+ // failed or unsupported fetch just omits that line, no banner.
22525
+ loadDrift().catch(function () {}),
22497
22526
  ]);
22498
22527
  })
22499
22528
  .catch(function (err) {
@@ -22506,6 +22535,82 @@ const CLIENT_JS = `
22506
22535
  return state.project + "/" + state.profile;
22507
22536
  }
22508
22537
 
22538
+ // --- pure: data-profile pick ---------------------------------------------
22539
+ // Self-contained on purpose (no DOM, no closures): the network probing this
22540
+ // feeds is what makes "has deploy data" answerable at all, but which
22541
+ // candidate wins from the results is a plain function worth pinning without
22542
+ // mocking a fetch.
22543
+
22544
+ // "default" is always offered (the API guarantees it) but usually holds no
22545
+ // deploys, so opening straight into it reads as broken: every row pending,
22546
+ // a "no deploy log" banner. Fallback for when no candidate's deploy log can
22547
+ // be confirmed (a fresh project): prefer a profile the run index shows has
22548
+ // runs, with exactly one such profile the only reasonable pick.
22549
+ function pickDataProfile(current, dataProfiles) {
22550
+ if (!dataProfiles.length || dataProfiles.indexOf(current) !== -1) return current;
22551
+ return dataProfiles.length === 1 ? dataProfiles[0] : dataProfiles.slice().sort()[0];
22552
+ }
22553
+
22554
+ // Every profile worth checking for deploy data, in preference order: the
22555
+ // current profile first (so an already-fine pick is left alone), then
22556
+ // run-index profiles (a profile with runs is a maintained environment),
22557
+ // then the project's full profile set. That last tier is what makes a
22558
+ // profile deploys are recorded under but nothing has ever run against
22559
+ // reachable at all — the run index alone has no way to see it.
22560
+ function dataProfileCandidates(current, dataProfiles, projectProfiles) {
22561
+ var seen = {};
22562
+ var out = [];
22563
+ [current].concat(dataProfiles, projectProfiles).forEach(function (p) {
22564
+ if (p && !seen[p]) { seen[p] = true; out.push(p); }
22565
+ });
22566
+ return out;
22567
+ }
22568
+
22569
+ // The deterministic half of resolveDataProfile below: given which
22570
+ // candidates actually have a deploy log (probed in parallel, so answers can
22571
+ // arrive in any order), pick the first by candidate order, not by response
22572
+ // order — the result must not depend on network timing. Falls back to
22573
+ // pickDataProfile's answer when nothing confirms (no project has deploy
22574
+ // data yet), leaving that case's behaviour unchanged.
22575
+ function pickFirstWithDeployLog(candidates, hasLog, current, dataProfiles) {
22576
+ for (var i = 0; i < candidates.length; i++) {
22577
+ if (hasLog[i]) return candidates[i];
22578
+ }
22579
+ return pickDataProfile(current, dataProfiles);
22580
+ }
22581
+ // --- end pure: data-profile pick ------------------------------------------
22582
+
22583
+ // Confirms a candidate actually has a deploy log, rather than merely being
22584
+ // known to the run index or the secrets tab — the two facts pickDataProfile
22585
+ // used to conflate (a profile can hold runs, or secrets, with no deploy log
22586
+ // at all). limit=1 makes this a cheap existence check.
22587
+ function hasDeployLog(profile) {
22588
+ return apiFetch(
22589
+ "/api/v1/projects/" + encodeURIComponent(state.project) + "/deploys?profile=" + encodeURIComponent(profile) + "&limit=1",
22590
+ ).then(function (data) { return !!(data && data.entries && data.entries.length); })
22591
+ .catch(function () { return false; });
22592
+ }
22593
+
22594
+ // The candidate set from /profiles is broader than the run index (see
22595
+ // dataProfileCandidates), so probe every candidate's deploy log in
22596
+ // parallel before letting pickFirstWithDeployLog decide.
22597
+ function resolveDataProfile(current, dataProfiles, projectProfiles) {
22598
+ var candidates = dataProfileCandidates(current, dataProfiles, projectProfiles);
22599
+ return Promise.all(candidates.map(hasDeployLog))
22600
+ .then(function (hasLog) { return pickFirstWithDeployLog(candidates, hasLog, current, dataProfiles); });
22601
+ }
22602
+
22603
+ // The full profile set (secrets tab's universe) is broader than the run
22604
+ // index: a profile deploys are recorded under but nothing has ever run
22605
+ // against is invisible to fetchRunIndex. Fetched independently from
22606
+ // loadProfiles, which mutates knownProfiles/state.profile as a side effect
22607
+ // the Secrets tab depends on and this must not trigger.
22608
+ function fetchProjectProfiles() {
22609
+ return apiFetch("/api/v1/projects/" + encodeURIComponent(state.project) + "/profiles")
22610
+ .then(function (data) { return (data && data.profiles) || []; })
22611
+ .catch(function () { return []; });
22612
+ }
22613
+
22509
22614
  // Project-scoped (see fetchRunIndex) — never rejects, so this always
22510
22615
  // re-renders once the run index settles, whichever of the three loads in
22511
22616
  // loadPerspectives is slowest.
@@ -22513,7 +22618,22 @@ const CLIENT_JS = `
22513
22618
  return fetchRunIndex().then(function (result) {
22514
22619
  perspState.runUrls = result.urls;
22515
22620
  perspState.rerunProfiles = result.profiles;
22516
- renderPerspectives();
22621
+ if (storedProfileForProject(state.project)) {
22622
+ renderPerspectives();
22623
+ return;
22624
+ }
22625
+ // Never overrides a choice the user made explicitly (the check above)
22626
+ // — this only fills in the very first, unopinionated default.
22627
+ return fetchProjectProfiles()
22628
+ .then(function (projectProfiles) { return resolveDataProfile(state.profile, result.profiles, projectProfiles); })
22629
+ .then(function (pick) {
22630
+ if (pick !== state.profile) {
22631
+ setProfile(pick);
22632
+ renderPerspectives();
22633
+ return reloadRerun();
22634
+ }
22635
+ renderPerspectives();
22636
+ });
22517
22637
  });
22518
22638
  }
22519
22639
 
@@ -22529,8 +22649,9 @@ const CLIENT_JS = `
22529
22649
  if (rerun.note) {
22530
22650
  setPerspNote("persp-rerun-note", rerun.note, rerun.kind);
22531
22651
  } 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.
22652
+ // With no deploy log nothing can be placed, so every case is assumed
22653
+ // reached. Say once, at the top, what is missing and how to supply it,
22654
+ // rather than repeating it on every row.
22534
22655
  setPerspNote("persp-rerun-note", t("perspectives.rerun.noDeployLogBanner").replace("{profile}", state.profile), "warn");
22535
22656
  } else {
22536
22657
  setPerspNote("persp-rerun-note", "");
@@ -22540,13 +22661,18 @@ const CLIENT_JS = `
22540
22661
  }
22541
22662
 
22542
22663
  // Loaded once per project open — never re-run on a profile switch, since
22543
- // drift carries no profile (unlike loadRerun/reloadRerun below).
22664
+ // drift carries no profile (unlike loadRerun/reloadRerun below). Only the
22665
+ // ledger's own coordinate (when a spec was last audited) survives into the
22666
+ // view now — its finding is superseded by the fresher, deploy-aware audit
22667
+ // axis in the /rerun report — so there is nothing here worth a banner on
22668
+ // an older or unreachable hub.
22544
22669
  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
- });
22670
+ return fetch(driftPath(), { headers: { Authorization: "Bearer " + state.token } })
22671
+ .then(function (res) { return res.ok ? res.json() : null; }, function () { return null; })
22672
+ .then(function (report) {
22673
+ perspState.drift = report || null;
22674
+ renderPerspectives();
22675
+ });
22550
22676
  }
22551
22677
 
22552
22678
  // Switching profile re-asks only the profile-scoped question: the