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

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/build/esm/run.js CHANGED
@@ -1,8 +1,8 @@
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
- import { allowed, baselineOf, campaignsSelecting, CONFORMANCE_MEASURES, coverageOf, cyclesIn, decodeBaseline, decodeManifest, EMPTY_BASELINE, entryOf, evaluateCampaigns, evaluateGraph, evaluateMemberSite, evaluateResolvedEdge, evaluateSelectedBindings, evaluateStructure, evaluateSurface, explainCampaign, exportRulesSelecting, findManifestFile, fingerprintOf, formatManifestYaml, formatMessage, fractionsOf, hasGraphRules, heightOf, isComplete, isStalled, leafTermsOf, ledgerArithmeticHolds, ledgerOf, listSourceFiles, makeBaselineFilter, MANIFEST_FILENAMES, MANIFEST_SCHEMA_ID, memberRulesSelecting, progressOf, pruned, readManifestFile, reconcile, reportSpecsOf, requiredSiblingsOf, residueOf, rulesSelecting, serializeBaseline, serializeLedger, slackOf, SNAPSHOT_VERSION, staleEntriesOf, surfaceRulesSelecting, vacancyOf, } from "@goodbones/core";
4
+ import { attest, authorOf, baseSideAt, campaignFailuresOf, campaignReportsOf, campaignsOf, campaignsSelecting, clear, concede, evaluateCampaigns, explainCampaignLines, explainObjective, historyOf, hitsInWindow, ledgeredFilter, note, nudgeOf, readDiff, renderCampaignReports, renderCampaignRows, renderHistory, renderNudge, reportSpecsOf, snapshotCampaignsOf, widenedExtensions, } from "@goodbones/campaigns";
5
+ import { baselineOf, CONFORMANCE_MEASURES, coverageOf, cyclesIn, decodeBaseline, decodeManifest, EMPTY_BASELINE, evaluateGraph, evaluateMemberSite, evaluateResolvedEdge, evaluateSelectedBindings, evaluateStructure, evaluateSurface, exportRulesSelecting, findManifestFile, fingerprintOf, formatManifestYaml, formatMessage, fractionsOf, hasGraphRules, heightOf, listSourceFiles, makeBaselineFilter, MANIFEST_FILENAMES, MANIFEST_SCHEMA_ID, memberRulesSelecting, readManifestFile, requiredSiblingsOf, residueOf, rulesSelecting, serializeBaseline, slackOf, SNAPSHOT_VERSION, staleEntriesOf, surfaceRulesSelecting, vacancyOf, } from "@goodbones/core";
6
6
  import * as Effect from "effect/Effect";
7
7
  import * as Result from "effect/Result";
8
8
  import { loadPolicyFromFile, manifestPathOf } from "./config-loader.js";
@@ -11,9 +11,12 @@ import { infer } from "./infer.js";
11
11
  import { sourceFactsOf } from "./source-facts.js";
12
12
  const fail = (message) => ({ _tag: "CliFailure", message });
13
13
  export const collectFindings = (policy, roots, options = {}) => {
14
- const files = listSourceFiles(policy.repoRoot, roots, policy.languages);
14
+ // A campaign may widen the walk past the packs' extensions; a file only a
15
+ // campaign asked for is seen by the campaigns and by no other family.
16
+ const walked = listSourceFiles(policy.repoRoot, roots, policy.languages, widenedExtensions(policy));
17
+ const known = new Set(policy.languages.flatMap((one) => one.extensions));
18
+ const files = walked.filter((file) => known.has(path.extname(file)));
15
19
  const violations = [];
16
- const campaigns = [];
17
20
  const unresolved = [];
18
21
  const edges = [];
19
22
  // Each file is read and parsed at most once, whether the per-file
@@ -45,30 +48,12 @@ export const collectFindings = (policy, roots, options = {}) => {
45
48
  for (const violation of evaluateGraph(policy.graph, graph))
46
49
  violations.push(violation);
47
50
  }
51
+ // The campaigns, over every walked file, through the same caches.
52
+ const campaigns = evaluateCampaigns(policy, roots, walked, { textOf, factsOf });
48
53
  for (const file of files) {
49
54
  for (const violation of evaluateStructure(policy.structure, policy.fileSystem, file)) {
50
55
  violations.push(violation);
51
56
  }
52
- // A campaign selects by its scope. The file is parsed by the scope's
53
- // matcher once, and only when a term of some selected campaign reads the
54
- // syntax tree.
55
- const selectedCampaigns = campaignsSelecting(policy.campaignRules, file);
56
- if (selectedCampaigns.length > 0) {
57
- const text = textOf(file);
58
- const needsSyntax = selectedCampaigns.some((rule) => leafTermsOf(rule.detect).some((leaf) => leaf === "syntax" || leaf === "report" || leaf === "fn"));
59
- for (const hit of evaluateCampaigns(selectedCampaigns, {
60
- file,
61
- text,
62
- facts: factsOf(file),
63
- resolver: policy.resolver,
64
- fileSystem: policy.fileSystem,
65
- syntax: needsSyntax ? policy.syntax.parse(file, text) : null,
66
- functions: policy.functions,
67
- reports: policy.reports,
68
- })) {
69
- campaigns.push(hit);
70
- }
71
- }
72
57
  const selectedImports = rulesSelecting(policy.importRules, file);
73
58
  const selectedExports = exportRulesSelecting(policy.exportRules, file);
74
59
  const selectedMembers = memberRulesSelecting(policy.memberRules, file);
@@ -139,66 +124,6 @@ const report = (lines) => Effect.sync(() => {
139
124
  process.stdout.write(`${line}\n`);
140
125
  });
141
126
  const describe = (violation) => ` ${violation.file}\n ${formatMessage(violation)}`;
142
- // Each campaign's hits against its ledger. A campaign with no ledger has
143
- // every hit new; one with no hits and no ledger has nothing to say.
144
- const campaignReportsOf = (policy, hits) => policy.campaignRules.map((rule) => {
145
- const own = hits.filter((hit) => hit.campaign === rule.id).map((hit) => hit.violation);
146
- const ledger = policy.ledgers.get(rule.id);
147
- if (ledger === undefined) {
148
- return {
149
- id: rule.id,
150
- count: own.length,
151
- new: [...new Set(own.map(entryOf))].sort(),
152
- stale: [],
153
- drifted: 0,
154
- missingLedger: true,
155
- arithmetic: true,
156
- complete: own.length === 0,
157
- stalled: false,
158
- onComplete: rule.onComplete,
159
- };
160
- }
161
- const state = reconcile(ledger, own, rule.unit);
162
- return {
163
- id: rule.id,
164
- count: own.length,
165
- new: [...new Set(state.unrecorded.map(entryOf))].sort(),
166
- stale: state.stale,
167
- drifted: state.drifted.length,
168
- missingLedger: false,
169
- arithmetic: ledgerArithmeticHolds(ledger),
170
- complete: isComplete(ledger) && own.length === 0,
171
- stalled: isStalled(rule, ledger, policy.now),
172
- onComplete: rule.onComplete,
173
- };
174
- });
175
- // Whether a campaign hit is carried by its ledger — exactly, or by anchor.
176
- const ledgeredFilter = (policy, hits) => {
177
- const carried = new Set();
178
- for (const rule of policy.campaignRules) {
179
- const ledger = policy.ledgers.get(rule.id);
180
- if (ledger === undefined)
181
- continue;
182
- const own = hits.filter((hit) => hit.campaign === rule.id).map((hit) => hit.violation);
183
- for (const one of reconcile(ledger, own, rule.unit).ledgered)
184
- carried.add(one);
185
- }
186
- return (hit) => carried.has(hit.violation);
187
- };
188
- // Why a campaign report is not ok, in the order `check` explains it.
189
- const campaignFailuresOf = (campaigns) => [
190
- ...campaigns.filter((one) => one.stale.length > 0).map(() => "stale ledger entries"),
191
- ...campaigns.filter((one) => !one.arithmetic).map(() => "ledger arithmetic does not hold"),
192
- ...campaigns
193
- .filter((one) => one.missingLedger && one.count > 0)
194
- .map((one) => `campaign ${one.id} has no ledger`),
195
- ...campaigns
196
- .filter((one) => !one.missingLedger && one.new.length > 0)
197
- .map(() => "unrecorded campaign growth"),
198
- ...campaigns
199
- .filter((one) => one.complete && !one.missingLedger && one.onComplete === "remove")
200
- .map((one) => `campaign ${one.id} is complete and declared onComplete: remove`),
201
- ];
202
127
  const COVERAGE_FAMILIES = [
203
128
  "imports",
204
129
  "structure",
@@ -248,12 +173,17 @@ const reportOf = (policy, roots, manifestPath, findings) => {
248
173
  baselined: isBaselined(violation),
249
174
  ledgered: false,
250
175
  })),
251
- ...findings.campaigns.map((hit) => ({
176
+ // The hits that count: those whose objective is in window for the
177
+ // sector they fall in.
178
+ ...findings.campaigns.flatMap((evaluation) => hitsInWindow(evaluation).map((hit) => ({
252
179
  ...hit.violation,
253
180
  fingerprint: fingerprintOf(hit.violation),
254
181
  baselined: false,
255
182
  ledgered: isLedgered(hit),
256
- })),
183
+ objective: hit.objective,
184
+ sector: hit.sector,
185
+ entry: hit.entry,
186
+ }))),
257
187
  ];
258
188
  const campaigns = campaignReportsOf(policy, findings.campaigns);
259
189
  // The floors. A policy states how much of the tree it reaches, per
@@ -350,66 +280,20 @@ const describeExcess = (one) => {
350
280
  const [singular, plural] = MEASURE_NOUNS[one.measure];
351
281
  return ` ${one.measure}: ${count(one.count, singular, plural)}, ceiling ${String(one.ceiling)}`;
352
282
  };
353
- // A campaign hit, with the campaign's `how` as its instruction.
354
- const describeHit = (violation) => ` ${violation.file}${violation.subject === null ? "" : ` (${violation.subject})`}\n ${formatMessage(violation)}`;
355
- const renderCampaigns = (report) => {
356
- const hits = report.violations.filter((one) => one.kind === "campaign");
357
- return report.campaigns.flatMap((campaign) => {
358
- const rule = `campaign/${campaign.id}`;
359
- const fresh = new Set(campaign.new);
360
- const own = hits.filter((one) => one.ruleName === rule && fresh.has(entryOf(one)));
361
- if (campaign.missingLedger && campaign.count > 0) {
362
- return [
363
- "",
364
- `campaign ${campaign.id}: ${count(campaign.count, "hit")} and no ledger. Record them before they count as growth:`,
365
- "",
366
- ` architecture campaigns init ${campaign.id}`,
367
- ];
368
- }
369
- const complete = campaign.complete && !campaign.missingLedger;
370
- return [
371
- ...(campaign.new.length === 0
372
- ? []
373
- : [
374
- "",
375
- `campaign ${campaign.id}: ${count(campaign.new.length, "new hit")} the ledger does not carry. Fix them, or record why the count may rise:`,
376
- ...own.map(describeHit),
377
- "",
378
- ` architecture campaigns allow ${campaign.id} --reason "<why>"`,
379
- ]),
380
- ...(campaign.stale.length === 0
381
- ? []
382
- : [
383
- "",
384
- `campaign ${campaign.id}: ${count(campaign.stale.length, "ledger entry", "ledger entries")} no longer fire. The code was fixed; prune them:`,
385
- ...campaign.stale.map((entry) => ` ${entry}`),
386
- "",
387
- ` architecture campaigns prune ${campaign.id}`,
388
- ]),
389
- ...(campaign.arithmetic
390
- ? []
391
- : [
392
- "",
393
- `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\`.`,
394
- ]),
395
- ...(complete && campaign.onComplete === "remove"
396
- ? [
397
- "",
398
- `campaign ${campaign.id} is complete and declares onComplete: remove. Delete it from the manifest, and its ledger.`,
399
- ]
400
- : []),
401
- ...(campaign.stalled
402
- ? [
403
- "",
404
- `notice: campaign ${campaign.id} has stalled — no entry has left its ledger within its staleAfter.`,
405
- ]
406
- : []),
407
- ...(complete && campaign.onComplete === "keep"
408
- ? ["", `notice: campaign ${campaign.id} is complete, and stays as a guard.`]
409
- : []),
410
- ];
411
- });
412
- };
283
+ const renderCampaigns = (report) => renderCampaignReports(report.campaigns, report.violations.flatMap((one) => one.kind === "campaign" &&
284
+ one.objective !== undefined &&
285
+ one.sector !== undefined &&
286
+ one.entry !== undefined
287
+ ? [
288
+ {
289
+ violation: one,
290
+ objective: one.objective,
291
+ sector: one.sector,
292
+ entry: one.entry,
293
+ ledgered: one.ledgered,
294
+ },
295
+ ]
296
+ : []));
413
297
  const renderText = (report) => {
414
298
  const reportable = report.violations.filter((one) => one.kind !== "campaign" && !one.baselined);
415
299
  const carried = report.violations.filter((one) => one.kind !== "campaign").length - reportable.length;
@@ -484,41 +368,7 @@ export const snapshotOf = (policy, roots, manifestPath) => {
484
368
  const byHeight = heightOfViolation(left) - heightOfViolation(right);
485
369
  return byHeight !== 0 ? byHeight : left.fingerprint.localeCompare(right.fingerprint);
486
370
  });
487
- const campaigns = policy.campaignRules.map((rule) => {
488
- const ledger = policy.ledgers.get(rule.id);
489
- const own = findings.campaigns.filter((hit) => hit.campaign === rule.id).length;
490
- const base = {
491
- id: rule.id,
492
- ...(rule.title === null ? {} : { title: rule.title }),
493
- ...(rule.owner === null ? {} : { owner: rule.owner }),
494
- initial: own,
495
- allowed: 0,
496
- count: own,
497
- fixed: 0,
498
- progress: 0,
499
- lastProgress: new Date(policy.now).toISOString(),
500
- regressions: 0,
501
- stalled: false,
502
- complete: own === 0,
503
- onComplete: rule.onComplete,
504
- ledgered: false,
505
- };
506
- if (ledger === undefined)
507
- return base;
508
- return {
509
- ...base,
510
- initial: ledger.initial,
511
- allowed: ledger.regressions.reduce((sum, one) => sum + one.delta, 0),
512
- count: ledger.entries.length,
513
- fixed: ledger.fixed,
514
- progress: progressOf(ledger),
515
- lastProgress: ledger.lastProgress,
516
- regressions: ledger.regressions.length,
517
- stalled: isStalled(rule, ledger, policy.now),
518
- complete: isComplete(ledger),
519
- ledgered: true,
520
- };
521
- });
371
+ const campaigns = snapshotCampaignsOf(policy, findings.campaigns);
522
372
  return {
523
373
  version: SNAPSHOT_VERSION,
524
374
  manifest: report_.manifest,
@@ -540,20 +390,6 @@ export const snapshotOf = (policy, roots, manifestPath) => {
540
390
  campaigns,
541
391
  };
542
392
  };
543
- // The campaigns, stalled and complete first, then by progress.
544
- const renderCampaignRows = (campaigns) => {
545
- const width = Math.max(0, ...campaigns.map((one) => one.id.length));
546
- const state = (one) => !one.ledgered ? "no ledger" : one.complete ? "complete" : one.stalled ? "stalled" : "";
547
- const ordered = [...campaigns].sort((left, right) => {
548
- const rank = (one) => one.stalled ? 0 : one.complete && one.ledgered ? 1 : 2;
549
- const byRank = rank(left) - rank(right);
550
- return byRank !== 0 ? byRank : left.progress - right.progress;
551
- });
552
- return ordered.map((one) => ` ${one.id.padEnd(width)} ${percent(one.progress).padStart(4)} ${String(one.count).padStart(5)} left` +
553
- ` ${String(one.fixed)} fixed ${String(one.allowed)} allowed` +
554
- (one.owner === undefined ? "" : ` ${one.owner}`) +
555
- (state(one) === "" ? "" : ` ${state(one)}`));
556
- };
557
393
  const renderSnapshot = (snapshot) => {
558
394
  const reportable = snapshot.violations.filter((one) => !one.baselined && !one.ledgered);
559
395
  const carried = snapshot.violations.length - reportable.length;
@@ -687,7 +523,7 @@ export const writeBaseline = (policy, roots) => Effect.gen(function* () {
687
523
  });
688
524
  // The question a tree config makes harder to answer than a flat one: given a
689
525
  // file, what governs it? A flat config you grep; a tree you have to walk.
690
- export const explain = (policy, file) => Effect.gen(function* () {
526
+ export const explain = (policy, file, roots = ["packages"]) => Effect.gen(function* () {
691
527
  const relative = path.relative(policy.repoRoot, path.resolve(policy.repoRoot, file));
692
528
  const selected = rulesSelecting(policy.importRules, relative);
693
529
  // An allowlist rule names no `to` — it fires when the target matches none of
@@ -719,10 +555,15 @@ export const explain = (policy, file) => Effect.gen(function* () {
719
555
  .map((rule) => `${named(rule)} (reach)`),
720
556
  ];
721
557
  const section = (title, lines) => lines.length === 0 ? [] : ["", title, ...lines];
722
- // Each campaign selecting the file, with its truth table: one line per
723
- // leaf term and what it answered here, so a detector that "should fire"
724
- // and does not shows which term is not saying what its author thinks.
725
- const selectedCampaigns = campaignsSelecting(policy.campaignRules, relative);
558
+ // Each campaign selecting the file: the sector the file is in, its
559
+ // phase and definedness, the objectives in window that fire on it and
560
+ // the nearest remaining holdouts — which needs the campaign evaluated
561
+ // over its files, since a sector's phase is derived from all of them —
562
+ // then each objective's truth table: one line per leaf term and what
563
+ // it answered here, so a detector that "should fire" and does not shows
564
+ // which term is not saying what its author thinks.
565
+ const selectedCampaigns = campaignsSelecting(campaignsOf(policy).campaignRules, relative);
566
+ const evaluations = selectedCampaigns.length === 0 ? [] : collectFindings(policy, roots).campaigns;
726
567
  const campaignLines = selectedCampaigns.flatMap((rule) => {
727
568
  const at = path.join(policy.repoRoot, relative);
728
569
  const text = existsSync(at) ? readFileSync(at, "utf8") : "";
@@ -733,13 +574,22 @@ export const explain = (policy, file) => Effect.gen(function* () {
733
574
  resolver: policy.resolver,
734
575
  fileSystem: policy.fileSystem,
735
576
  syntax: policy.syntax.parse(relative, text),
736
- functions: policy.functions,
737
- reports: policy.reports,
577
+ functions: campaignsOf(policy).functions,
578
+ reports: campaignsOf(policy).reports,
738
579
  };
739
- const hits = evaluateCampaigns([rule], input);
580
+ const evaluation = evaluations.find((one) => one.rule.id === rule.id);
740
581
  return [
741
- ` ${rule.name} — ${firstSentence(rule.why)} (${rule.unit}; ${hits.length === 0 ? "no hit" : count(hits.length, "hit")})`,
742
- ...explainCampaign(rule, input).map((line) => ` ${line.answer ? "✓" : "✗"} ${line.term}${line.count === undefined ? "" : ` (${String(line.count)})`}`),
582
+ ...(evaluation === undefined ? [] : explainCampaignLines(policy, evaluation, relative)),
583
+ ...rule.objectives.flatMap((objective) => {
584
+ const table = explainObjective(objective, input);
585
+ if (table.length === 0)
586
+ return [];
587
+ const fired = table.length > 0 && table.every((line) => line.answer);
588
+ return [
589
+ ` ${objective.name} — ${firstSentence(objective.why ?? objective.message)} (${objective.holdout}; ${fired ? "fires here" : "no hit"})`,
590
+ ...table.map((line) => ` ${line.answer ? "✓" : "✗"} ${line.term}${line.count === undefined ? "" : ` (${String(line.count)})`}`),
591
+ ];
592
+ }),
743
593
  ];
744
594
  });
745
595
  yield* report([
@@ -858,18 +708,23 @@ limits:
858
708
  unrestricted: 0
859
709
  partial: 0
860
710
 
861
- # Migrations the repository is running, each with a detector, a rationale, a
862
- # guide, an owner and a ledger of every place the pattern still occurs. Fill
863
- # one in, then \`architecture campaigns init <id>\` to write its ledger.
711
+ # Refactors the repository is running, each an object: objectives (a
712
+ # detector with a ledger under \`.architecture-campaigns/\` of every place the
713
+ # pattern still occurs), over sectors the code births through a perimeter,
714
+ # through phases toward an end. Fill one in, then
715
+ # \`architecture objectives clear <id>\` to write its ledgers.
864
716
  # https://dataquail.github.io/goodbones/architecture-rules/manifest/campaigns/
865
717
  # campaigns:
866
- # - id: js-to-ts
718
+ # js-to-ts:
867
719
  # why: The strict tsconfig cannot land while any src file is JavaScript.
868
720
  # how: Rename to .ts, add types at the module boundary, leave the body alone.
869
- # scope: ["src/**"]
870
- # unit: file
871
- # detect: { path: { file: "\\.(js|jsx)$" } }
872
- # probes: { fires: [{ path: src/legacy/util.js }], ignores: [{ path: src/util.ts }] }
721
+ # scope: { path: "src/**", extensions: [.js, .jsx] }
722
+ # perimeter: file
723
+ # objectives:
724
+ # is-ts:
725
+ # holdout: file
726
+ # match: { path: { file: "\\\\.(js|jsx)$" } }
727
+ # probes: { fires: [{ path: src/legacy/util.js }], ignores: [{ path: src/util.ts }] }
873
728
  # staleAfter: 14d
874
729
  # onComplete: remove
875
730
 
@@ -941,188 +796,361 @@ export const migrate = (repoRoot, configFilename) => Effect.gen(function* () {
941
796
  `Then delete ${path.basename(from)}: a repository with two manifests is refused.`,
942
797
  ]);
943
798
  });
944
- // The ledgers. `campaigns` alone is the status table; `init` writes a
945
- // campaign's first ledger from what fires today; `prune` removes what no
946
- // longer fires; `allow` is the one way an entry is added, and it records why.
947
- const ledgerPathOf = (policy, id) => path.resolve(policy.repoRoot, policy.ledgerDir, `${id}.json`);
948
- const writeLedger = (policy, ledger) => {
949
- const at = ledgerPathOf(policy, ledger.id);
950
- mkdirSync(path.dirname(at), { recursive: true });
951
- writeFileSync(at, serializeLedger(ledger));
952
- };
953
- const campaignNamed = (policy, id) => policy.campaignRules.find((rule) => rule.id === id) ?? null;
954
- // The author of a regression: `--by`, else git's user.email, else the
955
- // GIT_AUTHOR_EMAIL the environment carries. Without one the record is refused
956
- // rather than written blank, since the record is the point.
957
- const authorOf = (given) => {
958
- if (given !== undefined && given !== "")
959
- return given;
960
- try {
961
- const email = execFileSync("git", ["config", "user.email"], {
962
- encoding: "utf8",
963
- stdio: ["ignore", "pipe", "ignore"],
964
- }).trim();
965
- if (email !== "")
966
- return email;
967
- }
968
- catch {
969
- // git absent, or no email configured
970
- }
971
- const fromEnvironment = process.env.GIT_AUTHOR_EMAIL;
972
- return fromEnvironment === undefined || fromEnvironment === "" ? null : fromEnvironment;
973
- };
799
+ // The campaign commands. `campaigns` alone is the status table; `status
800
+ // --changed` is the nudge; `attest` and `note` write a sector's record;
801
+ // `history` replays the ledgers' git history. The ledgers themselves are
802
+ // written by `objectives clear` (the ledger reconciled with the code
803
+ // wherever that is not a regression) and `objectives concede` (the one way
804
+ // a holdout is added by hand, with a reason).
974
805
  const flagOf = (argv, flag) => {
975
806
  const at = argv.indexOf(flag);
976
807
  const value = at === -1 ? undefined : argv[at + 1];
977
808
  return value === undefined || value.startsWith("--") ? undefined : value;
978
809
  };
979
- const CAMPAIGN_SUBCOMMANDS = ["init", "prune", "allow"];
980
- const CAMPAIGN_VALUE_FLAGS = ["--reason", "--by", "--entries"];
981
- // `campaigns [init <id> | prune [<id>] | allow <id> --reason <text>] [roots…]`:
982
- // the subcommand and its id come first; whatever positional is left names
983
- // the roots to walk, as it does for every other command.
984
- export const campaignArgsOf = (argv, ids) => {
810
+ const CAMPAIGN_SUBCOMMANDS = ["status", "attest", "note", "history", "clear", "concede"];
811
+ const OBJECTIVE_SUBCOMMANDS = ["clear", "concede"];
812
+ const VALUE_FLAGS = [
813
+ "--reason",
814
+ "--by",
815
+ "--holdouts",
816
+ "--entries",
817
+ "--sector",
818
+ "--campaign",
819
+ "--evidence",
820
+ "--base",
821
+ "--since",
822
+ "--hotfix",
823
+ ];
824
+ // The verbs the family shipped with, refused by name: each has a new name
825
+ // or no place.
826
+ const RETIRED = {
827
+ init: "`campaigns init` is gone: `objectives clear <campaign>` writes a first ledger, recording each sector's initial.",
828
+ prune: "`campaigns prune` is now `objectives clear`.",
829
+ allow: "`campaigns allow` is now `objectives concede`.",
830
+ };
831
+ // The positionals after the subcommand, with the value flags and their
832
+ // values stepped over: whatever is left names the roots to walk, as it
833
+ // does for every other command.
834
+ const positionalsOf = (argv) => {
985
835
  const positional = [];
986
836
  for (let at = 0; at < argv.length; at += 1) {
987
837
  const one = argv[at] ?? "";
988
- if (CAMPAIGN_VALUE_FLAGS.includes(one)) {
838
+ if (VALUE_FLAGS.includes(one)) {
989
839
  at += 1;
990
840
  continue;
991
841
  }
992
842
  if (!one.startsWith("--"))
993
843
  positional.push(one);
994
844
  }
845
+ return positional;
846
+ };
847
+ // `campaigns [status | attest <sector> <phase> | note <sector> "<text>" |
848
+ // history [<campaign>]] [roots…]`: the subcommand and its arguments come
849
+ // first.
850
+ export const campaignArgsOf = (argv, ids) => {
851
+ const positional = positionalsOf(argv);
852
+ const [first, ...rest] = positional;
853
+ if (first === undefined)
854
+ return { subcommand: undefined, args: [], roots: [] };
855
+ if (first in RETIRED)
856
+ return { subcommand: first, args: [], roots: rest };
857
+ if (!CAMPAIGN_SUBCOMMANDS.includes(first)) {
858
+ return { subcommand: undefined, args: [], roots: positional };
859
+ }
860
+ switch (first) {
861
+ case "attest":
862
+ return { subcommand: first, args: rest.slice(0, 2), roots: rest.slice(2) };
863
+ case "note":
864
+ return { subcommand: first, args: rest.slice(0, 2), roots: rest.slice(2) };
865
+ case "history": {
866
+ const [second] = rest;
867
+ return second !== undefined && ids.includes(second)
868
+ ? { subcommand: first, args: [second], roots: rest.slice(1) }
869
+ : { subcommand: first, args: [], roots: rest };
870
+ }
871
+ case "clear":
872
+ case "concede": {
873
+ const [second] = rest;
874
+ return second !== undefined && ids.includes(second.split("/")[0] ?? "")
875
+ ? { subcommand: first, args: [second], roots: rest.slice(1) }
876
+ : { subcommand: first, args: [], roots: rest };
877
+ }
878
+ default:
879
+ return { subcommand: first, args: [], roots: rest };
880
+ }
881
+ };
882
+ // `objectives [clear [<campaign>[/<objective>]] | concede <campaign>[/<objective>]
883
+ // --reason <text>] [roots…]`.
884
+ export const objectiveArgsOf = (argv, ids) => {
885
+ const positional = positionalsOf(argv);
995
886
  const [first, second, ...rest] = positional;
996
- if (first === undefined || !CAMPAIGN_SUBCOMMANDS.includes(first)) {
997
- return { subcommand: undefined, id: undefined, roots: positional };
887
+ if (first === undefined || !OBJECTIVE_SUBCOMMANDS.includes(first)) {
888
+ return { subcommand: first, target: null, roots: positional.slice(1) };
998
889
  }
999
- // `prune` takes an optional id; the next word is one only if a campaign
1000
- // has that name, else it is a root.
1001
- const takesId = first !== "prune" || (second !== undefined && ids.includes(second));
1002
- return takesId
1003
- ? { subcommand: first, id: second, roots: rest }
1004
- : { subcommand: first, id: undefined, roots: second === undefined ? [] : [second, ...rest] };
890
+ const [campaign = "", objective] = (second ?? "").split("/");
891
+ const named = second !== undefined && ids.includes(campaign);
892
+ return {
893
+ subcommand: first,
894
+ target: named ? { campaign, objective: objective ?? null } : null,
895
+ roots: named ? rest : second === undefined ? [] : [second, ...rest],
896
+ };
1005
897
  };
1006
- export const campaigns = (policy, defaultRoots, argv) => Effect.gen(function* () {
1007
- const parsed = campaignArgsOf(argv, policy.campaignRules.map((rule) => rule.id));
1008
- const { id, subcommand } = parsed;
898
+ // The one campaign, when there is one, else the one `--campaign` names.
899
+ const campaignFor = (policy, argv) => {
900
+ const named = flagOf(argv, "--campaign");
901
+ if (named !== undefined) {
902
+ const found = campaignsOf(policy).campaignRules.find((rule) => rule.id === named);
903
+ return found === undefined
904
+ ? Result.fail(`no campaign is named "${named}"`)
905
+ : Result.succeed(found);
906
+ }
907
+ const [only] = campaignsOf(policy).campaignRules;
908
+ if (campaignsOf(policy).campaignRules.length === 1 && only !== undefined)
909
+ return Result.succeed(only);
910
+ return Result.fail(`this policy declares ${String(campaignsOf(policy).campaignRules.length)} campaigns; say which with --campaign <id>.`);
911
+ };
912
+ const objectiveFor = (rule, objective) => {
913
+ if (objective !== null) {
914
+ return rule.objectives.some((one) => one.id === objective)
915
+ ? Result.succeed(objective)
916
+ : Result.fail(`no objective of ${rule.id} is named "${objective}"`);
917
+ }
918
+ const [only] = rule.objectives;
919
+ if (rule.objectives.length === 1 && only !== undefined)
920
+ return Result.succeed(only.id);
921
+ return Result.fail(`campaign ${rule.id} declares ${String(rule.objectives.length)} objectives; say which as ${rule.id}/<objective>.`);
922
+ };
923
+ export const objectives = (policy, defaultRoots, argv) => Effect.gen(function* () {
924
+ const parsed = objectiveArgsOf(argv, campaignsOf(policy).campaignRules.map((rule) => rule.id));
1009
925
  const roots = parsed.roots.length > 0 ? parsed.roots : defaultRoots;
1010
- if (policy.campaignRules.length === 0) {
926
+ if (campaignsOf(policy).campaignRules.length === 0) {
1011
927
  return yield* report(["this policy declares no campaigns."]);
1012
928
  }
1013
- const hitsOf = () => collectFindings(policy, roots).campaigns;
1014
- const own = (hits, campaign) => hits.filter((hit) => hit.campaign === campaign).map((hit) => hit.violation);
1015
- switch (subcommand) {
1016
- case undefined: {
1017
- const snapshot = snapshotOf(policy, roots, manifestPathOf(policy.repoRoot));
1018
- return yield* report([
1019
- `${count(snapshot.campaigns.length, "campaign")} under ${roots.join(", ")}`,
1020
- "",
1021
- ...renderCampaignRows(snapshot.campaigns),
1022
- "",
1023
- " architecture campaigns init <id> # write a ledger from what fires today",
1024
- " architecture campaigns prune [<id>] # drop entries that no longer fire",
1025
- ' architecture campaigns allow <id> --reason "<why>" # record why the count may rise',
1026
- ]);
1027
- }
1028
- case "init": {
1029
- if (id === undefined)
1030
- return yield* Effect.fail(fail("campaigns init needs a campaign id"));
1031
- const rule = campaignNamed(policy, id);
1032
- if (rule === null)
1033
- return yield* Effect.fail(fail(`no campaign is named "${id}"`));
1034
- if (policy.ledgers.has(id)) {
1035
- return yield* Effect.fail(fail(`${path.relative(policy.repoRoot, ledgerPathOf(policy, id))} already exists. \`init\` ` +
1036
- `writes a campaign's first ledger and does not overwrite one; \`prune\` and \`allow\` ` +
1037
- `are how it changes.`));
1038
- }
1039
- const ledger = ledgerOf(id, own(hitsOf(), id), policy.now);
1040
- yield* Effect.sync(() => {
1041
- writeLedger(policy, ledger);
1042
- });
1043
- return yield* report([
1044
- `${count(ledger.entries.length, "hit")} recorded in ${path.relative(policy.repoRoot, ledgerPathOf(policy, id))}.`,
1045
- "Each one is a place the campaign has yet to reach. Fixing one means pruning its line.",
1046
- ]);
1047
- }
1048
- case "prune": {
1049
- const targets = id === undefined
1050
- ? policy.campaignRules
1051
- : [campaignNamed(policy, id)].filter((one) => one !== null);
1052
- if (id !== undefined && targets.length === 0) {
1053
- return yield* Effect.fail(fail(`no campaign is named "${id}"`));
1054
- }
1055
- const hits = hitsOf();
929
+ const evaluations = () => collectFindings(policy, roots).campaigns;
930
+ switch (parsed.subcommand) {
931
+ case "clear": {
932
+ const targets = parsed.target === null
933
+ ? campaignsOf(policy).campaignRules
934
+ : campaignsOf(policy).campaignRules.filter((rule) => rule.id === parsed.target?.campaign);
935
+ const by = authorOf(flagOf(argv, "--by")) ?? "unknown";
936
+ const all = evaluations();
1056
937
  const lines = [];
1057
938
  for (const rule of targets) {
1058
- const ledger = policy.ledgers.get(rule.id);
1059
- if (ledger === undefined) {
1060
- lines.push(`${rule.id}: no ledger to prune (run \`campaigns init ${rule.id}\`).`);
1061
- continue;
1062
- }
1063
- const next = pruned(ledger, own(hits, rule.id), rule.unit, policy.now);
1064
- const removed = ledger.entries.length - next.entries.length;
1065
- const rewritten = next.entries.filter((entry) => !ledger.entries.includes(entry)).length;
1066
- if (removed === 0 && rewritten === 0) {
1067
- lines.push(`${rule.id}: nothing to prune.`);
939
+ const evaluation = all.find((one) => one.rule.id === rule.id);
940
+ if (evaluation === undefined)
1068
941
  continue;
942
+ const only = parsed.target?.objective ?? null;
943
+ if (only !== null && !rule.objectives.some((one) => one.id === only)) {
944
+ return yield* Effect.fail(fail(`no objective of ${rule.id} is named "${only}"`));
1069
945
  }
1070
- yield* Effect.sync(() => {
1071
- writeLedger(policy, next);
946
+ const outcomes = yield* Effect.try({
947
+ try: () => clear(policy, evaluation, only, by),
948
+ catch: (cause) => fail(String(cause)),
1072
949
  });
1073
- lines.push(`${rule.id}: ${count(removed, "entry", "entries")} pruned` +
1074
- (rewritten > 0 ? `, ${count(rewritten, "entry", "entries")} rewritten` : "") +
1075
- `; ${count(next.entries.length, "entry", "entries")} left.`);
950
+ for (const outcome of outcomes) {
951
+ const parts = [
952
+ ...(outcome.entered.length > 0
953
+ ? [
954
+ `${count(outcome.entered.length, "sector")} entered (${outcome.entered.join(", ")})`,
955
+ ]
956
+ : []),
957
+ ...(outcome.cleared > 0 ? [`${count(outcome.cleared, "holdout")} cleared`] : []),
958
+ ...(outcome.rewritten > 0
959
+ ? [`${count(outcome.rewritten, "holdout")} rewritten`]
960
+ : []),
961
+ ...(outcome.closed > 0 ? [`${count(outcome.closed, "holdout")} closed`] : []),
962
+ ...(outcome.rebaselined.length > 0
963
+ ? [
964
+ `${count(outcome.rebaselined.length, "sector")} re-baselined (${outcome.rebaselined.join(", ")})`,
965
+ ]
966
+ : []),
967
+ ];
968
+ lines.push(`${outcome.campaign}/${outcome.objective}: ${parts.length === 0 ? "nothing to clear" : parts.join(", ")}; ${count(outcome.left, "holdout")} left.`);
969
+ }
1076
970
  }
1077
971
  return yield* report(lines);
1078
972
  }
1079
- case "allow": {
1080
- if (id === undefined)
1081
- return yield* Effect.fail(fail("campaigns allow needs a campaign id"));
1082
- const rule = campaignNamed(policy, id);
1083
- if (rule === null)
1084
- return yield* Effect.fail(fail(`no campaign is named "${id}"`));
1085
- const ledger = policy.ledgers.get(id);
1086
- if (ledger === undefined) {
1087
- return yield* Effect.fail(fail(`campaign ${id} has no ledger yet; run \`campaigns init ${id}\` first.`));
973
+ case "concede": {
974
+ if (parsed.target === null) {
975
+ return yield* Effect.fail(fail("objectives concede needs a campaign: `objectives concede <campaign>[/<objective>] --reason <text>`"));
1088
976
  }
977
+ const rule = campaignsOf(policy).campaignRules.find((one) => one.id === parsed.target?.campaign);
978
+ if (rule === undefined)
979
+ return yield* Effect.fail(fail(`no campaign is named "${parsed.target.campaign}"`));
980
+ const objective = objectiveFor(rule, parsed.target.objective);
981
+ if (Result.isFailure(objective))
982
+ return yield* Effect.fail(fail(objective.failure));
1089
983
  const reason = flagOf(argv, "--reason");
1090
984
  if (reason === undefined) {
1091
- return yield* Effect.fail(fail("campaigns allow needs --reason <text>: growth is recorded with why, or not at all."));
985
+ return yield* Effect.fail(fail("objectives concede needs --reason <text>: growth is recorded with why, or not at all."));
1092
986
  }
1093
987
  const by = authorOf(flagOf(argv, "--by"));
1094
988
  if (by === null) {
1095
- return yield* Effect.fail(fail("campaigns allow needs an author: pass --by <email>, or set git's user.email."));
989
+ return yield* Effect.fail(fail("objectives concede needs an author: pass --by <email>, or set git's user.email."));
1096
990
  }
1097
- const state = reconcile(ledger, own(hitsOf(), id), rule.unit);
1098
- const unrecorded = [...new Set(state.unrecorded.map(entryOf))].sort();
1099
- // `--entries` allows a subset and refuses the rest: a pull request
1100
- // that legitimately adds one hit while another is an accident.
1101
- const chosen = flagOf(argv, "--entries")
991
+ const evaluation = evaluations().find((one) => one.rule.id === rule.id);
992
+ if (evaluation === undefined)
993
+ return yield* Effect.fail(fail(`campaign ${rule.id} was not evaluated`));
994
+ // `--holdouts` concedes a subset and refuses the rest: a pull
995
+ // request that legitimately adds one hit while another is an
996
+ // accident.
997
+ const chosen = (flagOf(argv, "--holdouts") ?? flagOf(argv, "--entries"))
1102
998
  ?.split(",")
1103
- .map((one) => one.trim()) ?? unrecorded;
1104
- const unknown = chosen.filter((entry) => !unrecorded.includes(entry));
1105
- if (unknown.length > 0) {
1106
- return yield* Effect.fail(fail(`these entries are not unrecorded hits of ${id}: ${unknown.join(", ")}`));
999
+ .map((one) => one.trim()) ?? null;
1000
+ const outcome = concede(policy, evaluation, objective.success, chosen, flagOf(argv, "--sector") ?? null, {
1001
+ at: policy.now,
1002
+ by,
1003
+ reason,
1004
+ });
1005
+ if (Result.isFailure(outcome))
1006
+ return yield* Effect.fail(fail(outcome.failure));
1007
+ if (outcome.success.conceded.length === 0) {
1008
+ return yield* report([
1009
+ `${rule.id}/${objective.success}: nothing to concede; every hit is in the ledger.`,
1010
+ ]);
1011
+ }
1012
+ return yield* report([
1013
+ `${rule.id}/${objective.success}: ${count(outcome.success.conceded.length, "holdout")} conceded, recorded by ${by}.`,
1014
+ ...outcome.success.conceded.map((one) => ` ${one.sector} · ${one.entry}`),
1015
+ ...(outcome.success.left.length === 0
1016
+ ? []
1017
+ : [
1018
+ "",
1019
+ `${count(outcome.success.left.length, "hit")} left unrecorded; check still fails on them.`,
1020
+ ]),
1021
+ ]);
1022
+ }
1023
+ default:
1024
+ return yield* Effect.fail(fail(`unknown objectives subcommand "${parsed.subcommand ?? ""}". Try: objectives clear [<campaign>[/<objective>]] | objectives concede <campaign>[/<objective>] --reason <text> [--by <email>] [--sector <name>] [--holdouts a,b]`));
1025
+ }
1026
+ });
1027
+ export const campaigns = (policy, defaultRoots, argv, configFilename) => Effect.gen(function* () {
1028
+ const parsed = campaignArgsOf(argv, campaignsOf(policy).campaignRules.map((rule) => rule.id));
1029
+ const roots = parsed.roots.length > 0 ? parsed.roots : defaultRoots;
1030
+ if (campaignsOf(policy).campaignRules.length === 0) {
1031
+ return yield* report(["this policy declares no campaigns."]);
1032
+ }
1033
+ const json = argv.includes("--json");
1034
+ const retired = parsed.subcommand === undefined ? undefined : RETIRED[parsed.subcommand];
1035
+ if (retired !== undefined)
1036
+ return yield* Effect.fail(fail(retired));
1037
+ switch (parsed.subcommand) {
1038
+ case undefined: {
1039
+ const snapshot = snapshotOf(policy, roots, manifestPathOf(policy.repoRoot, configFilename));
1040
+ return yield* report([
1041
+ `${count(snapshot.campaigns.length, "campaign")} under ${roots.join(", ")}`,
1042
+ "",
1043
+ ...renderCampaignRows(snapshot.campaigns),
1044
+ "",
1045
+ " architecture campaigns status --changed [--base <ref>] [--json] # what a diff touches, and what to do",
1046
+ " architecture objectives clear [<campaign>[/<objective>]] # reconcile the ledgers with the code",
1047
+ ' architecture objectives concede <campaign>[/<objective>] --reason "<why>" # record why a count may rise',
1048
+ ' architecture campaigns attest <sector> <phase> --reason "<why>" [--evidence <url>]',
1049
+ ' architecture campaigns note <sector> "<text>"',
1050
+ " architecture campaigns history [<campaign>] [--since <ref>]",
1051
+ ]);
1052
+ }
1053
+ case "status": {
1054
+ if (!argv.includes("--changed")) {
1055
+ return yield* Effect.fail(fail("campaigns status takes --changed: the nudge is scoped to a diff."));
1107
1056
  }
1108
- if (chosen.length === 0) {
1109
- return yield* report([`${id}: nothing to allow; every hit is in the ledger.`]);
1057
+ const base = flagOf(argv, "--base") ?? null;
1058
+ const diff = yield* Effect.try({
1059
+ try: () => readDiff(policy.repoRoot, base),
1060
+ catch: (cause) => fail(`could not read the diff: ${String(cause)}`),
1061
+ });
1062
+ const current = collectFindings(policy, roots).campaigns;
1063
+ const baseSide = base === null
1064
+ ? null
1065
+ : yield* Effect.tryPromise({
1066
+ try: () => baseSideAt(policy, base, roots, (repoRoot) => loadPolicyFromFile(repoRoot, configFilename)),
1067
+ catch: (cause) => fail(`could not evaluate the base tree at ${base}: ${String(cause)}`),
1068
+ });
1069
+ const hotfix = flagOf(argv, "--hotfix") ?? null;
1070
+ const nudge = nudgeOf(policy, current, diff, baseSide, hotfix, hotfix === null ? null : authorOf(flagOf(argv, "--by")));
1071
+ yield* report(json ? [JSON.stringify(nudge, null, 2)] : renderNudge(nudge, policy.now));
1072
+ if (!nudge.ok)
1073
+ return yield* Effect.fail(fail("the diff sends a sector back under its onTouch"));
1074
+ return;
1075
+ }
1076
+ case "attest": {
1077
+ const [sector, phase] = parsed.args;
1078
+ if (sector === undefined || phase === undefined) {
1079
+ return yield* Effect.fail(fail('campaigns attest needs a sector and a phase: `campaigns attest <sector> <phase> --reason "<why>"`'));
1110
1080
  }
1111
- const next = allowed(ledger, chosen, { at: policy.now, by, reason });
1112
- yield* Effect.sync(() => {
1113
- writeLedger(policy, next);
1081
+ const reason = flagOf(argv, "--reason");
1082
+ if (reason === undefined)
1083
+ return yield* Effect.fail(fail("campaigns attest needs --reason <text>."));
1084
+ const by = authorOf(flagOf(argv, "--by"));
1085
+ if (by === null)
1086
+ return yield* Effect.fail(fail("campaigns attest needs an author: pass --by <email>, or set git's user.email."));
1087
+ const rule = campaignFor(policy, argv);
1088
+ if (Result.isFailure(rule))
1089
+ return yield* Effect.fail(fail(rule.failure));
1090
+ const evaluation = collectFindings(policy, roots).campaigns.find((one) => one.rule.id === rule.success.id);
1091
+ if (evaluation === undefined)
1092
+ return yield* Effect.fail(fail(`campaign ${rule.success.id} was not evaluated`));
1093
+ const written = attest(policy, evaluation, sector, phase, {
1094
+ reason,
1095
+ evidence: flagOf(argv, "--evidence"),
1096
+ by,
1114
1097
  });
1115
- const left = unrecorded.filter((entry) => !chosen.includes(entry));
1098
+ if (Result.isFailure(written))
1099
+ return yield* Effect.fail(fail(written.failure));
1116
1100
  return yield* report([
1117
- `${id}: ${count(chosen.length, "entry", "entries")} allowed, recorded as a regression by ${by}.`,
1118
- ...chosen.map((entry) => ` ${entry}`),
1119
- ...(left.length === 0
1120
- ? []
1121
- : ["", `${count(left.length, "hit")} left unrecorded; check still fails on them.`]),
1101
+ `${rule.success.id}: sector ${sector} attested at ${phase} by ${by}, in ${written.success}.`,
1102
+ "Run `objectives clear` to move it on.",
1122
1103
  ]);
1123
1104
  }
1105
+ case "note": {
1106
+ const [sector, text] = parsed.args;
1107
+ if (sector === undefined || text === undefined) {
1108
+ return yield* Effect.fail(fail('campaigns note needs a sector and a text: `campaigns note <sector> "<text>"`'));
1109
+ }
1110
+ const by = authorOf(flagOf(argv, "--by"));
1111
+ if (by === null)
1112
+ return yield* Effect.fail(fail("campaigns note needs an author: pass --by <email>, or set git's user.email."));
1113
+ const rule = campaignFor(policy, argv);
1114
+ if (Result.isFailure(rule))
1115
+ return yield* Effect.fail(fail(rule.failure));
1116
+ const evaluation = collectFindings(policy, roots).campaigns.find((one) => one.rule.id === rule.success.id);
1117
+ if (evaluation === undefined)
1118
+ return yield* Effect.fail(fail(`campaign ${rule.success.id} was not evaluated`));
1119
+ const written = note(policy, evaluation, sector, text, by);
1120
+ if (Result.isFailure(written))
1121
+ return yield* Effect.fail(fail(written.failure));
1122
+ return yield* report([
1123
+ `${rule.success.id}: note left on ${sector}, in ${written.success}.`,
1124
+ ]);
1125
+ }
1126
+ case "history": {
1127
+ const [named] = parsed.args;
1128
+ const targets = named === undefined
1129
+ ? campaignsOf(policy).campaignRules
1130
+ : campaignsOf(policy).campaignRules.filter((rule) => rule.id === named);
1131
+ const manifestPath = path
1132
+ .relative(policy.repoRoot, manifestPathOf(policy.repoRoot, configFilename))
1133
+ .replaceAll(path.sep, "/");
1134
+ const lines = [];
1135
+ for (const rule of targets) {
1136
+ const rows = historyOf(policy, rule, flagOf(argv, "--since") ?? null, [manifestPath]);
1137
+ if (json) {
1138
+ lines.push(JSON.stringify({ campaign: rule.id, rows }, null, 2));
1139
+ continue;
1140
+ }
1141
+ if (lines.length > 0)
1142
+ lines.push("");
1143
+ for (const line of renderHistory(rule, rows))
1144
+ lines.push(line);
1145
+ }
1146
+ return yield* report(lines);
1147
+ }
1148
+ case "clear":
1149
+ case "concede":
1150
+ // The ledger verbs answer under `objectives`; accepted here too.
1151
+ return yield* objectives(policy, defaultRoots, argv);
1124
1152
  default:
1125
- return yield* Effect.fail(fail(`unknown campaigns subcommand "${subcommand}". Try: campaigns | campaigns init <id> | campaigns prune [<id>] | campaigns allow <id> --reason <text> [--by <email>] [--entries a,b]`));
1153
+ return yield* Effect.fail(fail(`unknown campaigns subcommand "${parsed.subcommand}". Try: campaigns | campaigns status --changed | campaigns attest <sector> <phase> --reason <text> | campaigns note <sector> "<text>" | campaigns history [<campaign>]`));
1126
1154
  }
1127
1155
  });
1128
1156
  // The commands that judge a campaign, and so ask its report source.
@@ -1131,6 +1159,7 @@ const READS_REPORTS = new Set([
1131
1159
  "conformance",
1132
1160
  "baseline",
1133
1161
  "campaigns",
1162
+ "objectives",
1134
1163
  "explain",
1135
1164
  ]);
1136
1165
  export const run = (repoRoot, argv,
@@ -1173,7 +1202,7 @@ configFilename) => Effect.gen(function* () {
1173
1202
  // for the campaign to report; a report that does not parse is refused.
1174
1203
  if (READS_REPORTS.has(command)) {
1175
1204
  yield* Effect.tryPromise({
1176
- try: () => Promise.all(reportSpecsOf(policy.campaignRules).map((spec) => policy.reports.read?.(spec))),
1205
+ try: () => Promise.all(reportSpecsOf(campaignsOf(policy).campaignRules).map((spec) => campaignsOf(policy).reports.read?.(spec))),
1177
1206
  catch: (cause) => fail(String(cause)),
1178
1207
  });
1179
1208
  }
@@ -1184,7 +1213,9 @@ configFilename) => Effect.gen(function* () {
1184
1213
  const roots = positional.length > 0 ? positional : ["packages"];
1185
1214
  switch (command) {
1186
1215
  case "campaigns":
1187
- return yield* campaigns(policy, ["packages"], rest);
1216
+ return yield* campaigns(policy, ["packages"], rest, configFilename);
1217
+ case "objectives":
1218
+ return yield* objectives(policy, ["packages"], rest);
1188
1219
  case "check":
1189
1220
  return yield* check(policy, roots, {
1190
1221
  format: json ? "json" : "text",
@@ -1198,10 +1229,10 @@ configFilename) => Effect.gen(function* () {
1198
1229
  case "baseline":
1199
1230
  return yield* writeBaseline(policy, roots);
1200
1231
  case "explain": {
1201
- const [file] = rest;
1232
+ const [file, ...explainRoots] = positional;
1202
1233
  if (file === undefined)
1203
1234
  return yield* Effect.fail(fail("explain needs a file path"));
1204
- return yield* explain(policy, file);
1235
+ return yield* explain(policy, file, explainRoots.length > 0 ? explainRoots : ["packages"]);
1205
1236
  }
1206
1237
  case "coverage":
1207
1238
  return yield* coverage(policy, roots);
@@ -1212,7 +1243,7 @@ configFilename) => Effect.gen(function* () {
1212
1243
  return yield* facts(policy, file, json ? "json" : "text");
1213
1244
  }
1214
1245
  default:
1215
- return yield* Effect.fail(fail(`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`));
1246
+ return yield* Effect.fail(fail(`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`));
1216
1247
  }
1217
1248
  });
1218
1249
  export const fingerprint = fingerprintOf;