@goodbones/cli 0.1.0-beta.10 → 0.1.0-beta.11

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
@@ -1,15 +1,39 @@
1
- import { execFileSync } from "node:child_process";
2
1
  import { createHash } from "node:crypto";
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
3
  import * as path from "node:path";
5
4
 
6
5
  import {
7
- allowed,
6
+ attest,
7
+ authorOf,
8
+ baseSideAt,
9
+ type CampaignEvaluation,
10
+ campaignFailuresOf,
11
+ type CampaignReport,
12
+ campaignReportsOf,
13
+ campaignsOf,
14
+ campaignsSelecting,
15
+ clear,
16
+ concede,
17
+ evaluateCampaigns,
18
+ explainCampaignLines,
19
+ explainObjective,
20
+ historyOf,
21
+ hitsInWindow,
22
+ ledgeredFilter,
23
+ note,
24
+ nudgeOf,
25
+ readDiff,
26
+ renderCampaignReports,
27
+ renderCampaignRows,
28
+ renderHistory,
29
+ renderNudge,
30
+ reportSpecsOf,
31
+ snapshotCampaignsOf,
32
+ widenedExtensions,
33
+ } from "@goodbones/campaigns";
34
+ import {
8
35
  type Baseline,
9
36
  baselineOf,
10
- type CampaignHit,
11
- campaignsSelecting,
12
- type CompiledCampaign,
13
37
  CONFORMANCE_MEASURES,
14
38
  type ConformanceMeasure,
15
39
  type CoverageFamily,
@@ -18,15 +42,12 @@ import {
18
42
  decodeBaseline,
19
43
  decodeManifest,
20
44
  EMPTY_BASELINE,
21
- entryOf,
22
- evaluateCampaigns,
23
45
  evaluateGraph,
24
46
  evaluateMemberSite,
25
47
  evaluateResolvedEdge,
26
48
  evaluateSelectedBindings,
27
49
  evaluateStructure,
28
50
  evaluateSurface,
29
- explainCampaign,
30
51
  exportRulesSelecting,
31
52
  findManifestFile,
32
53
  fingerprintOf,
@@ -36,32 +57,20 @@ import {
36
57
  type Graph,
37
58
  hasGraphRules,
38
59
  heightOf,
39
- isComplete,
40
- isStalled,
41
- leafTermsOf,
42
- type Ledger,
43
- ledgerArithmeticHolds,
44
- ledgerOf,
45
60
  listSourceFiles,
46
61
  makeBaselineFilter,
47
62
  MANIFEST_FILENAMES,
48
63
  MANIFEST_SCHEMA_ID,
49
64
  memberRulesSelecting,
50
65
  type ObservedEdge,
51
- progressOf,
52
- pruned,
53
66
  readManifestFile,
54
- reconcile,
55
- reportSpecsOf,
56
67
  requiredSiblingsOf,
57
68
  residueOf,
58
69
  rulesSelecting,
59
70
  serializeBaseline,
60
- serializeLedger,
61
71
  slackOf,
62
72
  type Snapshot,
63
73
  SNAPSHOT_VERSION,
64
- type SnapshotCampaign,
65
74
  type SourceFacts,
66
75
  staleEntriesOf,
67
76
  surfaceRulesSelecting,
@@ -102,10 +111,11 @@ export type UnresolvedEdge = {
102
111
 
103
112
  export type Findings = {
104
113
  readonly violations: ReadonlyArray<Violation>;
105
- // Every campaign hit, ledgered or not. Kept apart from the violations: a
106
- // hit is debt a campaign is paying down, judged against its ledger rather
107
- // than the baseline.
108
- readonly campaigns: ReadonlyArray<CampaignHit>;
114
+ // Every campaign, evaluated over the files it sees: its sectors, every
115
+ // hit placed in one, each sector's phase. Kept apart from the violations:
116
+ // a hit is debt a campaign is paying down, judged against its ledger
117
+ // rather than the baseline.
118
+ readonly campaigns: ReadonlyArray<CampaignEvaluation>;
109
119
  readonly unresolved: ReadonlyArray<UnresolvedEdge>;
110
120
  readonly files: number;
111
121
  // Every edge resolved from a file under an import rule — what the slack
@@ -127,9 +137,17 @@ export const collectFindings = (
127
137
  roots: ReadonlyArray<string>,
128
138
  options: CollectOptions = {},
129
139
  ): Findings => {
130
- const files = listSourceFiles(policy.repoRoot, roots, policy.languages);
140
+ // A campaign may widen the walk past the packs' extensions; a file only a
141
+ // campaign asked for is seen by the campaigns and by no other family.
142
+ const walked = listSourceFiles(
143
+ policy.repoRoot,
144
+ roots,
145
+ policy.languages,
146
+ widenedExtensions(policy),
147
+ );
148
+ const known = new Set(policy.languages.flatMap((one) => one.extensions));
149
+ const files = walked.filter((file) => known.has(path.extname(file)));
131
150
  const violations: Array<Violation> = [];
132
- const campaigns: Array<CampaignHit> = [];
133
151
  const unresolved: Array<UnresolvedEdge> = [];
134
152
  const edges: Array<ObservedEdge> = [];
135
153
 
@@ -162,36 +180,14 @@ export const collectFindings = (
162
180
  for (const violation of evaluateGraph(policy.graph, graph)) violations.push(violation);
163
181
  }
164
182
 
183
+ // The campaigns, over every walked file, through the same caches.
184
+ const campaigns = evaluateCampaigns(policy, roots, walked, { textOf, factsOf });
185
+
165
186
  for (const file of files) {
166
187
  for (const violation of evaluateStructure(policy.structure, policy.fileSystem, file)) {
167
188
  violations.push(violation);
168
189
  }
169
190
 
170
- // A campaign selects by its scope. The file is parsed by the scope's
171
- // matcher once, and only when a term of some selected campaign reads the
172
- // syntax tree.
173
- const selectedCampaigns = campaignsSelecting(policy.campaignRules, file);
174
- if (selectedCampaigns.length > 0) {
175
- const text = textOf(file);
176
- const needsSyntax = selectedCampaigns.some((rule) =>
177
- leafTermsOf(rule.detect).some(
178
- (leaf) => leaf === "syntax" || leaf === "report" || leaf === "fn",
179
- ),
180
- );
181
- for (const hit of evaluateCampaigns(selectedCampaigns, {
182
- file,
183
- text,
184
- facts: factsOf(file),
185
- resolver: policy.resolver,
186
- fileSystem: policy.fileSystem,
187
- syntax: needsSyntax ? policy.syntax.parse(file, text) : null,
188
- functions: policy.functions,
189
- reports: policy.reports,
190
- })) {
191
- campaigns.push(hit);
192
- }
193
- }
194
-
195
191
  const selectedImports = rulesSelecting(policy.importRules, file);
196
192
  const selectedExports = exportRulesSelecting(policy.exportRules, file);
197
193
  const selectedMembers = memberRulesSelecting(policy.memberRules, file);
@@ -278,29 +274,14 @@ const describe = (violation: Violation): string =>
278
274
  export type ReportedViolation = Violation & {
279
275
  readonly fingerprint: string;
280
276
  readonly baselined: boolean;
281
- // For a campaign hit: carried by the campaign's ledger, so `check` does
277
+ // For a campaign hit: carried by the objective's ledger, so `check` does
282
278
  // not fail on it. The campaign analogue of `baselined`.
283
279
  readonly ledgered: boolean;
284
- };
285
-
286
- // One campaign, as `check` sees it: how many hits, which are new, which
287
- // ledger entries no longer fire, and whether the ledger adds up.
288
- export type CampaignReport = {
289
- readonly id: string;
290
- readonly count: number;
291
- // Hits the ledger does not carry — unrecorded growth, as entries.
292
- readonly new: ReadonlyArray<string>;
293
- // Ledger entries no hit produces — fixed, and waiting to be pruned.
294
- readonly stale: ReadonlyArray<string>;
295
- // Entries whose hash moved under a still-present anchor.
296
- readonly drifted: number;
297
- // No ledger file: `campaigns init` has not been run.
298
- readonly missingLedger: boolean;
299
- // `entries.length === initial + Σ delta − fixed`.
300
- readonly arithmetic: boolean;
301
- readonly complete: boolean;
302
- readonly stalled: boolean;
303
- readonly onComplete: "keep" | "remove";
280
+ // For a campaign hit: which objective, in which sector, and the ledger
281
+ // entry it is keyed by there.
282
+ readonly objective?: string;
283
+ readonly sector?: string;
284
+ readonly entry?: string;
304
285
  };
305
286
 
306
287
  export type CoverageReport = Readonly<
@@ -339,74 +320,6 @@ export type CheckReport = {
339
320
  readonly campaigns: ReadonlyArray<CampaignReport>;
340
321
  };
341
322
 
342
- // Each campaign's hits against its ledger. A campaign with no ledger has
343
- // every hit new; one with no hits and no ledger has nothing to say.
344
- const campaignReportsOf = (
345
- policy: LoadedPolicy,
346
- hits: ReadonlyArray<CampaignHit>,
347
- ): ReadonlyArray<CampaignReport> =>
348
- policy.campaignRules.map((rule) => {
349
- const own = hits.filter((hit) => hit.campaign === rule.id).map((hit) => hit.violation);
350
- const ledger = policy.ledgers.get(rule.id);
351
- if (ledger === undefined) {
352
- return {
353
- id: rule.id,
354
- count: own.length,
355
- new: [...new Set(own.map(entryOf))].sort(),
356
- stale: [],
357
- drifted: 0,
358
- missingLedger: true,
359
- arithmetic: true,
360
- complete: own.length === 0,
361
- stalled: false,
362
- onComplete: rule.onComplete,
363
- };
364
- }
365
- const state = reconcile(ledger, own, rule.unit);
366
- return {
367
- id: rule.id,
368
- count: own.length,
369
- new: [...new Set(state.unrecorded.map(entryOf))].sort(),
370
- stale: state.stale,
371
- drifted: state.drifted.length,
372
- missingLedger: false,
373
- arithmetic: ledgerArithmeticHolds(ledger),
374
- complete: isComplete(ledger) && own.length === 0,
375
- stalled: isStalled(rule, ledger, policy.now),
376
- onComplete: rule.onComplete,
377
- };
378
- });
379
-
380
- // Whether a campaign hit is carried by its ledger — exactly, or by anchor.
381
- const ledgeredFilter = (
382
- policy: LoadedPolicy,
383
- hits: ReadonlyArray<CampaignHit>,
384
- ): ((hit: CampaignHit) => boolean) => {
385
- const carried = new Set<Violation>();
386
- for (const rule of policy.campaignRules) {
387
- const ledger = policy.ledgers.get(rule.id);
388
- if (ledger === undefined) continue;
389
- const own = hits.filter((hit) => hit.campaign === rule.id).map((hit) => hit.violation);
390
- for (const one of reconcile(ledger, own, rule.unit).ledgered) carried.add(one);
391
- }
392
- return (hit) => carried.has(hit.violation);
393
- };
394
-
395
- // Why a campaign report is not ok, in the order `check` explains it.
396
- const campaignFailuresOf = (campaigns: ReadonlyArray<CampaignReport>): ReadonlyArray<string> => [
397
- ...campaigns.filter((one) => one.stale.length > 0).map(() => "stale ledger entries"),
398
- ...campaigns.filter((one) => !one.arithmetic).map(() => "ledger arithmetic does not hold"),
399
- ...campaigns
400
- .filter((one) => one.missingLedger && one.count > 0)
401
- .map((one) => `campaign ${one.id} has no ledger`),
402
- ...campaigns
403
- .filter((one) => !one.missingLedger && one.new.length > 0)
404
- .map(() => "unrecorded campaign growth"),
405
- ...campaigns
406
- .filter((one) => one.complete && !one.missingLedger && one.onComplete === "remove")
407
- .map((one) => `campaign ${one.id} is complete and declared onComplete: remove`),
408
- ];
409
-
410
323
  const COVERAGE_FAMILIES: ReadonlyArray<CoverageFamily> = [
411
324
  "imports",
412
325
  "structure",
@@ -482,19 +395,26 @@ const reportOf = (
482
395
  const stale = staleEntriesOf(baseline, findings.violations);
483
396
  const { isBaselined } = makeBaselineFilter(baseline);
484
397
  const isLedgered = ledgeredFilter(policy, findings.campaigns);
485
- const violations = [
398
+ const violations: Array<ReportedViolation> = [
486
399
  ...findings.violations.map((violation) => ({
487
400
  ...violation,
488
401
  fingerprint: fingerprintOf(violation),
489
402
  baselined: isBaselined(violation),
490
403
  ledgered: false,
491
404
  })),
492
- ...findings.campaigns.map((hit) => ({
493
- ...hit.violation,
494
- fingerprint: fingerprintOf(hit.violation),
495
- baselined: false,
496
- ledgered: isLedgered(hit),
497
- })),
405
+ // The hits that count: those whose objective is in window for the
406
+ // sector they fall in.
407
+ ...findings.campaigns.flatMap((evaluation) =>
408
+ hitsInWindow(evaluation).map((hit) => ({
409
+ ...hit.violation,
410
+ fingerprint: fingerprintOf(hit.violation),
411
+ baselined: false,
412
+ ledgered: isLedgered(hit),
413
+ objective: hit.objective,
414
+ sector: hit.sector,
415
+ entry: hit.entry,
416
+ })),
417
+ ),
498
418
  ];
499
419
  const campaigns = campaignReportsOf(policy, findings.campaigns);
500
420
 
@@ -622,68 +542,26 @@ const describeExcess = (one: Excess): string => {
622
542
  return ` ${one.measure}: ${count(one.count, singular, plural)}, ceiling ${String(one.ceiling)}`;
623
543
  };
624
544
 
625
- // A campaign hit, with the campaign's `how` as its instruction.
626
- const describeHit = (violation: ReportedViolation): string =>
627
- ` ${violation.file}${violation.subject === null ? "" : ` (${violation.subject})`}\n ${formatMessage(violation)}`;
628
-
629
- const renderCampaigns = (report: CheckReport): ReadonlyArray<string> => {
630
- const hits = report.violations.filter((one) => one.kind === "campaign");
631
- return report.campaigns.flatMap((campaign): ReadonlyArray<string> => {
632
- const rule = `campaign/${campaign.id}`;
633
- const fresh = new Set(campaign.new);
634
- const own = hits.filter((one) => one.ruleName === rule && fresh.has(entryOf(one)));
635
- if (campaign.missingLedger && campaign.count > 0) {
636
- return [
637
- "",
638
- `campaign ${campaign.id}: ${count(campaign.count, "hit")} and no ledger. Record them before they count as growth:`,
639
- "",
640
- ` architecture campaigns init ${campaign.id}`,
641
- ];
642
- }
643
- const complete = campaign.complete && !campaign.missingLedger;
644
- return [
645
- ...(campaign.new.length === 0
646
- ? []
647
- : [
648
- "",
649
- `campaign ${campaign.id}: ${count(campaign.new.length, "new hit")} the ledger does not carry. Fix them, or record why the count may rise:`,
650
- ...own.map(describeHit),
651
- "",
652
- ` architecture campaigns allow ${campaign.id} --reason "<why>"`,
653
- ]),
654
- ...(campaign.stale.length === 0
655
- ? []
656
- : [
657
- "",
658
- `campaign ${campaign.id}: ${count(campaign.stale.length, "ledger entry", "ledger entries")} no longer fire. The code was fixed; prune them:`,
659
- ...campaign.stale.map((entry) => ` ${entry}`),
660
- "",
661
- ` architecture campaigns prune ${campaign.id}`,
662
- ]),
663
- ...(campaign.arithmetic
664
- ? []
665
- : [
666
- "",
667
- `campaign ${campaign.id}: the ledger does not add up (entries ≠ initial + allowed − fixed). An entry was added by hand; remove it, or record it with \`campaigns allow\`.`,
668
- ]),
669
- ...(complete && campaign.onComplete === "remove"
670
- ? [
671
- "",
672
- `campaign ${campaign.id} is complete and declares onComplete: remove. Delete it from the manifest, and its ledger.`,
673
- ]
674
- : []),
675
- ...(campaign.stalled
545
+ const renderCampaigns = (report: CheckReport): ReadonlyArray<string> =>
546
+ renderCampaignReports(
547
+ report.campaigns,
548
+ report.violations.flatMap((one) =>
549
+ one.kind === "campaign" &&
550
+ one.objective !== undefined &&
551
+ one.sector !== undefined &&
552
+ one.entry !== undefined
676
553
  ? [
677
- "",
678
- `notice: campaign ${campaign.id} has stalled — no entry has left its ledger within its staleAfter.`,
554
+ {
555
+ violation: one,
556
+ objective: one.objective,
557
+ sector: one.sector,
558
+ entry: one.entry,
559
+ ledgered: one.ledgered,
560
+ },
679
561
  ]
680
- : []),
681
- ...(complete && campaign.onComplete === "keep"
682
- ? ["", `notice: campaign ${campaign.id} is complete, and stays as a guard.`]
683
- : []),
684
- ];
685
- });
686
- };
562
+ : [],
563
+ ),
564
+ );
687
565
 
688
566
  const renderText = (report: CheckReport): ReadonlyArray<string> => {
689
567
  const reportable = report.violations.filter((one) => one.kind !== "campaign" && !one.baselined);
@@ -787,40 +665,7 @@ export const snapshotOf = (
787
665
  return byHeight !== 0 ? byHeight : left.fingerprint.localeCompare(right.fingerprint);
788
666
  });
789
667
 
790
- const campaigns: ReadonlyArray<SnapshotCampaign> = policy.campaignRules.map((rule) => {
791
- const ledger = policy.ledgers.get(rule.id);
792
- const own = findings.campaigns.filter((hit) => hit.campaign === rule.id).length;
793
- const base: SnapshotCampaign = {
794
- id: rule.id,
795
- ...(rule.title === null ? {} : { title: rule.title }),
796
- ...(rule.owner === null ? {} : { owner: rule.owner }),
797
- initial: own,
798
- allowed: 0,
799
- count: own,
800
- fixed: 0,
801
- progress: 0,
802
- lastProgress: new Date(policy.now).toISOString(),
803
- regressions: 0,
804
- stalled: false,
805
- complete: own === 0,
806
- onComplete: rule.onComplete,
807
- ledgered: false,
808
- };
809
- if (ledger === undefined) return base;
810
- return {
811
- ...base,
812
- initial: ledger.initial,
813
- allowed: ledger.regressions.reduce((sum, one) => sum + one.delta, 0),
814
- count: ledger.entries.length,
815
- fixed: ledger.fixed,
816
- progress: progressOf(ledger),
817
- lastProgress: ledger.lastProgress,
818
- regressions: ledger.regressions.length,
819
- stalled: isStalled(rule, ledger, policy.now),
820
- complete: isComplete(ledger),
821
- ledgered: true,
822
- };
823
- });
668
+ const campaigns = snapshotCampaignsOf(policy, findings.campaigns);
824
669
 
825
670
  return {
826
671
  version: SNAPSHOT_VERSION,
@@ -844,26 +689,6 @@ export const snapshotOf = (
844
689
  };
845
690
  };
846
691
 
847
- // The campaigns, stalled and complete first, then by progress.
848
- const renderCampaignRows = (campaigns: ReadonlyArray<SnapshotCampaign>): ReadonlyArray<string> => {
849
- const width = Math.max(0, ...campaigns.map((one) => one.id.length));
850
- const state = (one: SnapshotCampaign): string =>
851
- !one.ledgered ? "no ledger" : one.complete ? "complete" : one.stalled ? "stalled" : "";
852
- const ordered = [...campaigns].sort((left, right) => {
853
- const rank = (one: SnapshotCampaign): number =>
854
- one.stalled ? 0 : one.complete && one.ledgered ? 1 : 2;
855
- const byRank = rank(left) - rank(right);
856
- return byRank !== 0 ? byRank : left.progress - right.progress;
857
- });
858
- return ordered.map(
859
- (one) =>
860
- ` ${one.id.padEnd(width)} ${percent(one.progress).padStart(4)} ${String(one.count).padStart(5)} left` +
861
- ` ${String(one.fixed)} fixed ${String(one.allowed)} allowed` +
862
- (one.owner === undefined ? "" : ` ${one.owner}`) +
863
- (state(one) === "" ? "" : ` ${state(one)}`),
864
- );
865
- };
866
-
867
692
  const renderSnapshot = (snapshot: Snapshot): ReadonlyArray<string> => {
868
693
  const reportable = snapshot.violations.filter((one) => !one.baselined && !one.ledgered);
869
694
  const carried = snapshot.violations.length - reportable.length;
@@ -1058,7 +883,11 @@ export const writeBaseline = (
1058
883
 
1059
884
  // The question a tree config makes harder to answer than a flat one: given a
1060
885
  // file, what governs it? A flat config you grep; a tree you have to walk.
1061
- export const explain = (policy: LoadedPolicy, file: string): Effect.Effect<void, CliFailure> =>
886
+ export const explain = (
887
+ policy: LoadedPolicy,
888
+ file: string,
889
+ roots: ReadonlyArray<string> = ["packages"],
890
+ ): Effect.Effect<void, CliFailure> =>
1062
891
  Effect.gen(function* () {
1063
892
  const relative = path.relative(policy.repoRoot, path.resolve(policy.repoRoot, file));
1064
893
  const selected = rulesSelecting(policy.importRules, relative);
@@ -1113,10 +942,16 @@ export const explain = (policy: LoadedPolicy, file: string): Effect.Effect<void,
1113
942
  const section = (title: string, lines: ReadonlyArray<string>): ReadonlyArray<string> =>
1114
943
  lines.length === 0 ? [] : ["", title, ...lines];
1115
944
 
1116
- // Each campaign selecting the file, with its truth table: one line per
1117
- // leaf term and what it answered here, so a detector that "should fire"
1118
- // and does not shows which term is not saying what its author thinks.
1119
- const selectedCampaigns = campaignsSelecting(policy.campaignRules, relative);
945
+ // Each campaign selecting the file: the sector the file is in, its
946
+ // phase and definedness, the objectives in window that fire on it and
947
+ // the nearest remaining holdouts — which needs the campaign evaluated
948
+ // over its files, since a sector's phase is derived from all of them —
949
+ // then each objective's truth table: one line per leaf term and what
950
+ // it answered here, so a detector that "should fire" and does not shows
951
+ // which term is not saying what its author thinks.
952
+ const selectedCampaigns = campaignsSelecting(campaignsOf(policy).campaignRules, relative);
953
+ const evaluations =
954
+ selectedCampaigns.length === 0 ? [] : collectFindings(policy, roots).campaigns;
1120
955
  const campaignLines = selectedCampaigns.flatMap((rule) => {
1121
956
  const at = path.join(policy.repoRoot, relative);
1122
957
  const text = existsSync(at) ? readFileSync(at, "utf8") : "";
@@ -1127,16 +962,24 @@ export const explain = (policy: LoadedPolicy, file: string): Effect.Effect<void,
1127
962
  resolver: policy.resolver,
1128
963
  fileSystem: policy.fileSystem,
1129
964
  syntax: policy.syntax.parse(relative, text),
1130
- functions: policy.functions,
1131
- reports: policy.reports,
965
+ functions: campaignsOf(policy).functions,
966
+ reports: campaignsOf(policy).reports,
1132
967
  };
1133
- const hits = evaluateCampaigns([rule], input);
968
+ const evaluation = evaluations.find((one) => one.rule.id === rule.id);
1134
969
  return [
1135
- ` ${rule.name} — ${firstSentence(rule.why)} (${rule.unit}; ${hits.length === 0 ? "no hit" : count(hits.length, "hit")})`,
1136
- ...explainCampaign(rule, input).map(
1137
- (line) =>
1138
- ` ${line.answer ? "✓" : "✗"} ${line.term}${line.count === undefined ? "" : ` (${String(line.count)})`}`,
1139
- ),
970
+ ...(evaluation === undefined ? [] : explainCampaignLines(policy, evaluation, relative)),
971
+ ...rule.objectives.flatMap((objective) => {
972
+ const table = explainObjective(objective, input);
973
+ if (table.length === 0) return [];
974
+ const fired = table.length > 0 && table.every((line) => line.answer);
975
+ return [
976
+ ` ${objective.name} — ${firstSentence(objective.why ?? objective.message)} (${objective.holdout}; ${fired ? "fires here" : "no hit"})`,
977
+ ...table.map(
978
+ (line) =>
979
+ ` ${line.answer ? "✓" : "✗"} ${line.term}${line.count === undefined ? "" : ` (${String(line.count)})`}`,
980
+ ),
981
+ ];
982
+ }),
1140
983
  ];
1141
984
  });
1142
985
 
@@ -1278,18 +1121,23 @@ limits:
1278
1121
  unrestricted: 0
1279
1122
  partial: 0
1280
1123
 
1281
- # Migrations the repository is running, each with a detector, a rationale, a
1282
- # guide, an owner and a ledger of every place the pattern still occurs. Fill
1283
- # one in, then \`architecture campaigns init <id>\` to write its ledger.
1124
+ # Refactors the repository is running, each an object: objectives (a
1125
+ # detector with a ledger under \`.architecture-campaigns/\` of every place the
1126
+ # pattern still occurs), over sectors the code births through a perimeter,
1127
+ # through phases toward an end. Fill one in, then
1128
+ # \`architecture objectives clear <id>\` to write its ledgers.
1284
1129
  # https://dataquail.github.io/goodbones/architecture-rules/manifest/campaigns/
1285
1130
  # campaigns:
1286
- # - id: js-to-ts
1131
+ # js-to-ts:
1287
1132
  # why: The strict tsconfig cannot land while any src file is JavaScript.
1288
1133
  # how: Rename to .ts, add types at the module boundary, leave the body alone.
1289
- # scope: ["src/**"]
1290
- # unit: file
1291
- # detect: { path: { file: "\\.(js|jsx)$" } }
1292
- # probes: { fires: [{ path: src/legacy/util.js }], ignores: [{ path: src/util.ts }] }
1134
+ # scope: { path: "src/**", extensions: [.js, .jsx] }
1135
+ # perimeter: file
1136
+ # objectives:
1137
+ # is-ts:
1138
+ # holdout: file
1139
+ # match: { path: { file: "\\\\.(js|jsx)$" } }
1140
+ # probes: { fires: [{ path: src/legacy/util.js }], ignores: [{ path: src/util.ts }] }
1293
1141
  # staleAfter: 14d
1294
1142
  # onComplete: remove
1295
1143
 
@@ -1381,38 +1229,12 @@ export const migrate = (
1381
1229
  ]);
1382
1230
  });
1383
1231
 
1384
- // The ledgers. `campaigns` alone is the status table; `init` writes a
1385
- // campaign's first ledger from what fires today; `prune` removes what no
1386
- // longer fires; `allow` is the one way an entry is added, and it records why.
1387
- const ledgerPathOf = (policy: LoadedPolicy, id: string): string =>
1388
- path.resolve(policy.repoRoot, policy.ledgerDir, `${id}.json`);
1389
-
1390
- const writeLedger = (policy: LoadedPolicy, ledger: Ledger): void => {
1391
- const at = ledgerPathOf(policy, ledger.id);
1392
- mkdirSync(path.dirname(at), { recursive: true });
1393
- writeFileSync(at, serializeLedger(ledger));
1394
- };
1395
-
1396
- const campaignNamed = (policy: LoadedPolicy, id: string): CompiledCampaign | null =>
1397
- policy.campaignRules.find((rule) => rule.id === id) ?? null;
1398
-
1399
- // The author of a regression: `--by`, else git's user.email, else the
1400
- // GIT_AUTHOR_EMAIL the environment carries. Without one the record is refused
1401
- // rather than written blank, since the record is the point.
1402
- const authorOf = (given: string | undefined): string | null => {
1403
- if (given !== undefined && given !== "") return given;
1404
- try {
1405
- const email = execFileSync("git", ["config", "user.email"], {
1406
- encoding: "utf8",
1407
- stdio: ["ignore", "pipe", "ignore"],
1408
- }).trim();
1409
- if (email !== "") return email;
1410
- } catch {
1411
- // git absent, or no email configured
1412
- }
1413
- const fromEnvironment = process.env.GIT_AUTHOR_EMAIL;
1414
- return fromEnvironment === undefined || fromEnvironment === "" ? null : fromEnvironment;
1415
- };
1232
+ // The campaign commands. `campaigns` alone is the status table; `status
1233
+ // --changed` is the nudge; `attest` and `note` write a sector's record;
1234
+ // `history` replays the ledgers' git history. The ledgers themselves are
1235
+ // written by `objectives clear` (the ledger reconciled with the code
1236
+ // wherever that is not a regression) and `objectives concede` (the one way
1237
+ // a holdout is added by hand, with a reason).
1416
1238
 
1417
1239
  const flagOf = (argv: ReadonlyArray<string>, flag: string): string | undefined => {
1418
1240
  const at = argv.indexOf(flag);
@@ -1420,188 +1242,443 @@ const flagOf = (argv: ReadonlyArray<string>, flag: string): string | undefined =
1420
1242
  return value === undefined || value.startsWith("--") ? undefined : value;
1421
1243
  };
1422
1244
 
1423
- const CAMPAIGN_SUBCOMMANDS = ["init", "prune", "allow"] as const;
1424
- const CAMPAIGN_VALUE_FLAGS = ["--reason", "--by", "--entries"] as const;
1245
+ const CAMPAIGN_SUBCOMMANDS = ["status", "attest", "note", "history", "clear", "concede"] as const;
1246
+ const OBJECTIVE_SUBCOMMANDS = ["clear", "concede"] as const;
1247
+ const VALUE_FLAGS = [
1248
+ "--reason",
1249
+ "--by",
1250
+ "--holdouts",
1251
+ "--entries",
1252
+ "--sector",
1253
+ "--campaign",
1254
+ "--evidence",
1255
+ "--base",
1256
+ "--since",
1257
+ "--hotfix",
1258
+ ] as const;
1259
+
1260
+ // The verbs the family shipped with, refused by name: each has a new name
1261
+ // or no place.
1262
+ const RETIRED: Readonly<Record<string, string>> = {
1263
+ init: "`campaigns init` is gone: `objectives clear <campaign>` writes a first ledger, recording each sector's initial.",
1264
+ prune: "`campaigns prune` is now `objectives clear`.",
1265
+ allow: "`campaigns allow` is now `objectives concede`.",
1266
+ };
1267
+
1268
+ // The positionals after the subcommand, with the value flags and their
1269
+ // values stepped over: whatever is left names the roots to walk, as it
1270
+ // does for every other command.
1271
+ const positionalsOf = (argv: ReadonlyArray<string>): ReadonlyArray<string> => {
1272
+ const positional: Array<string> = [];
1273
+ for (let at = 0; at < argv.length; at += 1) {
1274
+ const one = argv[at] ?? "";
1275
+ if ((VALUE_FLAGS as ReadonlyArray<string>).includes(one)) {
1276
+ at += 1;
1277
+ continue;
1278
+ }
1279
+ if (!one.startsWith("--")) positional.push(one);
1280
+ }
1281
+ return positional;
1282
+ };
1425
1283
 
1426
- // `campaigns [init <id> | prune [<id>] | allow <id> --reason <text>] [roots…]`:
1427
- // the subcommand and its id come first; whatever positional is left names
1428
- // the roots to walk, as it does for every other command.
1284
+ // `campaigns [status | attest <sector> <phase> | note <sector> "<text>" |
1285
+ // history [<campaign>]] [roots…]`: the subcommand and its arguments come
1286
+ // first.
1429
1287
  export const campaignArgsOf = (
1430
1288
  argv: ReadonlyArray<string>,
1431
1289
  ids: ReadonlyArray<string>,
1432
1290
  ): {
1433
1291
  readonly subcommand: string | undefined;
1434
- readonly id: string | undefined;
1292
+ readonly args: ReadonlyArray<string>;
1435
1293
  readonly roots: ReadonlyArray<string>;
1436
1294
  } => {
1437
- const positional: Array<string> = [];
1438
- for (let at = 0; at < argv.length; at += 1) {
1439
- const one = argv[at] ?? "";
1440
- if ((CAMPAIGN_VALUE_FLAGS as ReadonlyArray<string>).includes(one)) {
1441
- at += 1;
1442
- continue;
1295
+ const positional = positionalsOf(argv);
1296
+ const [first, ...rest] = positional;
1297
+ if (first === undefined) return { subcommand: undefined, args: [], roots: [] };
1298
+ if (first in RETIRED) return { subcommand: first, args: [], roots: rest };
1299
+ if (!(CAMPAIGN_SUBCOMMANDS as ReadonlyArray<string>).includes(first)) {
1300
+ return { subcommand: undefined, args: [], roots: positional };
1301
+ }
1302
+ switch (first) {
1303
+ case "attest":
1304
+ return { subcommand: first, args: rest.slice(0, 2), roots: rest.slice(2) };
1305
+ case "note":
1306
+ return { subcommand: first, args: rest.slice(0, 2), roots: rest.slice(2) };
1307
+ case "history": {
1308
+ const [second] = rest;
1309
+ return second !== undefined && ids.includes(second)
1310
+ ? { subcommand: first, args: [second], roots: rest.slice(1) }
1311
+ : { subcommand: first, args: [], roots: rest };
1443
1312
  }
1444
- if (!one.startsWith("--")) positional.push(one);
1313
+ case "clear":
1314
+ case "concede": {
1315
+ const [second] = rest;
1316
+ return second !== undefined && ids.includes(second.split("/")[0] ?? "")
1317
+ ? { subcommand: first, args: [second], roots: rest.slice(1) }
1318
+ : { subcommand: first, args: [], roots: rest };
1319
+ }
1320
+ default:
1321
+ return { subcommand: first, args: [], roots: rest };
1445
1322
  }
1323
+ };
1324
+
1325
+ // `objectives [clear [<campaign>[/<objective>]] | concede <campaign>[/<objective>]
1326
+ // --reason <text>] [roots…]`.
1327
+ export const objectiveArgsOf = (
1328
+ argv: ReadonlyArray<string>,
1329
+ ids: ReadonlyArray<string>,
1330
+ ): {
1331
+ readonly subcommand: string | undefined;
1332
+ readonly target: { campaign: string; objective: string | null } | null;
1333
+ readonly roots: ReadonlyArray<string>;
1334
+ } => {
1335
+ const positional = positionalsOf(argv);
1446
1336
  const [first, second, ...rest] = positional;
1447
- if (first === undefined || !(CAMPAIGN_SUBCOMMANDS as ReadonlyArray<string>).includes(first)) {
1448
- return { subcommand: undefined, id: undefined, roots: positional };
1337
+ if (first === undefined || !(OBJECTIVE_SUBCOMMANDS as ReadonlyArray<string>).includes(first)) {
1338
+ return { subcommand: first, target: null, roots: positional.slice(1) };
1339
+ }
1340
+ const [campaign = "", objective] = (second ?? "").split("/");
1341
+ const named = second !== undefined && ids.includes(campaign);
1342
+ return {
1343
+ subcommand: first,
1344
+ target: named ? { campaign, objective: objective ?? null } : null,
1345
+ roots: named ? rest : second === undefined ? [] : [second, ...rest],
1346
+ };
1347
+ };
1348
+
1349
+ // The one campaign, when there is one, else the one `--campaign` names.
1350
+ const campaignFor = (
1351
+ policy: LoadedPolicy,
1352
+ argv: ReadonlyArray<string>,
1353
+ ): Result.Result<CampaignEvaluation["rule"], string> => {
1354
+ const named = flagOf(argv, "--campaign");
1355
+ if (named !== undefined) {
1356
+ const found = campaignsOf(policy).campaignRules.find((rule) => rule.id === named);
1357
+ return found === undefined
1358
+ ? Result.fail(`no campaign is named "${named}"`)
1359
+ : Result.succeed(found);
1360
+ }
1361
+ const [only] = campaignsOf(policy).campaignRules;
1362
+ if (campaignsOf(policy).campaignRules.length === 1 && only !== undefined) return Result.succeed(only);
1363
+ return Result.fail(
1364
+ `this policy declares ${String(campaignsOf(policy).campaignRules.length)} campaigns; say which with --campaign <id>.`,
1365
+ );
1366
+ };
1367
+
1368
+ const objectiveFor = (
1369
+ rule: CampaignEvaluation["rule"],
1370
+ objective: string | null,
1371
+ ): Result.Result<string, string> => {
1372
+ if (objective !== null) {
1373
+ return rule.objectives.some((one) => one.id === objective)
1374
+ ? Result.succeed(objective)
1375
+ : Result.fail(`no objective of ${rule.id} is named "${objective}"`);
1449
1376
  }
1450
- // `prune` takes an optional id; the next word is one only if a campaign
1451
- // has that name, else it is a root.
1452
- const takesId = first !== "prune" || (second !== undefined && ids.includes(second));
1453
- return takesId
1454
- ? { subcommand: first, id: second, roots: rest }
1455
- : { subcommand: first, id: undefined, roots: second === undefined ? [] : [second, ...rest] };
1377
+ const [only] = rule.objectives;
1378
+ if (rule.objectives.length === 1 && only !== undefined) return Result.succeed(only.id);
1379
+ return Result.fail(
1380
+ `campaign ${rule.id} declares ${String(rule.objectives.length)} objectives; say which as ${rule.id}/<objective>.`,
1381
+ );
1456
1382
  };
1457
1383
 
1458
- export const campaigns = (
1384
+ export const objectives = (
1459
1385
  policy: LoadedPolicy,
1460
1386
  defaultRoots: ReadonlyArray<string>,
1461
1387
  argv: ReadonlyArray<string>,
1462
1388
  ): Effect.Effect<void, CliFailure> =>
1463
1389
  Effect.gen(function* () {
1464
- const parsed = campaignArgsOf(
1390
+ const parsed = objectiveArgsOf(
1465
1391
  argv,
1466
- policy.campaignRules.map((rule) => rule.id),
1392
+ campaignsOf(policy).campaignRules.map((rule) => rule.id),
1467
1393
  );
1468
- const { id, subcommand } = parsed;
1469
1394
  const roots = parsed.roots.length > 0 ? parsed.roots : defaultRoots;
1470
- if (policy.campaignRules.length === 0) {
1395
+ if (campaignsOf(policy).campaignRules.length === 0) {
1471
1396
  return yield* report(["this policy declares no campaigns."]);
1472
1397
  }
1473
- const hitsOf = (): ReadonlyArray<CampaignHit> => collectFindings(policy, roots).campaigns;
1474
- const own = (hits: ReadonlyArray<CampaignHit>, campaign: string): ReadonlyArray<Violation> =>
1475
- hits.filter((hit) => hit.campaign === campaign).map((hit) => hit.violation);
1398
+ const evaluations = (): ReadonlyArray<CampaignEvaluation> =>
1399
+ collectFindings(policy, roots).campaigns;
1476
1400
 
1477
- switch (subcommand) {
1478
- case undefined: {
1479
- const snapshot = snapshotOf(policy, roots, manifestPathOf(policy.repoRoot));
1480
- return yield* report([
1481
- `${count(snapshot.campaigns.length, "campaign")} under ${roots.join(", ")}`,
1482
- "",
1483
- ...renderCampaignRows(snapshot.campaigns),
1484
- "",
1485
- " architecture campaigns init <id> # write a ledger from what fires today",
1486
- " architecture campaigns prune [<id>] # drop entries that no longer fire",
1487
- ' architecture campaigns allow <id> --reason "<why>" # record why the count may rise',
1488
- ]);
1489
- }
1490
- case "init": {
1491
- if (id === undefined) return yield* Effect.fail(fail("campaigns init needs a campaign id"));
1492
- const rule = campaignNamed(policy, id);
1493
- if (rule === null) return yield* Effect.fail(fail(`no campaign is named "${id}"`));
1494
- if (policy.ledgers.has(id)) {
1495
- return yield* Effect.fail(
1496
- fail(
1497
- `${path.relative(policy.repoRoot, ledgerPathOf(policy, id))} already exists. \`init\` ` +
1498
- `writes a campaign's first ledger and does not overwrite one; \`prune\` and \`allow\` ` +
1499
- `are how it changes.`,
1500
- ),
1501
- );
1502
- }
1503
- const ledger = ledgerOf(id, own(hitsOf(), id), policy.now);
1504
- yield* Effect.sync(() => {
1505
- writeLedger(policy, ledger);
1506
- });
1507
- return yield* report([
1508
- `${count(ledger.entries.length, "hit")} recorded in ${path.relative(policy.repoRoot, ledgerPathOf(policy, id))}.`,
1509
- "Each one is a place the campaign has yet to reach. Fixing one means pruning its line.",
1510
- ]);
1511
- }
1512
- case "prune": {
1401
+ switch (parsed.subcommand) {
1402
+ case "clear": {
1513
1403
  const targets =
1514
- id === undefined
1515
- ? policy.campaignRules
1516
- : [campaignNamed(policy, id)].filter((one) => one !== null);
1517
- if (id !== undefined && targets.length === 0) {
1518
- return yield* Effect.fail(fail(`no campaign is named "${id}"`));
1519
- }
1520
- const hits = hitsOf();
1404
+ parsed.target === null
1405
+ ? campaignsOf(policy).campaignRules
1406
+ : campaignsOf(policy).campaignRules.filter((rule) => rule.id === parsed.target?.campaign);
1407
+ const by = authorOf(flagOf(argv, "--by")) ?? "unknown";
1408
+ const all = evaluations();
1521
1409
  const lines: Array<string> = [];
1522
1410
  for (const rule of targets) {
1523
- const ledger = policy.ledgers.get(rule.id);
1524
- if (ledger === undefined) {
1525
- lines.push(`${rule.id}: no ledger to prune (run \`campaigns init ${rule.id}\`).`);
1526
- continue;
1527
- }
1528
- const next = pruned(ledger, own(hits, rule.id), rule.unit, policy.now);
1529
- const removed = ledger.entries.length - next.entries.length;
1530
- const rewritten = next.entries.filter((entry) => !ledger.entries.includes(entry)).length;
1531
- if (removed === 0 && rewritten === 0) {
1532
- lines.push(`${rule.id}: nothing to prune.`);
1533
- continue;
1411
+ const evaluation = all.find((one) => one.rule.id === rule.id);
1412
+ if (evaluation === undefined) continue;
1413
+ const only = parsed.target?.objective ?? null;
1414
+ if (only !== null && !rule.objectives.some((one) => one.id === only)) {
1415
+ return yield* Effect.fail(fail(`no objective of ${rule.id} is named "${only}"`));
1534
1416
  }
1535
- yield* Effect.sync(() => {
1536
- writeLedger(policy, next);
1417
+ const outcomes = yield* Effect.try({
1418
+ try: () => clear(policy, evaluation, only, by),
1419
+ catch: (cause) => fail(String(cause)),
1537
1420
  });
1538
- lines.push(
1539
- `${rule.id}: ${count(removed, "entry", "entries")} pruned` +
1540
- (rewritten > 0 ? `, ${count(rewritten, "entry", "entries")} rewritten` : "") +
1541
- `; ${count(next.entries.length, "entry", "entries")} left.`,
1542
- );
1421
+ for (const outcome of outcomes) {
1422
+ const parts = [
1423
+ ...(outcome.entered.length > 0
1424
+ ? [
1425
+ `${count(outcome.entered.length, "sector")} entered (${outcome.entered.join(", ")})`,
1426
+ ]
1427
+ : []),
1428
+ ...(outcome.cleared > 0 ? [`${count(outcome.cleared, "holdout")} cleared`] : []),
1429
+ ...(outcome.rewritten > 0
1430
+ ? [`${count(outcome.rewritten, "holdout")} rewritten`]
1431
+ : []),
1432
+ ...(outcome.closed > 0 ? [`${count(outcome.closed, "holdout")} closed`] : []),
1433
+ ...(outcome.rebaselined.length > 0
1434
+ ? [
1435
+ `${count(outcome.rebaselined.length, "sector")} re-baselined (${outcome.rebaselined.join(", ")})`,
1436
+ ]
1437
+ : []),
1438
+ ];
1439
+ lines.push(
1440
+ `${outcome.campaign}/${outcome.objective}: ${parts.length === 0 ? "nothing to clear" : parts.join(", ")}; ${count(outcome.left, "holdout")} left.`,
1441
+ );
1442
+ }
1543
1443
  }
1544
1444
  return yield* report(lines);
1545
1445
  }
1546
- case "allow": {
1547
- if (id === undefined)
1548
- return yield* Effect.fail(fail("campaigns allow needs a campaign id"));
1549
- const rule = campaignNamed(policy, id);
1550
- if (rule === null) return yield* Effect.fail(fail(`no campaign is named "${id}"`));
1551
- const ledger = policy.ledgers.get(id);
1552
- if (ledger === undefined) {
1446
+ case "concede": {
1447
+ if (parsed.target === null) {
1553
1448
  return yield* Effect.fail(
1554
- fail(`campaign ${id} has no ledger yet; run \`campaigns init ${id}\` first.`),
1449
+ fail(
1450
+ "objectives concede needs a campaign: `objectives concede <campaign>[/<objective>] --reason <text>`",
1451
+ ),
1555
1452
  );
1556
1453
  }
1454
+ const rule = campaignsOf(policy).campaignRules.find((one) => one.id === parsed.target?.campaign);
1455
+ if (rule === undefined)
1456
+ return yield* Effect.fail(fail(`no campaign is named "${parsed.target.campaign}"`));
1457
+ const objective = objectiveFor(rule, parsed.target.objective);
1458
+ if (Result.isFailure(objective)) return yield* Effect.fail(fail(objective.failure));
1557
1459
  const reason = flagOf(argv, "--reason");
1558
1460
  if (reason === undefined) {
1559
1461
  return yield* Effect.fail(
1560
1462
  fail(
1561
- "campaigns allow needs --reason <text>: growth is recorded with why, or not at all.",
1463
+ "objectives concede needs --reason <text>: growth is recorded with why, or not at all.",
1562
1464
  ),
1563
1465
  );
1564
1466
  }
1565
1467
  const by = authorOf(flagOf(argv, "--by"));
1566
1468
  if (by === null) {
1567
1469
  return yield* Effect.fail(
1568
- fail("campaigns allow needs an author: pass --by <email>, or set git's user.email."),
1470
+ fail("objectives concede needs an author: pass --by <email>, or set git's user.email."),
1569
1471
  );
1570
1472
  }
1571
- const state = reconcile(ledger, own(hitsOf(), id), rule.unit);
1572
- const unrecorded = [...new Set(state.unrecorded.map(entryOf))].sort();
1573
- // `--entries` allows a subset and refuses the rest: a pull request
1574
- // that legitimately adds one hit while another is an accident.
1473
+ const evaluation = evaluations().find((one) => one.rule.id === rule.id);
1474
+ if (evaluation === undefined)
1475
+ return yield* Effect.fail(fail(`campaign ${rule.id} was not evaluated`));
1476
+ // `--holdouts` concedes a subset and refuses the rest: a pull
1477
+ // request that legitimately adds one hit while another is an
1478
+ // accident.
1575
1479
  const chosen =
1576
- flagOf(argv, "--entries")
1480
+ (flagOf(argv, "--holdouts") ?? flagOf(argv, "--entries"))
1577
1481
  ?.split(",")
1578
- .map((one) => one.trim()) ?? unrecorded;
1579
- const unknown = chosen.filter((entry) => !unrecorded.includes(entry));
1580
- if (unknown.length > 0) {
1482
+ .map((one) => one.trim()) ?? null;
1483
+ const outcome = concede(
1484
+ policy,
1485
+ evaluation,
1486
+ objective.success,
1487
+ chosen,
1488
+ flagOf(argv, "--sector") ?? null,
1489
+ {
1490
+ at: policy.now,
1491
+ by,
1492
+ reason,
1493
+ },
1494
+ );
1495
+ if (Result.isFailure(outcome)) return yield* Effect.fail(fail(outcome.failure));
1496
+ if (outcome.success.conceded.length === 0) {
1497
+ return yield* report([
1498
+ `${rule.id}/${objective.success}: nothing to concede; every hit is in the ledger.`,
1499
+ ]);
1500
+ }
1501
+ return yield* report([
1502
+ `${rule.id}/${objective.success}: ${count(outcome.success.conceded.length, "holdout")} conceded, recorded by ${by}.`,
1503
+ ...outcome.success.conceded.map((one) => ` ${one.sector} · ${one.entry}`),
1504
+ ...(outcome.success.left.length === 0
1505
+ ? []
1506
+ : [
1507
+ "",
1508
+ `${count(outcome.success.left.length, "hit")} left unrecorded; check still fails on them.`,
1509
+ ]),
1510
+ ]);
1511
+ }
1512
+ default:
1513
+ return yield* Effect.fail(
1514
+ fail(
1515
+ `unknown objectives subcommand "${parsed.subcommand ?? ""}". Try: objectives clear [<campaign>[/<objective>]] | objectives concede <campaign>[/<objective>] --reason <text> [--by <email>] [--sector <name>] [--holdouts a,b]`,
1516
+ ),
1517
+ );
1518
+ }
1519
+ });
1520
+
1521
+ export const campaigns = (
1522
+ policy: LoadedPolicy,
1523
+ defaultRoots: ReadonlyArray<string>,
1524
+ argv: ReadonlyArray<string>,
1525
+ configFilename?: string,
1526
+ ): Effect.Effect<void, CliFailure> =>
1527
+ Effect.gen(function* () {
1528
+ const parsed = campaignArgsOf(
1529
+ argv,
1530
+ campaignsOf(policy).campaignRules.map((rule) => rule.id),
1531
+ );
1532
+ const roots = parsed.roots.length > 0 ? parsed.roots : defaultRoots;
1533
+ if (campaignsOf(policy).campaignRules.length === 0) {
1534
+ return yield* report(["this policy declares no campaigns."]);
1535
+ }
1536
+ const json = argv.includes("--json");
1537
+ const retired = parsed.subcommand === undefined ? undefined : RETIRED[parsed.subcommand];
1538
+ if (retired !== undefined) return yield* Effect.fail(fail(retired));
1539
+
1540
+ switch (parsed.subcommand) {
1541
+ case undefined: {
1542
+ const snapshot = snapshotOf(policy, roots, manifestPathOf(policy.repoRoot, configFilename));
1543
+ return yield* report([
1544
+ `${count(snapshot.campaigns.length, "campaign")} under ${roots.join(", ")}`,
1545
+ "",
1546
+ ...renderCampaignRows(snapshot.campaigns),
1547
+ "",
1548
+ " architecture campaigns status --changed [--base <ref>] [--json] # what a diff touches, and what to do",
1549
+ " architecture objectives clear [<campaign>[/<objective>]] # reconcile the ledgers with the code",
1550
+ ' architecture objectives concede <campaign>[/<objective>] --reason "<why>" # record why a count may rise',
1551
+ ' architecture campaigns attest <sector> <phase> --reason "<why>" [--evidence <url>]',
1552
+ ' architecture campaigns note <sector> "<text>"',
1553
+ " architecture campaigns history [<campaign>] [--since <ref>]",
1554
+ ]);
1555
+ }
1556
+ case "status": {
1557
+ if (!argv.includes("--changed")) {
1581
1558
  return yield* Effect.fail(
1582
- fail(`these entries are not unrecorded hits of ${id}: ${unknown.join(", ")}`),
1559
+ fail("campaigns status takes --changed: the nudge is scoped to a diff."),
1583
1560
  );
1584
1561
  }
1585
- if (chosen.length === 0) {
1586
- return yield* report([`${id}: nothing to allow; every hit is in the ledger.`]);
1562
+ const base = flagOf(argv, "--base") ?? null;
1563
+ const diff = yield* Effect.try({
1564
+ try: () => readDiff(policy.repoRoot, base),
1565
+ catch: (cause) => fail(`could not read the diff: ${String(cause)}`),
1566
+ });
1567
+ const current = collectFindings(policy, roots).campaigns;
1568
+ const baseSide =
1569
+ base === null
1570
+ ? null
1571
+ : yield* Effect.tryPromise({
1572
+ try: () =>
1573
+ baseSideAt(policy, base, roots, (repoRoot) =>
1574
+ loadPolicyFromFile(repoRoot, configFilename),
1575
+ ),
1576
+ catch: (cause) =>
1577
+ fail(`could not evaluate the base tree at ${base}: ${String(cause)}`),
1578
+ });
1579
+ const hotfix = flagOf(argv, "--hotfix") ?? null;
1580
+ const nudge = nudgeOf(
1581
+ policy,
1582
+ current,
1583
+ diff,
1584
+ baseSide,
1585
+ hotfix,
1586
+ hotfix === null ? null : authorOf(flagOf(argv, "--by")),
1587
+ );
1588
+ yield* report(json ? [JSON.stringify(nudge, null, 2)] : renderNudge(nudge, policy.now));
1589
+ if (!nudge.ok)
1590
+ return yield* Effect.fail(fail("the diff sends a sector back under its onTouch"));
1591
+ return;
1592
+ }
1593
+ case "attest": {
1594
+ const [sector, phase] = parsed.args;
1595
+ if (sector === undefined || phase === undefined) {
1596
+ return yield* Effect.fail(
1597
+ fail(
1598
+ 'campaigns attest needs a sector and a phase: `campaigns attest <sector> <phase> --reason "<why>"`',
1599
+ ),
1600
+ );
1587
1601
  }
1588
- const next = allowed(ledger, chosen, { at: policy.now, by, reason });
1589
- yield* Effect.sync(() => {
1590
- writeLedger(policy, next);
1602
+ const reason = flagOf(argv, "--reason");
1603
+ if (reason === undefined)
1604
+ return yield* Effect.fail(fail("campaigns attest needs --reason <text>."));
1605
+ const by = authorOf(flagOf(argv, "--by"));
1606
+ if (by === null)
1607
+ return yield* Effect.fail(
1608
+ fail("campaigns attest needs an author: pass --by <email>, or set git's user.email."),
1609
+ );
1610
+ const rule = campaignFor(policy, argv);
1611
+ if (Result.isFailure(rule)) return yield* Effect.fail(fail(rule.failure));
1612
+ const evaluation = collectFindings(policy, roots).campaigns.find(
1613
+ (one) => one.rule.id === rule.success.id,
1614
+ );
1615
+ if (evaluation === undefined)
1616
+ return yield* Effect.fail(fail(`campaign ${rule.success.id} was not evaluated`));
1617
+ const written = attest(policy, evaluation, sector, phase, {
1618
+ reason,
1619
+ evidence: flagOf(argv, "--evidence"),
1620
+ by,
1591
1621
  });
1592
- const left = unrecorded.filter((entry) => !chosen.includes(entry));
1622
+ if (Result.isFailure(written)) return yield* Effect.fail(fail(written.failure));
1593
1623
  return yield* report([
1594
- `${id}: ${count(chosen.length, "entry", "entries")} allowed, recorded as a regression by ${by}.`,
1595
- ...chosen.map((entry) => ` ${entry}`),
1596
- ...(left.length === 0
1597
- ? []
1598
- : ["", `${count(left.length, "hit")} left unrecorded; check still fails on them.`]),
1624
+ `${rule.success.id}: sector ${sector} attested at ${phase} by ${by}, in ${written.success}.`,
1625
+ "Run `objectives clear` to move it on.",
1626
+ ]);
1627
+ }
1628
+ case "note": {
1629
+ const [sector, text] = parsed.args;
1630
+ if (sector === undefined || text === undefined) {
1631
+ return yield* Effect.fail(
1632
+ fail('campaigns note needs a sector and a text: `campaigns note <sector> "<text>"`'),
1633
+ );
1634
+ }
1635
+ const by = authorOf(flagOf(argv, "--by"));
1636
+ if (by === null)
1637
+ return yield* Effect.fail(
1638
+ fail("campaigns note needs an author: pass --by <email>, or set git's user.email."),
1639
+ );
1640
+ const rule = campaignFor(policy, argv);
1641
+ if (Result.isFailure(rule)) return yield* Effect.fail(fail(rule.failure));
1642
+ const evaluation = collectFindings(policy, roots).campaigns.find(
1643
+ (one) => one.rule.id === rule.success.id,
1644
+ );
1645
+ if (evaluation === undefined)
1646
+ return yield* Effect.fail(fail(`campaign ${rule.success.id} was not evaluated`));
1647
+ const written = note(policy, evaluation, sector, text, by);
1648
+ if (Result.isFailure(written)) return yield* Effect.fail(fail(written.failure));
1649
+ return yield* report([
1650
+ `${rule.success.id}: note left on ${sector}, in ${written.success}.`,
1599
1651
  ]);
1600
1652
  }
1653
+ case "history": {
1654
+ const [named] = parsed.args;
1655
+ const targets =
1656
+ named === undefined
1657
+ ? campaignsOf(policy).campaignRules
1658
+ : campaignsOf(policy).campaignRules.filter((rule) => rule.id === named);
1659
+ const manifestPath = path
1660
+ .relative(policy.repoRoot, manifestPathOf(policy.repoRoot, configFilename))
1661
+ .replaceAll(path.sep, "/");
1662
+ const lines: Array<string> = [];
1663
+ for (const rule of targets) {
1664
+ const rows = historyOf(policy, rule, flagOf(argv, "--since") ?? null, [manifestPath]);
1665
+ if (json) {
1666
+ lines.push(JSON.stringify({ campaign: rule.id, rows }, null, 2));
1667
+ continue;
1668
+ }
1669
+ if (lines.length > 0) lines.push("");
1670
+ for (const line of renderHistory(rule, rows)) lines.push(line);
1671
+ }
1672
+ return yield* report(lines);
1673
+ }
1674
+ case "clear":
1675
+ case "concede":
1676
+ // The ledger verbs answer under `objectives`; accepted here too.
1677
+ return yield* objectives(policy, defaultRoots, argv);
1601
1678
  default:
1602
1679
  return yield* Effect.fail(
1603
1680
  fail(
1604
- `unknown campaigns subcommand "${subcommand}". Try: campaigns | campaigns init <id> | campaigns prune [<id>] | campaigns allow <id> --reason <text> [--by <email>] [--entries a,b]`,
1681
+ `unknown campaigns subcommand "${parsed.subcommand}". Try: campaigns | campaigns status --changed | campaigns attest <sector> <phase> --reason <text> | campaigns note <sector> "<text>" | campaigns history [<campaign>]`,
1605
1682
  ),
1606
1683
  );
1607
1684
  }
@@ -1613,6 +1690,7 @@ const READS_REPORTS: ReadonlySet<string> = new Set([
1613
1690
  "conformance",
1614
1691
  "baseline",
1615
1692
  "campaigns",
1693
+ "objectives",
1616
1694
  "explain",
1617
1695
  ]);
1618
1696
 
@@ -1665,7 +1743,7 @@ export const run = (
1665
1743
  yield* Effect.tryPromise({
1666
1744
  try: () =>
1667
1745
  Promise.all(
1668
- reportSpecsOf(policy.campaignRules).map((spec) => policy.reports.read?.(spec)),
1746
+ reportSpecsOf(campaignsOf(policy).campaignRules).map((spec) => campaignsOf(policy).reports.read?.(spec)),
1669
1747
  ),
1670
1748
  catch: (cause) => fail(String(cause)),
1671
1749
  });
@@ -1682,7 +1760,9 @@ export const run = (
1682
1760
 
1683
1761
  switch (command) {
1684
1762
  case "campaigns":
1685
- return yield* campaigns(policy, ["packages"], rest);
1763
+ return yield* campaigns(policy, ["packages"], rest, configFilename);
1764
+ case "objectives":
1765
+ return yield* objectives(policy, ["packages"], rest);
1686
1766
  case "check":
1687
1767
  return yield* check(policy, roots, {
1688
1768
  format: json ? "json" : "text",
@@ -1696,9 +1776,9 @@ export const run = (
1696
1776
  case "baseline":
1697
1777
  return yield* writeBaseline(policy, roots);
1698
1778
  case "explain": {
1699
- const [file] = rest;
1779
+ const [file, ...explainRoots] = positional;
1700
1780
  if (file === undefined) return yield* Effect.fail(fail("explain needs a file path"));
1701
- return yield* explain(policy, file);
1781
+ return yield* explain(policy, file, explainRoots.length > 0 ? explainRoots : ["packages"]);
1702
1782
  }
1703
1783
  case "coverage":
1704
1784
  return yield* coverage(policy, roots);
@@ -1710,7 +1790,7 @@ export const run = (
1710
1790
  default:
1711
1791
  return yield* Effect.fail(
1712
1792
  fail(
1713
- `unknown command "${command}". Try: check [--json] | conformance [--json] [--against <manifest>] | baseline | campaigns [init <id> | prune [<id>] | allow <id> --reason <text>] | coverage | explain <file> | facts <file> [--json] | init | infer | migrate`,
1793
+ `unknown command "${command}". Try: check [--json] | conformance [--json] [--against <manifest>] | baseline | campaigns [status --changed | attest | note | history] | objectives [clear | concede] | coverage | explain <file> | facts <file> [--json] | init | infer | migrate`,
1714
1794
  ),
1715
1795
  );
1716
1796
  }