omp-conductor 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/daemon.ts CHANGED
@@ -18,11 +18,13 @@ import {
18
18
  resolveReleaseGrants,
19
19
  stateDir,
20
20
  } from "./config.ts";
21
+ import { availabilityState, type AvailabilityState } from "./availability.ts";
21
22
  import {
22
23
  analyseSettlement,
23
24
  formatSettlementFlags,
24
25
  settlementFlagSummary,
25
26
  } from "./diff-flags.ts";
27
+ import { digestScheduleState, type DigestScheduleState } from "./digest-schedule.ts";
26
28
  import { createEscalator, escalationIssueRef } from "./escalate.ts";
27
29
  import { pendingCodeGraph, probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
28
30
  import { graphHint } from "./graph.ts";
@@ -30,7 +32,11 @@ import { livingDaemon } from "./lifecycle.ts";
30
32
  import { STALL_MARKER_FILE } from "./orchestrator-tick.ts";
31
33
  import { startOrchestrator } from "./orchestrator.ts";
32
34
  import type { OrchestratorHandle } from "./orchestrator.ts";
33
- import { createReportOutbox, formatOpenReports } from "./reports.ts";
35
+ import {
36
+ createReportOutbox,
37
+ enqueueAvailableHeldNotices,
38
+ formatOpenReports,
39
+ } from "./reports.ts";
34
40
  import {
35
41
  recordReleaseBlock,
36
42
  type ReleaseBlockContext,
@@ -42,11 +48,13 @@ import { classifyRun, providerCreditRefusal, providerTransientFault, type Classi
42
48
  import { projectLabels } from "./label-projection.ts";
43
49
  import { dbPath, LIVE_STATES, openStore, utcDay } from "./store.ts";
44
50
  import { makeTracker, type RateLimitStatus } from "./tracker/github.ts";
45
- import { RELEASE_SHAPES } from "./types.ts";
51
+ import { DEFAULT_REPORT_POLICY, RELEASE_SHAPES } from "./types.ts";
46
52
  import type {
53
+ BaseHealth,
47
54
  AdmissionHoldReason,
48
55
  Caps,
49
56
  DispatchSummary,
57
+ DigestBacklog,
50
58
  Escalation,
51
59
  IssueSnapshot,
52
60
  MergedPrInfo,
@@ -167,6 +175,9 @@ interface Deps {
167
175
  caps: Caps;
168
176
  tracker: Tracker;
169
177
  store: Store;
178
+ /** False after a live config reload fails; autonomous delivery then holds
179
+ * fail-closed until a later tick validates the config again. */
180
+ deliveryPolicyValid?: boolean;
170
181
  /** Provider-reported plan allowance, cached with a TTL. Resolved once at
171
182
  * startup like every other dep so a tick cannot swap its own meter. */
172
183
  usage: UsageSource;
@@ -1402,12 +1413,15 @@ export async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<
1402
1413
  });
1403
1414
  if (await settleStopBeforeSession()) return;
1404
1415
 
1416
+ const repoSlug = githubRepo(r.repo.cloneUrl);
1417
+
1405
1418
  let result: WorkerResult;
1406
1419
  try {
1407
1420
  result = await runWorker({
1408
1421
  brief,
1409
1422
  cwd: worktreePath,
1410
1423
  caps,
1424
+ ...(repoSlug === undefined ? {} : { repoSlug }),
1411
1425
  maxTurns: () => turnLimit?.maxTurns() ?? maxTurns,
1412
1426
  onPauseControl: (control) => {
1413
1427
  workerSessionInstalled = true;
@@ -1804,12 +1818,12 @@ export interface Settlement {
1804
1818
  *
1805
1819
  * - `merged` — the work landed. That is what `merged` was reserved for.
1806
1820
  * - `closed` — a human read the work and said no. Leaving it `pushed-green`
1807
- * strands the issue forever behind a PR nobody will ever merge, and calling it
1808
- * `merged` is simply a lie about work that does not exist on the base branch.
1809
- * `failed` is true the attempt did not land — and it releases the busy guard,
1810
- * so an issue a human re-queues can be attempted again. The attempt counter is
1811
- * untouched either way: this row was a real attempt, and pretending otherwise
1812
- * would let a rejected issue cycle past `maxAttemptsPerIssue`.
1821
+ * forever is a lie; `failed` records that it did not land and releases the
1822
+ * busy guard, so an issue a human re-queues can be attempted again. A row
1823
+ * that had reached `pushed-green` or `pushed-pending` is classified
1824
+ * `returned-for-revision` at settlement. A review decision asks for another
1825
+ * implementation pass, not a failure, so it consumes the continuation budget
1826
+ * instead of the failed-attempt budget.
1813
1827
  * - `open`, and undefined — nothing changes. Undefined is "could not tell": a
1814
1828
  * flaky network, a revoked token, a deleted PR. Settling on it would record a
1815
1829
  * merge that never happened, and the next tick asks again for free. An
@@ -1944,15 +1958,24 @@ export async function watchMergedBase(d: Pick<Deps, "project" | "tracker" | "sto
1944
1958
 
1945
1959
  let workflows;
1946
1960
  try {
1947
- workflows = await d.tracker.workflowRunsAt(repoIdentity, run.mergeSha);
1961
+ workflows = await d.tracker.workflowRunsAt(repoIdentity, run.mergeSha, {
1962
+ event: "push",
1963
+ branch: run.baseRef,
1964
+ });
1948
1965
  } catch (err) {
1949
1966
  log(`#${run.issue} base check unavailable (${errText(err)}) — retrying next tick`);
1950
1967
  continue;
1951
1968
  }
1952
- if (workflows === undefined || workflows.length === 0) {
1969
+ if (workflows === undefined) {
1953
1970
  log(`#${run.issue} base check unavailable for ${run.mergeSha} — retrying next tick`);
1954
1971
  continue;
1955
1972
  }
1973
+ if (workflows.length === 0) {
1974
+ log(
1975
+ `#${run.issue} base check: no push-triggered run yet for ${run.mergeSha} — retrying next tick`,
1976
+ );
1977
+ continue;
1978
+ }
1956
1979
  if (workflows.some((workflow) => workflow.status !== "completed")) continue;
1957
1980
 
1958
1981
  const failed = workflows.find(
@@ -2016,6 +2039,110 @@ export async function watchMergedBase(d: Pick<Deps, "project" | "tracker" | "sto
2016
2039
  }
2017
2040
  }
2018
2041
 
2042
+ /**
2043
+ * Refresh current base-branch health at each recently merged repository's live
2044
+ * head. This is status and release-gate evidence only; the per-merge audit
2045
+ * above remains the sole path that attributes and escalates a regression.
2046
+ */
2047
+ export async function watchBaseHealth(
2048
+ d: Pick<Deps, "project" | "tracker" | "store">,
2049
+ ): Promise<void> {
2050
+ const now = Date.now();
2051
+ const previousByRepo = new Map(
2052
+ d.store.baseHealth(d.project.name).map((row) => [row.repo, row] as const),
2053
+ );
2054
+ for (const { repo, baseRef } of d.store.mergedRepoBranches(
2055
+ d.project.name,
2056
+ now - BASE_STATUS_WINDOW_MS,
2057
+ )) {
2058
+ const target = d.project.routing.repos[repo];
2059
+ if (target === undefined) {
2060
+ log(`base health skipped: routed repository ${repo} is no longer configured`);
2061
+ continue;
2062
+ }
2063
+ const identity = githubRepo(target.cloneUrl);
2064
+ if (identity === undefined) {
2065
+ log(`base health skipped: routed repository ${repo} has no GitHub identity`);
2066
+ continue;
2067
+ }
2068
+ const branch = baseRef ?? target.defaultBranch;
2069
+
2070
+ let head: string | undefined;
2071
+ try {
2072
+ head = await d.tracker.branchHead(identity, branch);
2073
+ } catch (err) {
2074
+ log(`base ${repo}/${branch} head unavailable (${errText(err)}) — keeping previous health`);
2075
+ continue;
2076
+ }
2077
+ if (head === undefined) {
2078
+ log(`base ${repo}/${branch} head unavailable — keeping previous health`);
2079
+ continue;
2080
+ }
2081
+
2082
+ const previous = previousByRepo.get(repo);
2083
+ if (
2084
+ previous?.branch === branch &&
2085
+ previous.headSha === head &&
2086
+ (previous.verdict === "green" || previous.verdict === "red")
2087
+ ) {
2088
+ continue;
2089
+ }
2090
+
2091
+ let runs;
2092
+ try {
2093
+ runs = await d.tracker.workflowRunsAt(identity, head, { event: "push", branch });
2094
+ } catch (err) {
2095
+ log(`base ${repo}/${branch} workflows unavailable (${errText(err)}) — keeping previous health`);
2096
+ continue;
2097
+ }
2098
+ if (runs === undefined) {
2099
+ log(`base ${repo}/${branch} workflows unavailable — keeping previous health`);
2100
+ continue;
2101
+ }
2102
+
2103
+ let verdict: BaseHealth["verdict"];
2104
+ let detail: string | undefined;
2105
+ if (runs.length === 0) {
2106
+ verdict = "unknown";
2107
+ detail = `no push-triggered workflow run for ${head.slice(0, 8)}`;
2108
+ } else if (runs.some((run) => run.status !== "completed")) {
2109
+ verdict = "pending";
2110
+ } else {
2111
+ const failed = runs.find(
2112
+ (run) =>
2113
+ run.conclusion !== undefined &&
2114
+ FAILING_WORKFLOW_CONCLUSIONS.has(run.conclusion),
2115
+ );
2116
+ if (failed !== undefined) {
2117
+ verdict = "red";
2118
+ detail = `${failed.name} failed at ${head.slice(0, 8)} — ${failed.url}`;
2119
+ } else if (
2120
+ runs.some(
2121
+ (run) =>
2122
+ run.conclusion === undefined ||
2123
+ !SUCCESSFUL_WORKFLOW_CONCLUSIONS.has(run.conclusion),
2124
+ )
2125
+ ) {
2126
+ verdict = "pending";
2127
+ } else {
2128
+ verdict = "green";
2129
+ }
2130
+ }
2131
+
2132
+ const health: BaseHealth = {
2133
+ repo,
2134
+ branch,
2135
+ headSha: head,
2136
+ verdict,
2137
+ runsCount: runs.length,
2138
+ checkedAt: now,
2139
+ ...(detail === undefined ? {} : { detail }),
2140
+ };
2141
+ d.store.upsertBaseHealth(d.project.name, health);
2142
+ previousByRepo.set(repo, health);
2143
+ }
2144
+ }
2145
+
2019
2146
  export async function settlePushedGreen(
2020
2147
  d: Pick<Deps, "project" | "tracker" | "store">,
2021
2148
  ): Promise<void> {
@@ -2080,7 +2207,11 @@ export async function settlePushedGreen(
2080
2207
  baseCheck: "pending",
2081
2208
  }),
2082
2209
  };
2083
- if (settlement.state === "failed") patch.lastError = settlement.reason;
2210
+ if (settlement.state === "failed") {
2211
+ patch.lastError = settlement.reason;
2212
+ patch.failureClass = "returned-for-revision";
2213
+ patch.recoveryAction = "none";
2214
+ }
2084
2215
  store.updateRun(run.id, patch);
2085
2216
  log(`#${run.issue} settled: ${settlement.reason}`);
2086
2217
  continue;
@@ -2823,8 +2954,26 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
2823
2954
  }
2824
2955
  d.project = fresh;
2825
2956
  d.caps = freshCaps;
2957
+ d.deliveryPolicyValid = true;
2826
2958
  } catch (err) {
2827
- log(`config reload failed (${errText(err)}) — continuing with the values loaded at boot`);
2959
+ log(`config reload failed (${errText(err)}) — retaining boot values but blocking autonomous delivery`);
2960
+ d.deliveryPolicyValid = false;
2961
+ }
2962
+
2963
+ // Availability-held notices are already durable. Once the freshly reloaded
2964
+ // policy opens (or newly allows their category), atomically hand a bounded
2965
+ // batch to the report outbox. Pauses do not suppress delivery.
2966
+ if (d.deliveryPolicyValid !== false) {
2967
+ try {
2968
+ const catchUp = enqueueAvailableHeldNotices(d.project, d.store, Date.now());
2969
+ if (catchUp !== undefined && !catchUp.deduped) {
2970
+ log(`availability catch-up ${catchUp.report.id} queued for ${d.project.name}`);
2971
+ }
2972
+ } catch (err) {
2973
+ // The daily digest may have claimed the same rows from another process
2974
+ // between selection and association. Either way the ledger still owns them.
2975
+ log(`availability catch-up handoff deferred (${errText(err)}) — retrying next tick`);
2976
+ }
2828
2977
  }
2829
2978
 
2830
2979
  // Before the pause check, deliberately. This one is not about dispatch: the
@@ -2844,6 +2993,11 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
2844
2993
  } catch (err) {
2845
2994
  log(`base-branch check sweep failed: ${errText(err)}`);
2846
2995
  }
2996
+ try {
2997
+ await watchBaseHealth(d);
2998
+ } catch (err) {
2999
+ log(`current base-health sweep failed: ${errText(err)}`);
3000
+ }
2847
3001
  try {
2848
3002
  await adoptSalvagedPrs(d);
2849
3003
  } catch (err) {
@@ -3367,6 +3521,10 @@ export interface StatusSnapshot {
3367
3521
  * reading like a mistake (#220).
3368
3522
  */
3369
3523
  pauseReason?: string;
3524
+ /** Mechanical operator availability at the moment this snapshot was read. */
3525
+ availability?: AvailabilityState;
3526
+ /** Next digest opportunity under the same predicate that gates submission. */
3527
+ digestSchedule?: DigestScheduleState;
3370
3528
  caps: Caps;
3371
3529
  /**
3372
3530
  * The effective per-shape release grants. On the snapshot rather than re-read
@@ -3386,6 +3544,9 @@ export interface StatusSnapshot {
3386
3544
  * unknown outcome, or written off. An empty list is the only honest way to
3387
3545
  * say "everything authored this cycle actually went out" (#123). */
3388
3546
  openReports: ReportRecord[];
3547
+ /** Ordinary outcomes and deferred escalations not yet associated with an
3548
+ * accepted digest report. */
3549
+ digestBacklog: DigestBacklog;
3389
3550
  /**
3390
3551
  * The most recent conductor-verb calls and how the daemon decided them
3391
3552
  * (#126). On `status` rather than only behind `omp-conductor ledger` because
@@ -3393,8 +3554,8 @@ export interface StatusSnapshot {
3393
3554
  * config does not let it, and an operator who has to know to go looking is an
3394
3555
  * operator who finds out from the tracker instead.
3395
3556
  */
3396
- /** Newest post-merge base verdict per routed repository within seven days. */
3397
- baseChecks: RunRecord[];
3557
+ /** Current live-head push-workflow verdict per recently merged repository. */
3558
+ baseHealth: BaseHealth[];
3398
3559
  verbLedger: VerbLedgerEntry[];
3399
3560
  /** Runs backed by a worker process — the number capacity compares against. */
3400
3561
  liveWorkers: number;
@@ -3441,6 +3602,10 @@ export function statusSnapshotFromStore(
3441
3602
  store: Store,
3442
3603
  planUsage?: PlanUsageStatus,
3443
3604
  ): StatusSnapshot {
3605
+ const now = Date.now();
3606
+ const lastDigestKey = store.lastDigestDedupeKey(p.name);
3607
+ const lastDigestDay =
3608
+ lastDigestKey === undefined ? undefined : lastDigestKey.slice("digest:".length);
3444
3609
  const since = startOfToday();
3445
3610
  const dispatch = store.latestDispatch(p.name);
3446
3611
  const labelOpsPending = store.countPendingLabelOps(p.name);
@@ -3454,12 +3619,15 @@ export function statusSnapshotFromStore(
3454
3619
  stateDir: stateDir(),
3455
3620
  paused: isPaused(),
3456
3621
  ...(reason === undefined ? {} : { pauseReason: reason }),
3622
+ availability: availabilityState(p.reporting, now),
3623
+ digestSchedule: digestScheduleState(p.reporting ?? DEFAULT_REPORT_POLICY, lastDigestDay, now),
3457
3624
  caps,
3458
3625
  releaseGrants: resolveReleaseGrants(p),
3459
3626
  activeRuns: store.activeRuns(p.name),
3460
3627
  salvagedRuns: store.salvagedRuns(p.name),
3461
3628
  turnOverrides: store.listTurnOverrides(p.name),
3462
3629
  openReports: store.openReports(p.name),
3630
+ digestBacklog: store.digestBacklog(p.name),
3463
3631
  verbLedger: store.verbLedger(p.name, { limit: STATUS_LEDGER_SCAN }),
3464
3632
  liveWorkers: store.liveRuns(p.name).length,
3465
3633
  runsToday: store.runsStartedSince(p.name, since),
@@ -3468,12 +3636,12 @@ export function statusSnapshotFromStore(
3468
3636
  ...(planUsage === undefined ? {} : { planUsage }),
3469
3637
  // Written by the tracker's hooks rather than polled, so the renderer does
3470
3638
  // not re-read GitHub to know it is being refused (#198).
3471
- ghRefusals: store.ghRefusalsSince?.(Date.now() - 5 * 60_000),
3639
+ ghRefusals: store.ghRefusalsSince?.(now - 5 * 60_000),
3472
3640
  ghCallsToday: store.ghCallsToday?.(utcDay()),
3473
3641
  ...(labelOpsPending === 0 || oldestLabelOpAt === undefined
3474
3642
  ? {}
3475
- : { labelOps: { pending: labelOpsPending, oldestAgeMs: Date.now() - oldestLabelOpAt } }),
3476
- baseChecks: store.latestBaseChecks(p.name, Date.now() - BASE_STATUS_WINDOW_MS),
3643
+ : { labelOps: { pending: labelOpsPending, oldestAgeMs: now - oldestLabelOpAt } }),
3644
+ baseHealth: store.baseHealth(p.name),
3477
3645
  };
3478
3646
  }
3479
3647
 
@@ -3556,18 +3724,22 @@ export function formatReleaseGrants(grants: ResolvedGrants): string[] {
3556
3724
  ...RELEASE_SHAPES.map((shape) => ` ${shape.padEnd(19)}${grants[shape]}`),
3557
3725
  ];
3558
3726
  }
3559
- export function formatBaseChecks(runs: readonly RunRecord[]): string[] {
3560
- return runs.flatMap((run) => {
3561
- if (run.baseCheck === undefined) return [];
3562
- const branch = run.baseRef ?? "?";
3563
- const flag = run.settlementFlags?.find((candidate) => candidate.kind === "base-branch-red");
3564
- const verdict =
3565
- run.baseCheck === "red"
3566
- ? `RED — ${flag?.detail ?? "workflow failed after merge"}`
3567
- : run.baseCheck === "red-preexisting"
3568
- ? `red before merge${flag === undefined ? "" : ` — ${flag.detail}`}`
3569
- : run.baseCheck;
3570
- return [`base ${run.repo}/${branch} ${verdict}`];
3727
+ export function formatBaseHealth(rows: readonly BaseHealth[]): string[] {
3728
+ return rows.map((row) => {
3729
+ const head = row.headSha.slice(0, 8);
3730
+ if (row.verdict === "green") {
3731
+ return `base ${row.repo}/${row.branch} green (${row.runsCount} run(s)) at ${head}`;
3732
+ }
3733
+ if (row.verdict === "red") {
3734
+ return `base ${row.repo}/${row.branch} RED — ${row.detail ?? `workflow failed at ${head}`}`;
3735
+ }
3736
+ if (row.verdict === "pending") {
3737
+ return `base ${row.repo}/${row.branch} pending (${row.runsCount} run(s)) at ${head}`;
3738
+ }
3739
+ return (
3740
+ `base ${row.repo}/${row.branch} unknown — ` +
3741
+ (row.detail ?? `no push-triggered workflow run for ${head}`)
3742
+ );
3571
3743
  });
3572
3744
  }
3573
3745
 
@@ -3618,7 +3790,7 @@ export function formatStatus(s: StatusSnapshot): string {
3618
3790
  if (flagged !== undefined) lines.push(` ${flagged}`);
3619
3791
  }
3620
3792
  }
3621
- lines.push(...formatBaseChecks(s.baseChecks));
3793
+ lines.push(...formatBaseHealth(s.baseHealth));
3622
3794
  lines.push(...formatSalvagedRuns(s.salvagedRuns));
3623
3795
  lines.push(...formatOpenReports(s.openReports));
3624
3796
  lines.push(...formatVerbLedger(s.verbLedger));
@@ -4380,7 +4552,17 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
4380
4552
  }
4381
4553
  }
4382
4554
 
4383
- const escalator = createEscalator(project, tracker, store, orchestrator);
4555
+ let runtimeDeps: Deps | undefined;
4556
+ const currentProject = (): ProjectConfig => runtimeDeps?.project ?? project;
4557
+ const deliveryPolicyValid = (): boolean => runtimeDeps?.deliveryPolicyValid === true;
4558
+ const escalator = createEscalator(
4559
+ currentProject,
4560
+ tracker,
4561
+ store,
4562
+ orchestrator,
4563
+ Date.now,
4564
+ deliveryPolicyValid,
4565
+ );
4384
4566
 
4385
4567
  // Report delivery is the daemon's, not the model's (#123). Built beside the
4386
4568
  // escalator because a report nobody can deliver pages through it, and driven
@@ -4388,10 +4570,11 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
4388
4570
  // but still owes its operator the report it was handed, and five minutes is a
4389
4571
  // long time to sit on a page.
4390
4572
  const outbox = createReportOutbox({
4391
- project,
4573
+ project: currentProject,
4392
4574
  store,
4393
4575
  escalate: (e) => escalator.escalate(e),
4394
4576
  log,
4577
+ deliveryAllowed: deliveryPolicyValid,
4395
4578
  });
4396
4579
 
4397
4580
  // Every row still `sending` when a daemon boots belonged to a process that is
@@ -4417,6 +4600,8 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
4417
4600
  caps,
4418
4601
  tracker,
4419
4602
  store,
4603
+ // Fail closed until the first tick re-reads and validates the live config.
4604
+ deliveryPolicyValid: false,
4420
4605
  // Process-wide, so a `status` served off this daemon's own HTTP surface
4421
4606
  // reuses the tick's reading instead of shelling out again.
4422
4607
  usage: sharedUsageSource(),
@@ -4431,6 +4616,7 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
4431
4616
  ...(verbPeerReader === undefined ? {} : { verbPeerReader }),
4432
4617
  verbActions,
4433
4618
  };
4619
+ runtimeDeps = d;
4434
4620
 
4435
4621
  if (o.once) {
4436
4622
  try {
package/src/diff-flags.ts CHANGED
@@ -290,9 +290,19 @@ function expandBraces(token: string): string[] {
290
290
  return alternatives.map((alt) => prefix + alt + suffix);
291
291
  }
292
292
 
293
+ /** A trailing slash-separated extension list:
294
+ * `scripts/a.sh/.py` → [`scripts/a.sh`, `scripts/a.py`]. */
295
+ function expandExtensionAlternation(token: string): string[] {
296
+ const match = /^(.+?)\.([A-Za-z][A-Za-z0-9]{1,7})((?:\/\.[A-Za-z][A-Za-z0-9]{1,7})+)$/.exec(token);
297
+ if (match === null) return [token];
298
+ const [, base, first, rest] = match;
299
+ if (base === undefined || first === undefined || rest === undefined) return [token];
300
+ return [first, ...rest.split("/.").filter(Boolean)].map((extension) => `${base}.${extension}`);
301
+ }
302
+
293
303
  /** Every path-shaped token on the report's `changed:` line. An absent line and
294
304
  * a line naming nothing are the same answer: nothing was disclosed. */
295
- export function claimedPaths(report: string): string[] {
305
+ function parseClaimedPaths(report: string, extensionAlternatives?: Set<string>): string[] {
296
306
  const line = CHANGED_LINE.exec(report)?.[1] ?? "";
297
307
  const seen = new Set<string>();
298
308
  // Split on whitespace and semicolons, and on commas *outside* a brace group:
@@ -306,13 +316,21 @@ export function claimedPaths(report: string): string[] {
306
316
  // directions — sees plain paths: a brace token dies earlier at the
307
317
  // CLAIMED_PATH filter if it is never opened up (#224).
308
318
  for (const expanded of expandBraces(normalised)) {
309
- if (!CLAIMED_PATH.test(expanded) && !DOTTED_MODULE.test(expanded)) continue;
310
- seen.add(expanded);
319
+ const alternatives = expandExtensionAlternation(expanded);
320
+ for (const path of alternatives) {
321
+ if (!CLAIMED_PATH.test(path) && !DOTTED_MODULE.test(path)) continue;
322
+ seen.add(path);
323
+ if (alternatives.length > 1) extensionAlternatives?.add(path);
324
+ }
311
325
  }
312
326
  }
313
327
  return [...seen];
314
328
  }
315
329
 
330
+ export function claimedPaths(report: string): string[] {
331
+ return parseClaimedPaths(report);
332
+ }
333
+
316
334
  /**
317
335
  * Whether one claim covers one path. Every rule here is deliberately generous,
318
336
  * because each one that fails produces an omission flag on an honest report:
@@ -505,7 +523,8 @@ export function analyseSettlement(audit: SettlementAudit): SettlementFlag[] {
505
523
 
506
524
  function reconcileChanged(audit: SettlementAudit, flags: SettlementFlag[]): void {
507
525
  const touched = audit.diff.files.filter((f) => DERIVED_FILE[basename(f.path)] !== true);
508
- const claims = claimedPaths(audit.report);
526
+ const extensionAlternatives = new Set<string>();
527
+ const claims = parseClaimedPaths(audit.report, extensionAlternatives);
509
528
  const priorClaims = claimedPaths((audit.priorReports ?? []).join("\n"));
510
529
  // Coverage pools the current report with every prior attempt's disclosures:
511
530
  // a file the final report no longer names was disclosed while the work was
@@ -553,12 +572,62 @@ function reconcileChanged(audit: SettlementAudit, flags: SettlementFlag[]): void
553
572
  if (audit.diff.files.some((f) => covers(claim, f.path) || covers(claim, f.previousPath ?? ""))) {
554
573
  continue;
555
574
  }
575
+ if (extensionAlternatives.has(claim)) {
576
+ const stem = claim.slice(0, claim.lastIndexOf("."));
577
+ const matchedAlternative = claims.some(
578
+ (other) =>
579
+ other !== claim &&
580
+ extensionAlternatives.has(other) &&
581
+ other.slice(0, other.lastIndexOf(".")) === stem &&
582
+ audit.diff.files.some((f) => covers(other, f.path) || covers(other, f.previousPath ?? "")),
583
+ );
584
+ if (matchedAlternative) continue;
585
+ }
556
586
  flags.push({
557
587
  kind: "unmatched-claim",
558
588
  file: claim,
559
589
  detail: "named by the report's `changed:` line but not touched by the PR",
560
590
  });
561
591
  }
592
+
593
+ // A parser failure can otherwise accuse the report in both directions for
594
+ // the same file. Collapse only that self-refuting pair; unrelated findings
595
+ // remain intact.
596
+ const unmatched = flags.filter((flag) => flag.kind === "unmatched-claim");
597
+ const removed = new Set<SettlementFlag>();
598
+ let exampleClaim = "";
599
+ let examplePath = "";
600
+ for (const undisclosed of flags.filter((flag) => flag.kind === "undisclosed-file")) {
601
+ const name = basename(undisclosed.file);
602
+ const extension = name.lastIndexOf(".");
603
+ const stem = extension > 0 ? name.slice(0, extension) : name;
604
+ if (stem.length < 3) continue;
605
+ for (const claim of unmatched) {
606
+ if (!claim.file.includes(stem)) continue;
607
+ removed.add(undisclosed);
608
+ removed.add(claim);
609
+ if (exampleClaim === "") {
610
+ exampleClaim = claim.file;
611
+ examplePath = undisclosed.file;
612
+ }
613
+ }
614
+ }
615
+ if (removed.size === 0) return;
616
+
617
+ const kept = flags.filter((flag) => !removed.has(flag));
618
+ const removedClaims = unmatched.filter((flag) => removed.has(flag)).length;
619
+ flags.splice(
620
+ 0,
621
+ flags.length,
622
+ ...kept,
623
+ {
624
+ kind: "report-format-unparsed",
625
+ file: "(report)",
626
+ detail:
627
+ `${removedClaims} claim(s) on the \`changed:\` line could not be parsed as paths yet name the same ` +
628
+ `file(s) the PR touched (e.g. ${exampleClaim} vs ${examplePath}) — read the diff directly`,
629
+ },
630
+ );
562
631
  }
563
632
 
564
633
  function detectWeakening(audit: SettlementAudit, flags: SettlementFlag[]): void {