@goodbones/cli 0.1.0-beta.17 → 0.1.0-beta.19

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.
Files changed (39) hide show
  1. package/build/dts/campaigns/host.d.ts +51 -0
  2. package/build/dts/campaigns/host.d.ts.map +1 -0
  3. package/build/dts/campaigns/live.d.ts +3 -0
  4. package/build/dts/campaigns/live.d.ts.map +1 -0
  5. package/build/dts/campaigns/none.d.ts +7 -0
  6. package/build/dts/campaigns/none.d.ts.map +1 -0
  7. package/build/dts/campaigns/verbs.d.ts +6 -0
  8. package/build/dts/campaigns/verbs.d.ts.map +1 -0
  9. package/build/dts/config-loader.d.ts +7 -5
  10. package/build/dts/config-loader.d.ts.map +1 -1
  11. package/build/dts/output.d.ts +9 -0
  12. package/build/dts/output.d.ts.map +1 -0
  13. package/build/dts/run.d.ts +3 -18
  14. package/build/dts/run.d.ts.map +1 -1
  15. package/build/esm/campaigns/host.js +2 -0
  16. package/build/esm/campaigns/host.js.map +1 -0
  17. package/build/esm/campaigns/live.js +106 -0
  18. package/build/esm/campaigns/live.js.map +1 -0
  19. package/build/esm/campaigns/none.js +34 -0
  20. package/build/esm/campaigns/none.js.map +1 -0
  21. package/build/esm/campaigns/verbs.js +489 -0
  22. package/build/esm/campaigns/verbs.js.map +1 -0
  23. package/build/esm/config-loader.js +40 -25
  24. package/build/esm/config-loader.js.map +1 -1
  25. package/build/esm/infer.js +3 -3
  26. package/build/esm/infer.js.map +1 -1
  27. package/build/esm/output.js +8 -0
  28. package/build/esm/output.js.map +1 -0
  29. package/build/esm/run.js +39 -555
  30. package/build/esm/run.js.map +1 -1
  31. package/package.json +18 -6
  32. package/src/campaigns/host.ts +120 -0
  33. package/src/campaigns/live.ts +136 -0
  34. package/src/campaigns/none.ts +47 -0
  35. package/src/campaigns/verbs.ts +620 -0
  36. package/src/config-loader.ts +41 -26
  37. package/src/infer.ts +3 -3
  38. package/src/output.ts +18 -0
  39. package/src/run.ts +56 -726
package/src/run.ts CHANGED
@@ -2,40 +2,6 @@ import { createHash } from "node:crypto";
2
2
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import * as path from "node:path";
4
4
 
5
- import {
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
- measureFile,
24
- note,
25
- nudgeOf,
26
- readDiff,
27
- renderCampaignReports,
28
- renderCampaignRows,
29
- renderHistory,
30
- renderNudge,
31
- renderSectorView,
32
- reportSpecsOf,
33
- sectorMovesOf,
34
- sectorViewOf,
35
- snapshotCampaignsOf,
36
- valueOfParts,
37
- widenedExtensions,
38
- } from "@goodbones/campaigns";
39
5
  import {
40
6
  type Baseline,
41
7
  baselineOf,
@@ -90,9 +56,11 @@ import {
90
56
  import * as Effect from "effect/Effect";
91
57
  import * as Result from "effect/Result";
92
58
 
59
+ import type { CampaignEvaluation, CampaignReport, VerbContext } from "./campaigns/host.js";
93
60
  import { type LoadedPolicy, loadPolicyFromFile, manifestPathOf } from "./config-loader.js";
94
61
  import { buildGraph } from "./graph.js";
95
62
  import { infer } from "./infer.js";
63
+ import { type CliFailure, count, fail, report } from "./output.js";
96
64
  import { sourceFactsOf } from "./source-facts.js";
97
65
 
98
66
  // The policy, run with no linter in the loop.
@@ -107,9 +75,7 @@ import { sourceFactsOf } from "./source-facts.js";
107
75
  // same vocabulary — a specifier, a binding, a member site — so they answer to
108
76
  // the same core rather than to each other.
109
77
 
110
- export type CliFailure = { readonly _tag: "CliFailure"; readonly message: string };
111
-
112
- const fail = (message: string): CliFailure => ({ _tag: "CliFailure", message });
78
+ export type { CliFailure } from "./output.js";
113
79
 
114
80
  // An edge the resolver could not turn into a file. It is reported on its own,
115
81
  // since every import rule about it enforces nothing.
@@ -153,7 +119,7 @@ export const collectFindings = (
153
119
  policy.repoRoot,
154
120
  roots,
155
121
  policy.languages,
156
- widenedExtensions(policy),
122
+ policy.campaigns.widenedExtensions(policy),
157
123
  );
158
124
  const known = new Set(policy.languages.flatMap((one) => one.extensions));
159
125
  const files = walked.filter((file) => known.has(path.extname(file)));
@@ -191,7 +157,7 @@ export const collectFindings = (
191
157
  }
192
158
 
193
159
  // The campaigns, over every walked file, through the same caches.
194
- const campaigns = evaluateCampaigns(policy, roots, walked, { textOf, factsOf });
160
+ const campaigns = policy.campaigns.evaluate(policy, roots, walked, { textOf, factsOf });
195
161
 
196
162
  for (const file of files) {
197
163
  for (const violation of evaluateStructure(policy.structure, policy.fileSystem, file)) {
@@ -270,11 +236,6 @@ const readBaseline = (policy: LoadedPolicy): Baseline => {
270
236
  }
271
237
  };
272
238
 
273
- const report = (lines: ReadonlyArray<string>): Effect.Effect<void> =>
274
- Effect.sync(() => {
275
- for (const line of lines) process.stdout.write(`${line}\n`);
276
- });
277
-
278
239
  const describe = (violation: Violation): string =>
279
240
  ` ${violation.file}\n ${formatMessage(violation)}`;
280
241
 
@@ -424,7 +385,6 @@ const reportOf = (
424
385
  const baseline = readBaseline(policy);
425
386
  const stale = staleEntriesOf(baseline, findings.violations);
426
387
  const { isBaselined } = makeBaselineFilter(baseline);
427
- const isLedgered = ledgeredFilter(policy, findings.campaigns);
428
388
  const violations: Array<ReportedViolation> = [
429
389
  ...findings.violations.map((violation) => ({
430
390
  ...violation,
@@ -434,19 +394,17 @@ const reportOf = (
434
394
  })),
435
395
  // The hits that count: those whose objective is in window for the
436
396
  // sector they fall in.
437
- ...findings.campaigns.flatMap((evaluation) =>
438
- hitsInWindow(evaluation).map((hit) => ({
439
- ...hit.violation,
440
- fingerprint: fingerprintOf(hit.violation),
441
- baselined: false,
442
- ledgered: isLedgered(hit),
443
- objective: hit.objective,
444
- sector: hit.sector,
445
- entry: hit.entry,
446
- })),
447
- ),
397
+ ...policy.campaigns.hitsOf(policy, findings.campaigns).map((hit) => ({
398
+ ...hit.violation,
399
+ fingerprint: fingerprintOf(hit.violation),
400
+ baselined: false,
401
+ ledgered: hit.ledgered,
402
+ objective: hit.objective,
403
+ sector: hit.sector,
404
+ entry: hit.entry,
405
+ })),
448
406
  ];
449
- const campaigns = campaignReportsOf(policy, findings.campaigns);
407
+ const campaigns = policy.campaigns.reportsOf(policy, findings.campaigns);
450
408
 
451
409
  // The floors. A policy states how much of the tree it reaches, per
452
410
  // family; falling under is a policy that quietly stopped covering files.
@@ -504,7 +462,7 @@ const reportOf = (
504
462
  shortfalls.length === 0 &&
505
463
  overruns.length === 0 &&
506
464
  excesses.length === 0 &&
507
- campaignFailuresOf(campaigns).length === 0,
465
+ policy.campaigns.failuresOf(campaigns).length === 0,
508
466
  manifest: {
509
467
  path: path.relative(policy.repoRoot, manifestPath).replaceAll(path.sep, "/"),
510
468
  sha256: sha256Of(manifestPath),
@@ -577,11 +535,12 @@ const excessesOf = (conformance: ConformanceReport): ReadonlyArray<Excess> =>
577
535
  });
578
536
 
579
537
  const failureOf = (
538
+ policy: LoadedPolicy,
580
539
  report: CheckReport,
581
540
  shortfalls: ReadonlyArray<Shortfall>,
582
541
  ): CliFailure | null => {
583
542
  if (report.stale.length > 0) return fail("stale baseline entries");
584
- const [campaignFailure] = campaignFailuresOf(report.campaigns);
543
+ const [campaignFailure] = policy.campaigns.failuresOf(report.campaigns);
585
544
  if (campaignFailure !== undefined) return fail(campaignFailure);
586
545
  if (shortfalls.length > 0) return fail("coverage below floor");
587
546
  if (overrunsOf(report.coverage).length > 0) return fail("unreached above ceiling");
@@ -606,8 +565,8 @@ const describeExcess = (one: Excess): string => {
606
565
  return ` ${one.measure}: ${count(one.count, singular, plural)}, ceiling ${String(one.ceiling)}`;
607
566
  };
608
567
 
609
- const renderCampaigns = (report: CheckReport): ReadonlyArray<string> =>
610
- renderCampaignReports(
568
+ const renderCampaigns = (policy: LoadedPolicy, report: CheckReport): ReadonlyArray<string> =>
569
+ policy.campaigns.renderReports(
611
570
  report.campaigns,
612
571
  report.violations.flatMap((one) =>
613
572
  one.kind === "campaign" &&
@@ -627,7 +586,7 @@ const renderCampaigns = (report: CheckReport): ReadonlyArray<string> =>
627
586
  ),
628
587
  );
629
588
 
630
- const renderText = (report: CheckReport): ReadonlyArray<string> => {
589
+ const renderText = (policy: LoadedPolicy, report: CheckReport): ReadonlyArray<string> => {
631
590
  const reportable = report.violations.filter((one) => one.kind !== "campaign" && !one.baselined);
632
591
  const carried =
633
592
  report.violations.filter((one) => one.kind !== "campaign").length - reportable.length;
@@ -685,7 +644,7 @@ const renderText = (report: CheckReport): ReadonlyArray<string> => {
685
644
  "",
686
645
  " architecture conformance # which files, nodes and allowances",
687
646
  ]),
688
- ...renderCampaigns(report),
647
+ ...renderCampaigns(policy, report),
689
648
  ];
690
649
  };
691
650
 
@@ -704,17 +663,14 @@ export const check = (
704
663
  // JSON is one object on stdout and nothing else there; the failure, when
705
664
  // there is one, is a sentence on stderr and the exit code, as in text.
706
665
  yield* report(
707
- options.format === "json" ? [JSON.stringify(report_, null, 2)] : renderText(report_),
666
+ options.format === "json" ? [JSON.stringify(report_, null, 2)] : renderText(policy, report_),
708
667
  );
709
- const failure = failureOf(report_, shortfallsOf(report_.coverage));
668
+ const failure = failureOf(policy, report_, shortfallsOf(report_.coverage));
710
669
  if (failure !== null) return yield* Effect.fail(failure);
711
670
  });
712
671
 
713
672
  const percent = (fraction: number): string => `${String(Math.floor(fraction * 100))}%`;
714
673
 
715
- const count = (n: number, noun: string, plural = `${noun}s`): string =>
716
- `${String(n)} ${n === 1 ? noun : plural}`;
717
-
718
674
  // The conformance snapshot: `check`'s report grown with what no family
719
675
  // reaches, what the allowlists permit and nothing uses, the cycle count and
720
676
  // the size of the debt — the whole distance between the tree and the
@@ -742,7 +698,7 @@ export const snapshotOf = (
742
698
  return byHeight !== 0 ? byHeight : left.fingerprint.localeCompare(right.fingerprint);
743
699
  });
744
700
 
745
- const campaigns = snapshotCampaignsOf(policy, findings.campaigns);
701
+ const campaigns = policy.campaigns.snapshotOf(policy, findings.campaigns);
746
702
 
747
703
  return {
748
704
  version: SNAPSHOT_VERSION,
@@ -779,7 +735,7 @@ const unreachedMark = (covered: number, total: number, ceiling: number | undefin
779
735
  const outsideNote = (outside: number): string =>
780
736
  outside === 0 ? "" : `, ${count(outside, "file")} outside the limits`;
781
737
 
782
- const renderSnapshot = (snapshot: Snapshot): ReadonlyArray<string> => {
738
+ const renderSnapshot = (policy: LoadedPolicy, snapshot: Snapshot): ReadonlyArray<string> => {
783
739
  const reportable = snapshot.violations.filter((one) => !one.baselined && !one.ledgered);
784
740
  const carried = snapshot.violations.length - reportable.length;
785
741
  const row = (family: CoverageFamily): string => {
@@ -880,7 +836,7 @@ const renderSnapshot = (snapshot: Snapshot): ReadonlyArray<string> => {
880
836
  (snapshot.campaigns.some((one) => one.complete && one.ledgered)
881
837
  ? `, ${count(snapshot.campaigns.filter((one) => one.complete && one.ledgered).length, "complete", "complete")}`
882
838
  : ""),
883
- renderCampaignRows(snapshot.campaigns),
839
+ policy.campaigns.renderRows(snapshot.campaigns),
884
840
  )),
885
841
  "",
886
842
  `cycles: ${String(snapshot.cycles)}`,
@@ -903,7 +859,9 @@ export const conformance = (
903
859
  Effect.gen(function* () {
904
860
  const snapshot = snapshotOf(policy, roots, options.manifestPath);
905
861
  yield* report(
906
- options.format === "json" ? [JSON.stringify(snapshot, null, 2)] : renderSnapshot(snapshot),
862
+ options.format === "json"
863
+ ? [JSON.stringify(snapshot, null, 2)]
864
+ : renderSnapshot(policy, snapshot),
907
865
  );
908
866
  });
909
867
 
@@ -1036,59 +994,13 @@ export const explain = (
1036
994
  const section = (title: string, lines: ReadonlyArray<string>): ReadonlyArray<string> =>
1037
995
  lines.length === 0 ? [] : ["", title, ...lines];
1038
996
 
1039
- // Each campaign selecting the file: the sector the file is in, its
1040
- // phase and definedness, the objectives in window that fire on it and
1041
- // the nearest remaining holdouts — which needs the campaign evaluated
1042
- // over its files, since a sector's phase is derived from all of them —
1043
- // then each objective's truth table: one line per leaf term and what
1044
- // it answered here, so a detector that "should fire" and does not shows
1045
- // which term is not saying what its author thinks.
1046
- const selectedCampaigns = campaignsSelecting(campaignsOf(policy).campaignRules, relative);
1047
- const evaluations =
1048
- selectedCampaigns.length === 0 ? [] : collectFindings(policy, roots).campaigns;
1049
- const campaignLines = selectedCampaigns.flatMap((rule) => {
1050
- const at = path.join(policy.repoRoot, relative);
1051
- const text = existsSync(at) ? readFileSync(at, "utf8") : "";
1052
- const input = {
1053
- file: relative,
1054
- text,
1055
- facts: policy.extractor.factsOf(relative, text),
1056
- resolver: policy.resolver,
1057
- fileSystem: policy.fileSystem,
1058
- syntax: policy.syntax.parse(relative, text),
1059
- functions: campaignsOf(policy).functions,
1060
- reports: campaignsOf(policy).reports,
1061
- };
1062
- const evaluation = evaluations.find((one) => one.rule.id === rule.id);
1063
- return [
1064
- ...(evaluation === undefined ? [] : explainCampaignLines(policy, evaluation, relative)),
1065
- ...rule.objectives.flatMap((objective) => {
1066
- const measure = objective.measure;
1067
- if (measure !== null) {
1068
- // A scalar: what this file adds to its sector's number.
1069
- if (measure.kind === "command") return [];
1070
- const parts = measureFile(measure, input);
1071
- const here =
1072
- measure.kind === "ratio"
1073
- ? `${String(parts.of)} of ${String(parts.per)} here`
1074
- : `${String(valueOfParts(measure, parts))} here`;
1075
- return [
1076
- ` ${objective.name} — ${firstSentence(objective.intent ?? objective.message)} (measure, ${objective.direction}; ${here})`,
1077
- ];
1078
- }
1079
- const table = explainObjective(objective, input);
1080
- if (table.length === 0) return [];
1081
- const fired = table.length > 0 && table.every((line) => line.answer);
1082
- return [
1083
- ` ${objective.name} — ${firstSentence(objective.intent ?? objective.message)} (${objective.holdout}; ${fired ? "fires here" : "no hit"})`,
1084
- ...table.map(
1085
- (line) =>
1086
- ` ${line.answer ? "✓" : "✗"} ${line.term}${line.count === undefined ? "" : ` (${String(line.count)})`}`,
1087
- ),
1088
- ];
1089
- }),
1090
- ];
1091
- });
997
+ // The campaigns selecting the file, evaluated over their files only
998
+ // when one does: a sector's phase is derived from all of them.
999
+ const campaignLines = policy.campaigns.explainLines(
1000
+ policy,
1001
+ relative,
1002
+ () => collectFindings(policy, roots).campaigns,
1003
+ );
1092
1004
 
1093
1005
  yield* report([
1094
1006
  relative,
@@ -1337,382 +1249,31 @@ export const migrate = (
1337
1249
  ]);
1338
1250
  });
1339
1251
 
1340
- // The campaign commands. `campaigns` alone is the status table; `status
1341
- // --changed` is the nudge; `attest` and `note` write a sector's record;
1342
- // `history` replays the ledgers' git history. The ledgers themselves are
1343
- // written by `objectives clear` (the ledger reconciled with the code
1344
- // wherever that is not a regression) and `objectives concede` (the one way
1345
- // a holdout is added by hand, with a reason).
1346
-
1347
- const flagOf = (argv: ReadonlyArray<string>, flag: string): string | undefined => {
1348
- const at = argv.indexOf(flag);
1349
- const value = at === -1 ? undefined : argv[at + 1];
1350
- return value === undefined || value.startsWith("--") ? undefined : value;
1351
- };
1352
-
1353
- const CAMPAIGN_SUBCOMMANDS = ["status", "attest", "note", "history", "clear", "concede"] as const;
1354
- const OBJECTIVE_SUBCOMMANDS = ["clear", "concede"] as const;
1355
- const VALUE_FLAGS = [
1356
- "--reason",
1357
- "--by",
1358
- "--holdouts",
1359
- "--entries",
1360
- "--sector",
1361
- "--campaign",
1362
- "--evidence",
1363
- "--base",
1364
- "--since",
1365
- "--hotfix",
1366
- ] as const;
1367
-
1368
- // The verbs the family shipped with, refused by name: each has a new name
1369
- // or no place.
1370
- const RETIRED: Readonly<Record<string, string>> = {
1371
- init: "`campaigns init` is gone: `objectives clear <campaign>` writes a first ledger, recording each sector's initial.",
1372
- prune: "`campaigns prune` is now `objectives clear`.",
1373
- allow: "`campaigns allow` is now `objectives concede`.",
1374
- };
1375
-
1376
- // The positionals after the subcommand, with the value flags and their
1377
- // values stepped over: whatever is left names the roots to walk, as it
1378
- // does for every other command.
1379
- const positionalsOf = (argv: ReadonlyArray<string>): ReadonlyArray<string> => {
1380
- const positional: Array<string> = [];
1381
- for (let at = 0; at < argv.length; at += 1) {
1382
- const one = argv[at] ?? "";
1383
- if ((VALUE_FLAGS as ReadonlyArray<string>).includes(one)) {
1384
- at += 1;
1385
- continue;
1386
- }
1387
- if (!one.startsWith("--")) positional.push(one);
1388
- }
1389
- return positional;
1390
- };
1391
-
1392
- // `campaigns [status | attest <sector> <phase> | note <sector> "<text>" |
1393
- // history [<campaign>]] [roots…]`: the subcommand and its arguments come
1394
- // first.
1395
- export const campaignArgsOf = (
1396
- argv: ReadonlyArray<string>,
1397
- ids: ReadonlyArray<string>,
1398
- ): {
1399
- readonly subcommand: string | undefined;
1400
- readonly args: ReadonlyArray<string>;
1401
- readonly roots: ReadonlyArray<string>;
1402
- } => {
1403
- const positional = positionalsOf(argv);
1404
- const [first, ...rest] = positional;
1405
- if (first === undefined) return { subcommand: undefined, args: [], roots: [] };
1406
- if (first in RETIRED) return { subcommand: first, args: [], roots: rest };
1407
- if (!(CAMPAIGN_SUBCOMMANDS as ReadonlyArray<string>).includes(first)) {
1408
- return { subcommand: undefined, args: [], roots: positional };
1409
- }
1410
- switch (first) {
1411
- case "attest":
1412
- return { subcommand: first, args: rest.slice(0, 2), roots: rest.slice(2) };
1413
- case "note":
1414
- return { subcommand: first, args: rest.slice(0, 2), roots: rest.slice(2) };
1415
- case "history": {
1416
- const [second] = rest;
1417
- return second !== undefined && ids.includes(second)
1418
- ? { subcommand: first, args: [second], roots: rest.slice(1) }
1419
- : { subcommand: first, args: [], roots: rest };
1420
- }
1421
- case "clear":
1422
- case "concede": {
1423
- const [second] = rest;
1424
- return second !== undefined && ids.includes(second.split("/")[0] ?? "")
1425
- ? { subcommand: first, args: [second], roots: rest.slice(1) }
1426
- : { subcommand: first, args: [], roots: rest };
1427
- }
1428
- default:
1429
- return { subcommand: first, args: [], roots: rest };
1430
- }
1431
- };
1432
-
1433
- // `objectives [clear [<campaign>[/<objective>]] | concede <campaign>[/<objective>]
1434
- // --reason <text>] [roots…]`.
1435
- export const objectiveArgsOf = (
1436
- argv: ReadonlyArray<string>,
1437
- ids: ReadonlyArray<string>,
1438
- ): {
1439
- readonly subcommand: string | undefined;
1440
- readonly target: { campaign: string; objective: string | null } | null;
1441
- readonly roots: ReadonlyArray<string>;
1442
- } => {
1443
- const positional = positionalsOf(argv);
1444
- const [first, second, ...rest] = positional;
1445
- if (first === undefined || !(OBJECTIVE_SUBCOMMANDS as ReadonlyArray<string>).includes(first)) {
1446
- return { subcommand: first, target: null, roots: positional.slice(1) };
1447
- }
1448
- const [campaign = "", objective] = (second ?? "").split("/");
1449
- const named = second !== undefined && ids.includes(campaign);
1450
- return {
1451
- subcommand: first,
1452
- target: named ? { campaign, objective: objective ?? null } : null,
1453
- roots: named ? rest : second === undefined ? [] : [second, ...rest],
1454
- };
1455
- };
1456
-
1457
- // The one campaign, when there is one, else the one `--campaign` names.
1458
- const campaignFor = (
1252
+ // The campaign commands, answered by the family the host was composed with:
1253
+ // `campaigns` alone is the status table, `status --changed` the nudge,
1254
+ // `attest` and `note` write a sector's record, `history` replays the ledgers;
1255
+ // `objectives clear` and `objectives concede` write the ledgers. Without
1256
+ // `@goodbones/campaigns` installed, each says what to install.
1257
+ const verbContext = (
1459
1258
  policy: LoadedPolicy,
1259
+ defaultRoots: ReadonlyArray<string>,
1460
1260
  argv: ReadonlyArray<string>,
1461
- ): Result.Result<CampaignEvaluation["rule"], string> => {
1462
- const named = flagOf(argv, "--campaign");
1463
- if (named !== undefined) {
1464
- const found = campaignsOf(policy).campaignRules.find((rule) => rule.id === named);
1465
- return found === undefined
1466
- ? Result.fail(`no campaign is named "${named}"`)
1467
- : Result.succeed(found);
1468
- }
1469
- const [only] = campaignsOf(policy).campaignRules;
1470
- if (campaignsOf(policy).campaignRules.length === 1 && only !== undefined)
1471
- return Result.succeed(only);
1472
- return Result.fail(
1473
- `this policy declares ${String(campaignsOf(policy).campaignRules.length)} campaigns; say which with --campaign <id>.`,
1474
- );
1475
- };
1476
-
1477
- const objectiveFor = (
1478
- rule: CampaignEvaluation["rule"],
1479
- objective: string | null,
1480
- ): Result.Result<string, string> => {
1481
- if (objective !== null) {
1482
- return rule.objectives.some((one) => one.id === objective)
1483
- ? Result.succeed(objective)
1484
- : Result.fail(`no objective of ${rule.id} is named "${objective}"`);
1485
- }
1486
- const [only] = rule.objectives;
1487
- if (rule.objectives.length === 1 && only !== undefined) return Result.succeed(only.id);
1488
- return Result.fail(
1489
- `campaign ${rule.id} declares ${String(rule.objectives.length)} objectives; say which as ${rule.id}/<objective>.`,
1490
- );
1491
- };
1261
+ configFilename?: string,
1262
+ ): VerbContext => ({
1263
+ policy,
1264
+ defaultRoots,
1265
+ argv,
1266
+ evaluate: (roots) => collectFindings(policy, roots).campaigns,
1267
+ reload: (repoRoot) => loadPolicyFromFile(repoRoot, configFilename),
1268
+ manifestPath: manifestPathOf(policy.repoRoot, configFilename),
1269
+ });
1492
1270
 
1493
1271
  export const objectives = (
1494
1272
  policy: LoadedPolicy,
1495
1273
  defaultRoots: ReadonlyArray<string>,
1496
1274
  argv: ReadonlyArray<string>,
1497
1275
  ): Effect.Effect<void, CliFailure> =>
1498
- Effect.gen(function* () {
1499
- const parsed = objectiveArgsOf(
1500
- argv,
1501
- campaignsOf(policy).campaignRules.map((rule) => rule.id),
1502
- );
1503
- const roots = parsed.roots.length > 0 ? parsed.roots : defaultRoots;
1504
- if (campaignsOf(policy).campaignRules.length === 0) {
1505
- return yield* report(["this policy declares no campaigns."]);
1506
- }
1507
- const evaluations = (): ReadonlyArray<CampaignEvaluation> =>
1508
- collectFindings(policy, roots).campaigns;
1509
-
1510
- switch (parsed.subcommand) {
1511
- case "clear": {
1512
- const targets =
1513
- parsed.target === null
1514
- ? campaignsOf(policy).campaignRules
1515
- : campaignsOf(policy).campaignRules.filter(
1516
- (rule) => rule.id === parsed.target?.campaign,
1517
- );
1518
- const by = authorOf(flagOf(argv, "--by")) ?? "unknown";
1519
- const all = evaluations();
1520
- const lines: Array<string> = [];
1521
- for (const rule of targets) {
1522
- const evaluation = all.find((one) => one.rule.id === rule.id);
1523
- if (evaluation === undefined) continue;
1524
- const only = parsed.target?.objective ?? null;
1525
- if (only !== null && !rule.objectives.some((one) => one.id === only)) {
1526
- return yield* Effect.fail(fail(`no objective of ${rule.id} is named "${only}"`));
1527
- }
1528
- const outcomes = yield* Effect.try({
1529
- try: () => clear(policy, evaluation, only, by),
1530
- catch: (cause) => fail(String(cause)),
1531
- });
1532
- // Where each sector went, read off the ledgers as they stood: a
1533
- // clear of one objective places no sector, so it says nothing.
1534
- const moves = only === null ? sectorMovesOf(policy, evaluation) : [];
1535
- for (const outcome of outcomes) {
1536
- const parts = [
1537
- ...(outcome.entered.length > 0
1538
- ? [
1539
- `${count(outcome.entered.length, "sector")} entered (${outcome.entered.join(", ")})`,
1540
- ]
1541
- : []),
1542
- ...(outcome.cleared > 0 ? [`${count(outcome.cleared, "holdout")} cleared`] : []),
1543
- ...(outcome.rewritten > 0
1544
- ? [`${count(outcome.rewritten, "holdout")} rewritten`]
1545
- : []),
1546
- ...(outcome.closed > 0 ? [`${count(outcome.closed, "holdout")} closed`] : []),
1547
- ...(outcome.rebaselined.length > 0
1548
- ? [
1549
- `${count(outcome.rebaselined.length, "sector")} re-baselined (${outcome.rebaselined.join(", ")})`,
1550
- ]
1551
- : []),
1552
- ];
1553
- if (outcome.measure !== undefined) {
1554
- const moved = [
1555
- ...(outcome.entered.length > 0
1556
- ? [
1557
- `${count(outcome.entered.length, "sector")} entered (${outcome.entered.join(", ")})`,
1558
- ]
1559
- : []),
1560
- ...(outcome.measure.improved.length > 0
1561
- ? [
1562
- `${count(outcome.measure.improved.length, "sector")} improved (${outcome.measure.improved
1563
- .map((one) => `${one.sector} ${String(one.from)} → ${String(one.to)}`)
1564
- .join(", ")})`,
1565
- ]
1566
- : []),
1567
- ...(outcome.measure.grown.length > 0
1568
- ? [
1569
- `${count(outcome.measure.grown.length, "sector")} grown (${outcome.measure.grown
1570
- .map(
1571
- (one) =>
1572
- `${one.sector} ${String(one.from)} → ${String(one.to)}, ${one.phase === null ? "measured, not held" : `as ${one.phase} expects`}`,
1573
- )
1574
- .join(", ")})`,
1575
- ]
1576
- : []),
1577
- ...(outcome.closed > 0 ? [`${count(outcome.closed, "sector")} closed`] : []),
1578
- ...(outcome.rebaselined.length > 0
1579
- ? [
1580
- `${count(outcome.rebaselined.length, "sector")} re-baselined (${outcome.rebaselined.join(", ")})`,
1581
- ]
1582
- : []),
1583
- ];
1584
- lines.push(
1585
- `${outcome.campaign}/${outcome.objective}: ${moved.length === 0 ? "nothing to clear" : moved.join(", ")}; held to ${String(outcome.measure.recorded)}.`,
1586
- );
1587
- continue;
1588
- }
1589
- lines.push(
1590
- `${outcome.campaign}/${outcome.objective}: ${parts.length === 0 ? "nothing to clear" : parts.join(", ")}; ${count(outcome.left, "holdout")} left.`,
1591
- );
1592
- }
1593
- for (const move of moves) {
1594
- lines.push(
1595
- `${move.campaign}: ${move.sector} ${move.back ? "went back" : "moved"} ${move.from ?? "done"} → ${move.to ?? "done"}` +
1596
- (move.passed.length === 0
1597
- ? "."
1598
- : `, passing ${move.passed.join(", ")} in the same clear: nothing there was ever counted for it.`),
1599
- );
1600
- }
1601
- }
1602
- return yield* report(lines);
1603
- }
1604
- case "concede": {
1605
- if (parsed.target === null) {
1606
- return yield* Effect.fail(
1607
- fail(
1608
- "objectives concede needs a campaign: `objectives concede <campaign>[/<objective>] --reason <text>`",
1609
- ),
1610
- );
1611
- }
1612
- const rule = campaignsOf(policy).campaignRules.find(
1613
- (one) => one.id === parsed.target?.campaign,
1614
- );
1615
- if (rule === undefined)
1616
- return yield* Effect.fail(fail(`no campaign is named "${parsed.target.campaign}"`));
1617
- const objective = objectiveFor(rule, parsed.target.objective);
1618
- if (Result.isFailure(objective)) return yield* Effect.fail(fail(objective.failure));
1619
- const reason = flagOf(argv, "--reason");
1620
- if (reason === undefined) {
1621
- return yield* Effect.fail(
1622
- fail(
1623
- "objectives concede needs --reason <text>: growth is recorded with why, or not at all.",
1624
- ),
1625
- );
1626
- }
1627
- const by = authorOf(flagOf(argv, "--by"));
1628
- if (by === null) {
1629
- return yield* Effect.fail(
1630
- fail("objectives concede needs an author: pass --by <email>, or set git's user.email."),
1631
- );
1632
- }
1633
- const evaluation = evaluations().find((one) => one.rule.id === rule.id);
1634
- if (evaluation === undefined)
1635
- return yield* Effect.fail(fail(`campaign ${rule.id} was not evaluated`));
1636
- // `--holdouts` concedes a subset and refuses the rest: a pull
1637
- // request that legitimately adds one hit while another is an
1638
- // accident.
1639
- const chosen =
1640
- (flagOf(argv, "--holdouts") ?? flagOf(argv, "--entries"))
1641
- ?.split(",")
1642
- .map((one) => one.trim()) ?? null;
1643
- const outcome = concede(
1644
- policy,
1645
- evaluation,
1646
- objective.success,
1647
- chosen,
1648
- flagOf(argv, "--sector") ?? null,
1649
- {
1650
- at: policy.now,
1651
- by,
1652
- reason,
1653
- },
1654
- );
1655
- if (Result.isFailure(outcome)) return yield* Effect.fail(fail(outcome.failure));
1656
- // A concession that sends a sector back a phase says so, and names
1657
- // the attestations it revoked on the way.
1658
- const sentBack = outcome.success.sentBack.flatMap((one) => [
1659
- "",
1660
- `this concession moves ${one.sector} back ${one.from ?? "done"} → ${one.to ?? "done"}.`,
1661
- ...(one.revoked.length === 0
1662
- ? []
1663
- : [
1664
- `It revokes ${one.sector}'s attestation of ${one.revoked.join(", ")}: what was attested no longer holds. Once it does again, attest it anew:`,
1665
- ...one.revoked.map(
1666
- (phase) =>
1667
- ` architecture campaigns attest ${one.sector} ${phase} --reason "…" --campaign ${rule.id}`,
1668
- ),
1669
- ]),
1670
- ]);
1671
- const scalar =
1672
- rule.objectives.find((one) => one.id === objective.success)?.measure !== null;
1673
- if (scalar) {
1674
- if (outcome.success.conceded.length === 0) {
1675
- return yield* report([
1676
- `${rule.id}/${objective.success}: nothing to concede; no sector measures past its record.`,
1677
- ]);
1678
- }
1679
- return yield* report([
1680
- `${rule.id}/${objective.success}: a rise conceded in ${count(outcome.success.conceded.length, "sector")}, recorded by ${by}.`,
1681
- ...outcome.success.conceded.map((one) => ` ${one.sector} · ${one.entry}`),
1682
- ...(outcome.success.left.length === 0
1683
- ? []
1684
- : [
1685
- "",
1686
- `${count(outcome.success.left.length, "sector")} left past the record; check still fails on them.`,
1687
- ]),
1688
- ...sentBack,
1689
- ]);
1690
- }
1691
- if (outcome.success.conceded.length === 0) {
1692
- return yield* report([
1693
- `${rule.id}/${objective.success}: nothing to concede; every hit is in the ledger.`,
1694
- ]);
1695
- }
1696
- return yield* report([
1697
- `${rule.id}/${objective.success}: ${count(outcome.success.conceded.length, "holdout")} conceded, recorded by ${by}.`,
1698
- ...outcome.success.conceded.map((one) => ` ${one.sector} · ${one.entry}`),
1699
- ...(outcome.success.left.length === 0
1700
- ? []
1701
- : [
1702
- "",
1703
- `${count(outcome.success.left.length, "hit")} left unrecorded; check still fails on them.`,
1704
- ]),
1705
- ...sentBack,
1706
- ]);
1707
- }
1708
- default:
1709
- return yield* Effect.fail(
1710
- fail(
1711
- `unknown objectives subcommand "${parsed.subcommand ?? ""}". Try: objectives clear [<campaign>[/<objective>]] | objectives concede <campaign>[/<objective>] --reason <text> [--by <email>] [--sector <name>] [--holdouts a,b]`,
1712
- ),
1713
- );
1714
- }
1715
- });
1276
+ policy.campaigns.objectives(verbContext(policy, defaultRoots, argv));
1716
1277
 
1717
1278
  export const campaigns = (
1718
1279
  policy: LoadedPolicy,
@@ -1720,233 +1281,7 @@ export const campaigns = (
1720
1281
  argv: ReadonlyArray<string>,
1721
1282
  configFilename?: string,
1722
1283
  ): Effect.Effect<void, CliFailure> =>
1723
- Effect.gen(function* () {
1724
- const parsed = campaignArgsOf(
1725
- argv,
1726
- campaignsOf(policy).campaignRules.map((rule) => rule.id),
1727
- );
1728
- const roots = parsed.roots.length > 0 ? parsed.roots : defaultRoots;
1729
- if (campaignsOf(policy).campaignRules.length === 0) {
1730
- return yield* report(["this policy declares no campaigns."]);
1731
- }
1732
- const json = argv.includes("--json");
1733
- const retired = parsed.subcommand === undefined ? undefined : RETIRED[parsed.subcommand];
1734
- if (retired !== undefined) return yield* Effect.fail(fail(retired));
1735
- // A positional that is no path is refused, not walked: most often it is
1736
- // a sector's name, and `--sector` is what asks about one.
1737
- for (const root of parsed.roots) {
1738
- if (existsSync(path.resolve(policy.repoRoot, root))) continue;
1739
- const named = collectFindings(policy, defaultRoots).campaigns.some((one) =>
1740
- one.sectors.has(root),
1741
- );
1742
- return yield* Effect.fail(
1743
- fail(
1744
- `campaigns: ${root} is not a path in the repository, so there is nothing to walk there.` +
1745
- (named ? ` It names a sector: \`campaigns status --sector ${root}\`.` : ""),
1746
- ),
1747
- );
1748
- }
1749
- const sector = flagOf(argv, "--sector");
1750
- if (parsed.subcommand === "status" && sector !== undefined) {
1751
- const only = argv.includes("--campaign") ? campaignFor(policy, argv) : null;
1752
- if (only !== null && Result.isFailure(only)) return yield* Effect.fail(fail(only.failure));
1753
- const views = collectFindings(policy, roots)
1754
- .campaigns.filter((one) => only === null || one.rule.id === only.success.id)
1755
- .flatMap((one) => {
1756
- const view = sectorViewOf(policy, one, sector);
1757
- return view === null ? [] : [view];
1758
- });
1759
- if (views.length === 0) {
1760
- return yield* Effect.fail(
1761
- fail(`campaigns: no campaign under ${roots.join(", ")} has a sector named ${sector}.`),
1762
- );
1763
- }
1764
- return yield* report(
1765
- json
1766
- ? [JSON.stringify({ version: 1, sectors: views }, null, 2)]
1767
- : views.flatMap((view, i) => [...(i === 0 ? [] : [""]), ...renderSectorView(view)]),
1768
- );
1769
- }
1770
-
1771
- // `status` without `--changed` is the overview, in text or JSON: the
1772
- // nudge alone is scoped to a diff.
1773
- const subcommand =
1774
- parsed.subcommand === "status" && !argv.includes("--changed") ? undefined : parsed.subcommand;
1775
- switch (subcommand) {
1776
- case undefined: {
1777
- const snapshot = snapshotOf(policy, roots, manifestPathOf(policy.repoRoot, configFilename));
1778
- if (json) {
1779
- // Each campaign as the conformance snapshot carries it: its
1780
- // burn-down, its ladder, and a row per sector.
1781
- return yield* report([
1782
- JSON.stringify({ version: 1, campaigns: snapshot.campaigns }, null, 2),
1783
- ]);
1784
- }
1785
- return yield* report([
1786
- `${count(snapshot.campaigns.length, "campaign")} under ${roots.join(", ")}`,
1787
- "",
1788
- ...renderCampaignRows(snapshot.campaigns),
1789
- "",
1790
- " architecture campaigns status --changed [--base <ref>] [--json] # what a diff touches, and what to do",
1791
- " architecture campaigns status --sector <sector> [--json] # where one sector stands, and what holds it",
1792
- " architecture objectives clear [<campaign>[/<objective>]] # reconcile the ledgers with the code",
1793
- ' architecture objectives concede <campaign>[/<objective>] --reason "<why>" # record why a count may rise',
1794
- ' architecture campaigns attest <sector> <phase> --reason "<why>" [--evidence <url>]',
1795
- ' architecture campaigns note <sector> "<text>"',
1796
- " architecture campaigns history [<campaign>] [--since <ref>]",
1797
- ]);
1798
- }
1799
- case "status": {
1800
- const base = flagOf(argv, "--base") ?? null;
1801
- const diff = yield* Effect.try({
1802
- try: () => readDiff(policy.repoRoot, base),
1803
- catch: (cause) => fail(`could not read the diff: ${String(cause)}`),
1804
- });
1805
- const current = collectFindings(policy, roots).campaigns;
1806
- const baseSide =
1807
- base === null
1808
- ? null
1809
- : yield* Effect.tryPromise({
1810
- try: () =>
1811
- baseSideAt(policy, base, roots, (repoRoot) =>
1812
- loadPolicyFromFile(repoRoot, configFilename),
1813
- ),
1814
- catch: (cause) =>
1815
- fail(`could not evaluate the base tree at ${base}: ${String(cause)}`),
1816
- });
1817
- // The ledger mode's "before" for work done ahead of the plan is the
1818
- // HEAD tree, evaluated once per commit and cached: no ledger counts
1819
- // the holdouts of a phase a sector has not entered.
1820
- const headTree =
1821
- base !== null || !current.some((one) => one.rule.phases.length > 0)
1822
- ? null
1823
- : yield* Effect.tryPromise({
1824
- try: () =>
1825
- baseSideAt(policy, "HEAD", roots, (repoRoot) =>
1826
- loadPolicyFromFile(repoRoot, configFilename),
1827
- ),
1828
- catch: (cause) => fail(`could not evaluate the tree at HEAD: ${String(cause)}`),
1829
- });
1830
- const hotfix = flagOf(argv, "--hotfix") ?? null;
1831
- const nudge = nudgeOf(
1832
- policy,
1833
- current,
1834
- diff,
1835
- baseSide,
1836
- hotfix,
1837
- hotfix === null ? null : authorOf(flagOf(argv, "--by")),
1838
- headTree,
1839
- );
1840
- yield* report(json ? [JSON.stringify(nudge, null, 2)] : renderNudge(nudge, policy.now));
1841
- if (!nudge.ok) {
1842
- const ahead = nudge.sectors.some((one) => one.verdict === "ahead");
1843
- const back = nudge.sectors.some(
1844
- (one) => one.verdict !== "ok" && one.verdict !== "ahead" && one.verdict !== "hotfix",
1845
- );
1846
- return yield* Effect.fail(
1847
- fail(
1848
- [
1849
- ...(back ? ["the diff sends a sector back under its onTouch"] : []),
1850
- ...(ahead ? ["the diff works ahead of a sector's phase under its onAhead"] : []),
1851
- ].join("; "),
1852
- ),
1853
- );
1854
- }
1855
- return;
1856
- }
1857
- case "attest": {
1858
- const [sector, phase] = parsed.args;
1859
- if (sector === undefined || phase === undefined) {
1860
- return yield* Effect.fail(
1861
- fail(
1862
- 'campaigns attest needs a sector and a phase: `campaigns attest <sector> <phase> --reason "<why>"`',
1863
- ),
1864
- );
1865
- }
1866
- const reason = flagOf(argv, "--reason");
1867
- if (reason === undefined)
1868
- return yield* Effect.fail(fail("campaigns attest needs --reason <text>."));
1869
- const by = authorOf(flagOf(argv, "--by"));
1870
- if (by === null)
1871
- return yield* Effect.fail(
1872
- fail("campaigns attest needs an author: pass --by <email>, or set git's user.email."),
1873
- );
1874
- const rule = campaignFor(policy, argv);
1875
- if (Result.isFailure(rule)) return yield* Effect.fail(fail(rule.failure));
1876
- const evaluation = collectFindings(policy, roots).campaigns.find(
1877
- (one) => one.rule.id === rule.success.id,
1878
- );
1879
- if (evaluation === undefined)
1880
- return yield* Effect.fail(fail(`campaign ${rule.success.id} was not evaluated`));
1881
- const written = attest(policy, evaluation, sector, phase, {
1882
- reason,
1883
- evidence: flagOf(argv, "--evidence"),
1884
- by,
1885
- });
1886
- if (Result.isFailure(written)) return yield* Effect.fail(fail(written.failure));
1887
- return yield* report([
1888
- `${rule.success.id}: sector ${sector} attested at ${phase} by ${by}, in ${written.success}.`,
1889
- "Run `objectives clear` to move it on.",
1890
- ]);
1891
- }
1892
- case "note": {
1893
- const [sector, text] = parsed.args;
1894
- if (sector === undefined || text === undefined) {
1895
- return yield* Effect.fail(
1896
- fail('campaigns note needs a sector and a text: `campaigns note <sector> "<text>"`'),
1897
- );
1898
- }
1899
- const by = authorOf(flagOf(argv, "--by"));
1900
- if (by === null)
1901
- return yield* Effect.fail(
1902
- fail("campaigns note needs an author: pass --by <email>, or set git's user.email."),
1903
- );
1904
- const rule = campaignFor(policy, argv);
1905
- if (Result.isFailure(rule)) return yield* Effect.fail(fail(rule.failure));
1906
- const evaluation = collectFindings(policy, roots).campaigns.find(
1907
- (one) => one.rule.id === rule.success.id,
1908
- );
1909
- if (evaluation === undefined)
1910
- return yield* Effect.fail(fail(`campaign ${rule.success.id} was not evaluated`));
1911
- const written = note(policy, evaluation, sector, text, by);
1912
- if (Result.isFailure(written)) return yield* Effect.fail(fail(written.failure));
1913
- return yield* report([
1914
- `${rule.success.id}: note left on ${sector}, in ${written.success}.`,
1915
- ]);
1916
- }
1917
- case "history": {
1918
- const [named] = parsed.args;
1919
- const targets =
1920
- named === undefined
1921
- ? campaignsOf(policy).campaignRules
1922
- : campaignsOf(policy).campaignRules.filter((rule) => rule.id === named);
1923
- const manifestPath = path
1924
- .relative(policy.repoRoot, manifestPathOf(policy.repoRoot, configFilename))
1925
- .replaceAll(path.sep, "/");
1926
- const lines: Array<string> = [];
1927
- for (const rule of targets) {
1928
- const rows = historyOf(policy, rule, flagOf(argv, "--since") ?? null, [manifestPath]);
1929
- if (json) {
1930
- lines.push(JSON.stringify({ campaign: rule.id, rows }, null, 2));
1931
- continue;
1932
- }
1933
- if (lines.length > 0) lines.push("");
1934
- for (const line of renderHistory(rule, rows)) lines.push(line);
1935
- }
1936
- return yield* report(lines);
1937
- }
1938
- case "clear":
1939
- case "concede":
1940
- // The ledger verbs answer under `objectives`; accepted here too.
1941
- return yield* objectives(policy, defaultRoots, argv);
1942
- default:
1943
- return yield* Effect.fail(
1944
- fail(
1945
- `unknown campaigns subcommand "${parsed.subcommand}". Try: campaigns | campaigns status --changed | campaigns attest <sector> <phase> --reason <text> | campaigns note <sector> "<text>" | campaigns history [<campaign>]`,
1946
- ),
1947
- );
1948
- }
1949
- });
1284
+ policy.campaigns.campaigns(verbContext(policy, defaultRoots, argv, configFilename));
1950
1285
 
1951
1286
  // The commands that judge a campaign, and so ask its report source.
1952
1287
  const READS_REPORTS: ReadonlySet<string> = new Set([
@@ -2005,12 +1340,7 @@ export const run = (
2005
1340
  // for the campaign to report; a report that does not parse is refused.
2006
1341
  if (READS_REPORTS.has(command)) {
2007
1342
  yield* Effect.tryPromise({
2008
- try: () =>
2009
- Promise.all(
2010
- reportSpecsOf(campaignsOf(policy).campaignRules).map((spec) =>
2011
- campaignsOf(policy).reports.read?.(spec),
2012
- ),
2013
- ),
1343
+ try: () => policy.campaigns.readReports(policy),
2014
1344
  catch: (cause) => fail(String(cause)),
2015
1345
  });
2016
1346
  }