@goodbones/cli 0.1.0-beta.14 → 0.1.0-beta.16

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/run.ts CHANGED
@@ -28,7 +28,10 @@ import {
28
28
  renderCampaignRows,
29
29
  renderHistory,
30
30
  renderNudge,
31
+ renderSectorView,
31
32
  reportSpecsOf,
33
+ sectorMovesOf,
34
+ sectorViewOf,
32
35
  snapshotCampaignsOf,
33
36
  valueOfParts,
34
37
  widenedExtensions,
@@ -39,6 +42,7 @@ import {
39
42
  breaches,
40
43
  CONFORMANCE_MEASURES,
41
44
  type ConformanceMeasure,
45
+ countedFiles,
42
46
  type CoverageFamily,
43
47
  coverageOf,
44
48
  cyclesIn,
@@ -51,12 +55,15 @@ import {
51
55
  evaluateSelectedBindings,
52
56
  evaluateStructure,
53
57
  evaluateSurface,
58
+ expandAliases,
54
59
  exportRulesSelecting,
55
60
  findManifestFile,
56
61
  fingerprintOf,
57
62
  formatManifestYaml,
58
63
  formatMessage,
59
64
  fractionsOf,
65
+ globsOf,
66
+ globToRegExp,
60
67
  type Graph,
61
68
  hasGraphRules,
62
69
  heightOf,
@@ -287,10 +294,18 @@ export type ReportedViolation = Violation & {
287
294
  readonly entry?: string;
288
295
  };
289
296
 
297
+ // Per family: the files it reaches of those the limits count, the floor the
298
+ // manifest's `limits.coverage` states as a fraction, and the ceiling its
299
+ // `limits.unreached` states on the files left over.
290
300
  export type CoverageReport = Readonly<
291
301
  Record<
292
302
  CoverageFamily,
293
- { readonly covered: number; readonly total: number; readonly floor?: number }
303
+ {
304
+ readonly covered: number;
305
+ readonly total: number;
306
+ readonly floor?: number;
307
+ readonly ceiling?: number;
308
+ }
294
309
  >
295
310
  >;
296
311
 
@@ -304,6 +319,9 @@ export type ConformanceReport = Readonly<
304
319
  export type CheckReport = {
305
320
  readonly version: 1;
306
321
  readonly files: number;
322
+ // Walked files under the manifest's `limits.outside`: judged by every
323
+ // rule, and left out of `coverage` and of the residue.
324
+ readonly outside: number;
307
325
  readonly roots: ReadonlyArray<string>;
308
326
  readonly ok: boolean;
309
327
  // The file the policy was read from, repo-relative, and a hash of its
@@ -355,6 +373,13 @@ type Measures = {
355
373
  readonly concentration: ReturnType<typeof slackOf>["concentration"];
356
374
  };
357
375
 
376
+ // The patterns the policy's `limits.outside` names, alias-expanded, each a
377
+ // subtree.
378
+ const outsideOf = (policy: LoadedPolicy): ReadonlyArray<RegExp> =>
379
+ globsOf(policy.config.limits?.outside ?? []).map((glob) =>
380
+ globToRegExp(expandAliases(glob, policy.config.aliases ?? {})),
381
+ );
382
+
358
383
  const measuresOf = (
359
384
  policy: LoadedPolicy,
360
385
  files: ReadonlyArray<string>,
@@ -365,7 +390,9 @@ const measuresOf = (
365
390
  // that rather than as lines nobody needs.
366
391
  const { concentration, slack } = slackOf(policy.importRules, edges, files);
367
392
  return {
368
- residue: residueOf(policy, files),
393
+ // Residue is a count of files, and counts the ones the limits count. A
394
+ // node is live or vacant by every file under it, counted or not.
395
+ residue: residueOf(policy, countedFiles(files, outsideOf(policy))),
369
396
  vacant: vacancyOf(policy.importRules, files),
370
397
  slack,
371
398
  concentration,
@@ -423,25 +450,32 @@ const reportOf = (
423
450
 
424
451
  // The floors. A policy states how much of the tree it reaches, per
425
452
  // family; falling under is a policy that quietly stopped covering files.
453
+ // Both are held over the files the limits count: what `limits.outside`
454
+ // names is judged by every rule and left out of these numbers.
426
455
  const floors = policy.config.limits?.coverage ?? {};
456
+ const unreached = policy.config.limits?.unreached ?? {};
427
457
  const files = listSourceFiles(policy.repoRoot, roots, policy.languages);
428
- const found = coverageOf(policy, files);
458
+ const counted = countedFiles(files, outsideOf(policy));
459
+ const found = coverageOf(policy, counted);
429
460
  const covered = (family: CoverageFamily): number =>
430
461
  family === "structure" ? found.structure.enumerated : found[family].covered;
431
462
  const coverage = Object.fromEntries(
432
463
  COVERAGE_FAMILIES.map((family) => {
433
464
  const floor = floors[family];
465
+ const ceiling = unreached[family];
434
466
  return [
435
467
  family,
436
468
  {
437
469
  covered: covered(family),
438
470
  total: found.files,
439
471
  ...(floor === undefined ? {} : { floor }),
472
+ ...(ceiling === undefined ? {} : { ceiling }),
440
473
  },
441
474
  ];
442
475
  }),
443
476
  ) as CoverageReport;
444
477
  const shortfalls = shortfallsOf(coverage);
478
+ const overruns = overrunsOf(coverage);
445
479
 
446
480
  // The ceilings. What no family reaches, what no file is under and what
447
481
  // nothing imports through are each a count the policy may hold itself
@@ -461,12 +495,14 @@ const reportOf = (
461
495
  const report: CheckReport = {
462
496
  version: 1,
463
497
  files: findings.files,
498
+ outside: files.length - counted.length,
464
499
  roots,
465
500
  ok:
466
501
  reportable === 0 &&
467
502
  findings.unresolved.length === 0 &&
468
503
  stale.length === 0 &&
469
504
  shortfalls.length === 0 &&
505
+ overruns.length === 0 &&
470
506
  excesses.length === 0 &&
471
507
  campaignFailuresOf(campaigns).length === 0,
472
508
  manifest: {
@@ -505,6 +541,25 @@ const shortfallsOf = (coverage: CoverageReport): ReadonlyArray<Shortfall> =>
505
541
  : [{ family, actual, floor }];
506
542
  });
507
543
 
544
+ // A family leaving more files unreached than the ceiling the policy states
545
+ // for it. A count, so deleting files a rule reached changes nothing here,
546
+ // where it lowers the fraction a floor holds.
547
+ type Overrun = {
548
+ readonly family: CoverageFamily;
549
+ readonly unreached: number;
550
+ readonly ceiling: number;
551
+ };
552
+
553
+ const overrunsOf = (coverage: CoverageReport): ReadonlyArray<Overrun> =>
554
+ COVERAGE_FAMILIES.flatMap((family) => {
555
+ const { ceiling, covered, total } = coverage[family];
556
+ const unreached = total - covered;
557
+ return ceiling === undefined ||
558
+ !breaches({ direction: "down", limit: ceiling, tolerance: 0 }, unreached)
559
+ ? []
560
+ : [{ family, unreached, ceiling }];
561
+ });
562
+
508
563
  // A conformance measure over the ceiling the policy states for it.
509
564
  type Excess = {
510
565
  readonly measure: ConformanceMeasure;
@@ -529,6 +584,7 @@ const failureOf = (
529
584
  const [campaignFailure] = campaignFailuresOf(report.campaigns);
530
585
  if (campaignFailure !== undefined) return fail(campaignFailure);
531
586
  if (shortfalls.length > 0) return fail("coverage below floor");
587
+ if (overrunsOf(report.coverage).length > 0) return fail("unreached above ceiling");
532
588
  if (excessesOf(report.conformance).length > 0) return fail("conformance above ceiling");
533
589
  if (report.ok) return null;
534
590
  return fail("architecture violations");
@@ -576,6 +632,7 @@ const renderText = (report: CheckReport): ReadonlyArray<string> => {
576
632
  const carried =
577
633
  report.violations.filter((one) => one.kind !== "campaign").length - reportable.length;
578
634
  const shortfalls = shortfallsOf(report.coverage);
635
+ const overruns = overrunsOf(report.coverage);
579
636
  const excesses = excessesOf(report.conformance);
580
637
  return [
581
638
  ...reportable.map(describe),
@@ -607,6 +664,18 @@ const renderText = (report: CheckReport): ReadonlyArray<string> => {
607
664
  "",
608
665
  " architecture coverage # which files no rule reaches",
609
666
  ]),
667
+ ...(overruns.length === 0
668
+ ? []
669
+ : [
670
+ "",
671
+ "more files are unreached than the ceiling the policy states for itself:",
672
+ ...overruns.map(
673
+ (one) =>
674
+ ` ${one.family}: ${count(one.unreached, "file")} unreached, ceiling ${String(one.ceiling)}`,
675
+ ),
676
+ "",
677
+ " architecture coverage # which files no rule reaches",
678
+ ]),
610
679
  ...(excesses.length === 0
611
680
  ? []
612
681
  : [
@@ -680,6 +749,7 @@ export const snapshotOf = (
680
749
  manifest: report_.manifest,
681
750
  roots: report_.roots,
682
751
  files: report_.files,
752
+ outside: report_.outside,
683
753
  ok: report_.ok,
684
754
  coverage: report_.coverage,
685
755
  conformance: report_.conformance,
@@ -697,11 +767,23 @@ export const snapshotOf = (
697
767
  };
698
768
  };
699
769
 
770
+ // The unreached ceiling beside a family's row, and the ratchet's nudge when
771
+ // the count has fallen under it: a ceiling is lowered by hand.
772
+ const unreachedMark = (covered: number, total: number, ceiling: number | undefined): string => {
773
+ if (ceiling === undefined) return "";
774
+ const left = total - covered;
775
+ if (left > ceiling) return ` ${String(left)} unreached > ${String(ceiling)} ✗`;
776
+ return ` ${String(left)} unreached ≤ ${String(ceiling)} ✓${left < ceiling ? `, lower it to ${String(left)}` : ""}`;
777
+ };
778
+
779
+ const outsideNote = (outside: number): string =>
780
+ outside === 0 ? "" : `, ${count(outside, "file")} outside the limits`;
781
+
700
782
  const renderSnapshot = (snapshot: Snapshot): ReadonlyArray<string> => {
701
783
  const reportable = snapshot.violations.filter((one) => !one.baselined && !one.ledgered);
702
784
  const carried = snapshot.violations.length - reportable.length;
703
785
  const row = (family: CoverageFamily): string => {
704
- const { covered, floor, total } = snapshot.coverage[family];
786
+ const { ceiling, covered, floor, total } = snapshot.coverage[family];
705
787
  const fraction = total === 0 ? 1 : covered / total;
706
788
  const mark =
707
789
  floor === undefined
@@ -709,7 +791,7 @@ const renderSnapshot = (snapshot: Snapshot): ReadonlyArray<string> => {
709
791
  : fraction >= floor
710
792
  ? ` ≥ ${percent(floor)} ✓`
711
793
  : ` < ${percent(floor)} ✗`;
712
- return ` ${family.padEnd(10)} ${String(covered).padStart(5)}/${String(total)} ${percent(fraction).padStart(4)}${mark}`;
794
+ return ` ${family.padEnd(10)} ${String(covered).padStart(5)}/${String(total)} ${percent(fraction).padStart(4)}${mark}${unreachedMark(covered, total, ceiling)}`;
713
795
  };
714
796
  const section = (title: string, lines: ReadonlyArray<string>): ReadonlyArray<string> => [
715
797
  "",
@@ -730,7 +812,7 @@ const renderSnapshot = (snapshot: Snapshot): ReadonlyArray<string> => {
730
812
  };
731
813
 
732
814
  return [
733
- `${String(snapshot.files)} files under ${snapshot.roots.join(", ")}, against ${snapshot.manifest.path}`,
815
+ `${String(snapshot.files)} files under ${snapshot.roots.join(", ")}, against ${snapshot.manifest.path}${outsideNote(snapshot.outside)}`,
734
816
  ...section("coverage", COVERAGE_FAMILIES.map(row)),
735
817
  ...section(
736
818
  `residue: ${count(snapshot.residue.files.length, "file")} no family reaches` +
@@ -833,10 +915,14 @@ export const coverage = (
833
915
  roots: ReadonlyArray<string>,
834
916
  ): Effect.Effect<void, CliFailure> =>
835
917
  Effect.gen(function* () {
836
- const files = listSourceFiles(policy.repoRoot, roots, policy.languages);
918
+ // The limits are held over the files they count, so that is what the
919
+ // rows are over: what `limits.outside` names is said, and left out.
920
+ const walked = listSourceFiles(policy.repoRoot, roots, policy.languages);
921
+ const files = countedFiles(walked, outsideOf(policy));
837
922
  const found = coverageOf(policy, files);
838
923
  const fractions = fractionsOf(found);
839
924
  const floors = policy.config.limits?.coverage ?? {};
925
+ const ceilings = policy.config.limits?.unreached ?? {};
840
926
  const row = (family: keyof typeof fractions, covered: number, note: string): string => {
841
927
  const floor = floors[family];
842
928
  const mark =
@@ -845,11 +931,11 @@ export const coverage = (
845
931
  : fractions[family] >= floor
846
932
  ? ` ≥ ${percent(floor)} ✓`
847
933
  : ` < ${percent(floor)} ✗`;
848
- return ` ${family.padEnd(10)} ${String(covered).padStart(5)}/${String(found.files)} ${percent(fractions[family]).padStart(4)} ${note}${mark}`;
934
+ return ` ${family.padEnd(10)} ${String(covered).padStart(5)}/${String(found.files)} ${percent(fractions[family]).padStart(4)} ${note}${mark}${unreachedMark(covered, found.files, ceilings[family])}`;
849
935
  };
850
936
 
851
937
  yield* report([
852
- `${String(found.files)} files under ${roots.join(", ")}`,
938
+ `${String(walked.length)} files under ${roots.join(", ")}${outsideNote(walked.length - files.length)}`,
853
939
  "",
854
940
  row("imports", found.imports.covered, "under an import allowlist"),
855
941
  row(
@@ -987,14 +1073,14 @@ export const explain = (
987
1073
  ? `${String(parts.of)} of ${String(parts.per)} here`
988
1074
  : `${String(valueOfParts(measure, parts))} here`;
989
1075
  return [
990
- ` ${objective.name} — ${firstSentence(objective.why ?? objective.message)} (measure, ${objective.direction}; ${here})`,
1076
+ ` ${objective.name} — ${firstSentence(objective.intent ?? objective.message)} (measure, ${objective.direction}; ${here})`,
991
1077
  ];
992
1078
  }
993
1079
  const table = explainObjective(objective, input);
994
1080
  if (table.length === 0) return [];
995
1081
  const fired = table.length > 0 && table.every((line) => line.answer);
996
1082
  return [
997
- ` ${objective.name} — ${firstSentence(objective.why ?? objective.message)} (${objective.holdout}; ${fired ? "fires here" : "no hit"})`,
1083
+ ` ${objective.name} — ${firstSentence(objective.intent ?? objective.message)} (${objective.holdout}; ${fired ? "fires here" : "no hit"})`,
998
1084
  ...table.map(
999
1085
  (line) =>
1000
1086
  ` ${line.answer ? "✓" : "✗"} ${line.term}${line.count === undefined ? "" : ` (${String(line.count)})`}`,
@@ -1156,6 +1242,7 @@ limits:
1156
1242
  # perimeter: file
1157
1243
  # objectives:
1158
1244
  # is-ts:
1245
+ # intent: A JavaScript file is one the strict compiler never checks.
1159
1246
  # holdout: file
1160
1247
  # match: { path: { file: "\\\\.(js|jsx)$" } }
1161
1248
  # probes: { fires: [{ path: src/legacy/util.js }], ignores: [{ path: src/util.ts }] }
@@ -1380,7 +1467,8 @@ const campaignFor = (
1380
1467
  : Result.succeed(found);
1381
1468
  }
1382
1469
  const [only] = campaignsOf(policy).campaignRules;
1383
- if (campaignsOf(policy).campaignRules.length === 1 && only !== undefined) return Result.succeed(only);
1470
+ if (campaignsOf(policy).campaignRules.length === 1 && only !== undefined)
1471
+ return Result.succeed(only);
1384
1472
  return Result.fail(
1385
1473
  `this policy declares ${String(campaignsOf(policy).campaignRules.length)} campaigns; say which with --campaign <id>.`,
1386
1474
  );
@@ -1424,7 +1512,9 @@ export const objectives = (
1424
1512
  const targets =
1425
1513
  parsed.target === null
1426
1514
  ? campaignsOf(policy).campaignRules
1427
- : campaignsOf(policy).campaignRules.filter((rule) => rule.id === parsed.target?.campaign);
1515
+ : campaignsOf(policy).campaignRules.filter(
1516
+ (rule) => rule.id === parsed.target?.campaign,
1517
+ );
1428
1518
  const by = authorOf(flagOf(argv, "--by")) ?? "unknown";
1429
1519
  const all = evaluations();
1430
1520
  const lines: Array<string> = [];
@@ -1439,6 +1529,9 @@ export const objectives = (
1439
1529
  try: () => clear(policy, evaluation, only, by),
1440
1530
  catch: (cause) => fail(String(cause)),
1441
1531
  });
1532
+ // Where each sector went, read off the ledgers as they stood: a
1533
+ // clear of one objective places no sector, so it says nothing.
1534
+ const moves = only === null ? sectorMovesOf(policy, evaluation) : [];
1442
1535
  for (const outcome of outcomes) {
1443
1536
  const parts = [
1444
1537
  ...(outcome.entered.length > 0
@@ -1471,6 +1564,16 @@ export const objectives = (
1471
1564
  .join(", ")})`,
1472
1565
  ]
1473
1566
  : []),
1567
+ ...(outcome.measure.grown.length > 0
1568
+ ? [
1569
+ `${count(outcome.measure.grown.length, "sector")} grown (${outcome.measure.grown
1570
+ .map(
1571
+ (one) =>
1572
+ `${one.sector} ${String(one.from)} → ${String(one.to)}, ${one.phase === null ? "measured, not held" : `as ${one.phase} expects`}`,
1573
+ )
1574
+ .join(", ")})`,
1575
+ ]
1576
+ : []),
1474
1577
  ...(outcome.closed > 0 ? [`${count(outcome.closed, "sector")} closed`] : []),
1475
1578
  ...(outcome.rebaselined.length > 0
1476
1579
  ? [
@@ -1487,6 +1590,14 @@ export const objectives = (
1487
1590
  `${outcome.campaign}/${outcome.objective}: ${parts.length === 0 ? "nothing to clear" : parts.join(", ")}; ${count(outcome.left, "holdout")} left.`,
1488
1591
  );
1489
1592
  }
1593
+ for (const move of moves) {
1594
+ lines.push(
1595
+ `${move.campaign}: ${move.sector} ${move.back ? "went back" : "moved"} ${move.from ?? "done"} → ${move.to ?? "done"}` +
1596
+ (move.passed.length === 0
1597
+ ? "."
1598
+ : `, passing ${move.passed.join(", ")} in the same clear: nothing there was ever counted for it.`),
1599
+ );
1600
+ }
1490
1601
  }
1491
1602
  return yield* report(lines);
1492
1603
  }
@@ -1498,7 +1609,9 @@ export const objectives = (
1498
1609
  ),
1499
1610
  );
1500
1611
  }
1501
- const rule = campaignsOf(policy).campaignRules.find((one) => one.id === parsed.target?.campaign);
1612
+ const rule = campaignsOf(policy).campaignRules.find(
1613
+ (one) => one.id === parsed.target?.campaign,
1614
+ );
1502
1615
  if (rule === undefined)
1503
1616
  return yield* Effect.fail(fail(`no campaign is named "${parsed.target.campaign}"`));
1504
1617
  const objective = objectiveFor(rule, parsed.target.objective);
@@ -1540,6 +1653,21 @@ export const objectives = (
1540
1653
  },
1541
1654
  );
1542
1655
  if (Result.isFailure(outcome)) return yield* Effect.fail(fail(outcome.failure));
1656
+ // A concession that sends a sector back a phase says so, and names
1657
+ // the attestations it revoked on the way.
1658
+ const sentBack = outcome.success.sentBack.flatMap((one) => [
1659
+ "",
1660
+ `this concession moves ${one.sector} back ${one.from ?? "done"} → ${one.to ?? "done"}.`,
1661
+ ...(one.revoked.length === 0
1662
+ ? []
1663
+ : [
1664
+ `It revokes ${one.sector}'s attestation of ${one.revoked.join(", ")}: what was attested no longer holds. Once it does again, attest it anew:`,
1665
+ ...one.revoked.map(
1666
+ (phase) =>
1667
+ ` architecture campaigns attest ${one.sector} ${phase} --reason "…" --campaign ${rule.id}`,
1668
+ ),
1669
+ ]),
1670
+ ]);
1543
1671
  const scalar =
1544
1672
  rule.objectives.find((one) => one.id === objective.success)?.measure !== null;
1545
1673
  if (scalar) {
@@ -1557,6 +1685,7 @@ export const objectives = (
1557
1685
  "",
1558
1686
  `${count(outcome.success.left.length, "sector")} left past the record; check still fails on them.`,
1559
1687
  ]),
1688
+ ...sentBack,
1560
1689
  ]);
1561
1690
  }
1562
1691
  if (outcome.success.conceded.length === 0) {
@@ -1573,6 +1702,7 @@ export const objectives = (
1573
1702
  "",
1574
1703
  `${count(outcome.success.left.length, "hit")} left unrecorded; check still fails on them.`,
1575
1704
  ]),
1705
+ ...sentBack,
1576
1706
  ]);
1577
1707
  }
1578
1708
  default:
@@ -1602,16 +1732,63 @@ export const campaigns = (
1602
1732
  const json = argv.includes("--json");
1603
1733
  const retired = parsed.subcommand === undefined ? undefined : RETIRED[parsed.subcommand];
1604
1734
  if (retired !== undefined) return yield* Effect.fail(fail(retired));
1735
+ // A positional that is no path is refused, not walked: most often it is
1736
+ // a sector's name, and `--sector` is what asks about one.
1737
+ for (const root of parsed.roots) {
1738
+ if (existsSync(path.resolve(policy.repoRoot, root))) continue;
1739
+ const named = collectFindings(policy, defaultRoots).campaigns.some((one) =>
1740
+ one.sectors.has(root),
1741
+ );
1742
+ return yield* Effect.fail(
1743
+ fail(
1744
+ `campaigns: ${root} is not a path in the repository, so there is nothing to walk there.` +
1745
+ (named ? ` It names a sector: \`campaigns status --sector ${root}\`.` : ""),
1746
+ ),
1747
+ );
1748
+ }
1749
+ const sector = flagOf(argv, "--sector");
1750
+ if (parsed.subcommand === "status" && sector !== undefined) {
1751
+ const only = argv.includes("--campaign") ? campaignFor(policy, argv) : null;
1752
+ if (only !== null && Result.isFailure(only)) return yield* Effect.fail(fail(only.failure));
1753
+ const views = collectFindings(policy, roots)
1754
+ .campaigns.filter((one) => only === null || one.rule.id === only.success.id)
1755
+ .flatMap((one) => {
1756
+ const view = sectorViewOf(policy, one, sector);
1757
+ return view === null ? [] : [view];
1758
+ });
1759
+ if (views.length === 0) {
1760
+ return yield* Effect.fail(
1761
+ fail(`campaigns: no campaign under ${roots.join(", ")} has a sector named ${sector}.`),
1762
+ );
1763
+ }
1764
+ return yield* report(
1765
+ json
1766
+ ? [JSON.stringify({ version: 1, sectors: views }, null, 2)]
1767
+ : views.flatMap((view, i) => [...(i === 0 ? [] : [""]), ...renderSectorView(view)]),
1768
+ );
1769
+ }
1605
1770
 
1606
- switch (parsed.subcommand) {
1771
+ // `status` without `--changed` is the overview, in text or JSON: the
1772
+ // nudge alone is scoped to a diff.
1773
+ const subcommand =
1774
+ parsed.subcommand === "status" && !argv.includes("--changed") ? undefined : parsed.subcommand;
1775
+ switch (subcommand) {
1607
1776
  case undefined: {
1608
1777
  const snapshot = snapshotOf(policy, roots, manifestPathOf(policy.repoRoot, configFilename));
1778
+ if (json) {
1779
+ // Each campaign as the conformance snapshot carries it: its
1780
+ // burn-down, its ladder, and a row per sector.
1781
+ return yield* report([
1782
+ JSON.stringify({ version: 1, campaigns: snapshot.campaigns }, null, 2),
1783
+ ]);
1784
+ }
1609
1785
  return yield* report([
1610
1786
  `${count(snapshot.campaigns.length, "campaign")} under ${roots.join(", ")}`,
1611
1787
  "",
1612
1788
  ...renderCampaignRows(snapshot.campaigns),
1613
1789
  "",
1614
1790
  " architecture campaigns status --changed [--base <ref>] [--json] # what a diff touches, and what to do",
1791
+ " architecture campaigns status --sector <sector> [--json] # where one sector stands, and what holds it",
1615
1792
  " architecture objectives clear [<campaign>[/<objective>]] # reconcile the ledgers with the code",
1616
1793
  ' architecture objectives concede <campaign>[/<objective>] --reason "<why>" # record why a count may rise',
1617
1794
  ' architecture campaigns attest <sector> <phase> --reason "<why>" [--evidence <url>]',
@@ -1620,11 +1797,6 @@ export const campaigns = (
1620
1797
  ]);
1621
1798
  }
1622
1799
  case "status": {
1623
- if (!argv.includes("--changed")) {
1624
- return yield* Effect.fail(
1625
- fail("campaigns status takes --changed: the nudge is scoped to a diff."),
1626
- );
1627
- }
1628
1800
  const base = flagOf(argv, "--base") ?? null;
1629
1801
  const diff = yield* Effect.try({
1630
1802
  try: () => readDiff(policy.repoRoot, base),
@@ -1642,6 +1814,19 @@ export const campaigns = (
1642
1814
  catch: (cause) =>
1643
1815
  fail(`could not evaluate the base tree at ${base}: ${String(cause)}`),
1644
1816
  });
1817
+ // The ledger mode's "before" for work done ahead of the plan is the
1818
+ // HEAD tree, evaluated once per commit and cached: no ledger counts
1819
+ // the holdouts of a phase a sector has not entered.
1820
+ const headTree =
1821
+ base !== null || !current.some((one) => one.rule.phases.length > 0)
1822
+ ? null
1823
+ : yield* Effect.tryPromise({
1824
+ try: () =>
1825
+ baseSideAt(policy, "HEAD", roots, (repoRoot) =>
1826
+ loadPolicyFromFile(repoRoot, configFilename),
1827
+ ),
1828
+ catch: (cause) => fail(`could not evaluate the tree at HEAD: ${String(cause)}`),
1829
+ });
1645
1830
  const hotfix = flagOf(argv, "--hotfix") ?? null;
1646
1831
  const nudge = nudgeOf(
1647
1832
  policy,
@@ -1650,10 +1835,23 @@ export const campaigns = (
1650
1835
  baseSide,
1651
1836
  hotfix,
1652
1837
  hotfix === null ? null : authorOf(flagOf(argv, "--by")),
1838
+ headTree,
1653
1839
  );
1654
1840
  yield* report(json ? [JSON.stringify(nudge, null, 2)] : renderNudge(nudge, policy.now));
1655
- if (!nudge.ok)
1656
- return yield* Effect.fail(fail("the diff sends a sector back under its onTouch"));
1841
+ if (!nudge.ok) {
1842
+ const ahead = nudge.sectors.some((one) => one.verdict === "ahead");
1843
+ const back = nudge.sectors.some(
1844
+ (one) => one.verdict !== "ok" && one.verdict !== "ahead" && one.verdict !== "hotfix",
1845
+ );
1846
+ return yield* Effect.fail(
1847
+ fail(
1848
+ [
1849
+ ...(back ? ["the diff sends a sector back under its onTouch"] : []),
1850
+ ...(ahead ? ["the diff works ahead of a sector's phase under its onAhead"] : []),
1851
+ ].join("; "),
1852
+ ),
1853
+ );
1854
+ }
1657
1855
  return;
1658
1856
  }
1659
1857
  case "attest": {
@@ -1809,7 +2007,9 @@ export const run = (
1809
2007
  yield* Effect.tryPromise({
1810
2008
  try: () =>
1811
2009
  Promise.all(
1812
- reportSpecsOf(campaignsOf(policy).campaignRules).map((spec) => campaignsOf(policy).reports.read?.(spec)),
2010
+ reportSpecsOf(campaignsOf(policy).campaignRules).map((spec) =>
2011
+ campaignsOf(policy).reports.read?.(spec),
2012
+ ),
1813
2013
  ),
1814
2014
  catch: (cause) => fail(String(cause)),
1815
2015
  });