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

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/build/esm/run.js CHANGED
@@ -1,19 +1,18 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import * as path from "node:path";
4
- import { attest, authorOf, baseSideAt, campaignFailuresOf, campaignReportsOf, campaignsOf, campaignsSelecting, clear, concede, evaluateCampaigns, explainCampaignLines, explainObjective, historyOf, hitsInWindow, ledgeredFilter, measureFile, note, nudgeOf, readDiff, renderCampaignReports, renderCampaignRows, renderHistory, renderNudge, renderSectorView, reportSpecsOf, sectorMovesOf, sectorViewOf, snapshotCampaignsOf, valueOfParts, widenedExtensions, } from "@goodbones/campaigns";
5
4
  import { baselineOf, breaches, CONFORMANCE_MEASURES, countedFiles, coverageOf, cyclesIn, decodeBaseline, decodeManifest, EMPTY_BASELINE, evaluateGraph, evaluateMemberSite, evaluateResolvedEdge, evaluateSelectedBindings, evaluateStructure, evaluateSurface, expandAliases, exportRulesSelecting, findManifestFile, fingerprintOf, formatManifestYaml, formatMessage, fractionsOf, globsOf, globToRegExp, 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
5
  import * as Effect from "effect/Effect";
7
6
  import * as Result from "effect/Result";
8
7
  import { loadPolicyFromFile, manifestPathOf } from "./config-loader.js";
9
8
  import { buildGraph } from "./graph.js";
10
9
  import { infer } from "./infer.js";
10
+ import { count, fail, report } from "./output.js";
11
11
  import { sourceFactsOf } from "./source-facts.js";
12
- const fail = (message) => ({ _tag: "CliFailure", message });
13
12
  export const collectFindings = (policy, roots, options = {}) => {
14
13
  // A campaign may widen the walk past the packs' extensions; a file only a
15
14
  // campaign asked for is seen by the campaigns and by no other family.
16
- const walked = listSourceFiles(policy.repoRoot, roots, policy.languages, widenedExtensions(policy));
15
+ const walked = listSourceFiles(policy.repoRoot, roots, policy.languages, policy.campaigns.widenedExtensions(policy));
17
16
  const known = new Set(policy.languages.flatMap((one) => one.extensions));
18
17
  const files = walked.filter((file) => known.has(path.extname(file)));
19
18
  const violations = [];
@@ -49,7 +48,7 @@ export const collectFindings = (policy, roots, options = {}) => {
49
48
  violations.push(violation);
50
49
  }
51
50
  // The campaigns, over every walked file, through the same caches.
52
- const campaigns = evaluateCampaigns(policy, roots, walked, { textOf, factsOf });
51
+ const campaigns = policy.campaigns.evaluate(policy, roots, walked, { textOf, factsOf });
53
52
  for (const file of files) {
54
53
  for (const violation of evaluateStructure(policy.structure, policy.fileSystem, file)) {
55
54
  violations.push(violation);
@@ -119,10 +118,6 @@ const readBaseline = (policy) => {
119
118
  return EMPTY_BASELINE;
120
119
  }
121
120
  };
122
- const report = (lines) => Effect.sync(() => {
123
- for (const line of lines)
124
- process.stdout.write(`${line}\n`);
125
- });
126
121
  const describe = (violation) => ` ${violation.file}\n ${formatMessage(violation)}`;
127
122
  const COVERAGE_FAMILIES = [
128
123
  "imports",
@@ -170,7 +165,6 @@ const reportOf = (policy, roots, manifestPath, findings) => {
170
165
  const baseline = readBaseline(policy);
171
166
  const stale = staleEntriesOf(baseline, findings.violations);
172
167
  const { isBaselined } = makeBaselineFilter(baseline);
173
- const isLedgered = ledgeredFilter(policy, findings.campaigns);
174
168
  const violations = [
175
169
  ...findings.violations.map((violation) => ({
176
170
  ...violation,
@@ -180,17 +174,17 @@ const reportOf = (policy, roots, manifestPath, findings) => {
180
174
  })),
181
175
  // The hits that count: those whose objective is in window for the
182
176
  // sector they fall in.
183
- ...findings.campaigns.flatMap((evaluation) => hitsInWindow(evaluation).map((hit) => ({
177
+ ...policy.campaigns.hitsOf(policy, findings.campaigns).map((hit) => ({
184
178
  ...hit.violation,
185
179
  fingerprint: fingerprintOf(hit.violation),
186
180
  baselined: false,
187
- ledgered: isLedgered(hit),
181
+ ledgered: hit.ledgered,
188
182
  objective: hit.objective,
189
183
  sector: hit.sector,
190
184
  entry: hit.entry,
191
- }))),
185
+ })),
192
186
  ];
193
- const campaigns = campaignReportsOf(policy, findings.campaigns);
187
+ const campaigns = policy.campaigns.reportsOf(policy, findings.campaigns);
194
188
  // The floors. A policy states how much of the tree it reaches, per
195
189
  // family; falling under is a policy that quietly stopped covering files.
196
190
  // Both are held over the files the limits count: what `limits.outside`
@@ -239,7 +233,7 @@ const reportOf = (policy, roots, manifestPath, findings) => {
239
233
  shortfalls.length === 0 &&
240
234
  overruns.length === 0 &&
241
235
  excesses.length === 0 &&
242
- campaignFailuresOf(campaigns).length === 0,
236
+ policy.campaigns.failuresOf(campaigns).length === 0,
243
237
  manifest: {
244
238
  path: path.relative(policy.repoRoot, manifestPath).replaceAll(path.sep, "/"),
245
239
  sha256: sha256Of(manifestPath),
@@ -279,10 +273,10 @@ const excessesOf = (conformance) => CONFORMANCE_MEASURES.flatMap((measure) => {
279
273
  ? []
280
274
  : [{ measure, count, ceiling }];
281
275
  });
282
- const failureOf = (report, shortfalls) => {
276
+ const failureOf = (policy, report, shortfalls) => {
283
277
  if (report.stale.length > 0)
284
278
  return fail("stale baseline entries");
285
- const [campaignFailure] = campaignFailuresOf(report.campaigns);
279
+ const [campaignFailure] = policy.campaigns.failuresOf(report.campaigns);
286
280
  if (campaignFailure !== undefined)
287
281
  return fail(campaignFailure);
288
282
  if (shortfalls.length > 0)
@@ -309,7 +303,7 @@ const describeExcess = (one) => {
309
303
  const [singular, plural] = MEASURE_NOUNS[one.measure];
310
304
  return ` ${one.measure}: ${count(one.count, singular, plural)}, ceiling ${String(one.ceiling)}`;
311
305
  };
312
- const renderCampaigns = (report) => renderCampaignReports(report.campaigns, report.violations.flatMap((one) => one.kind === "campaign" &&
306
+ const renderCampaigns = (policy, report) => policy.campaigns.renderReports(report.campaigns, report.violations.flatMap((one) => one.kind === "campaign" &&
313
307
  one.objective !== undefined &&
314
308
  one.sector !== undefined &&
315
309
  one.entry !== undefined
@@ -323,7 +317,7 @@ const renderCampaigns = (report) => renderCampaignReports(report.campaigns, repo
323
317
  },
324
318
  ]
325
319
  : []));
326
- const renderText = (report) => {
320
+ const renderText = (policy, report) => {
327
321
  const reportable = report.violations.filter((one) => one.kind !== "campaign" && !one.baselined);
328
322
  const carried = report.violations.filter((one) => one.kind !== "campaign").length - reportable.length;
329
323
  const shortfalls = shortfallsOf(report.coverage);
@@ -373,20 +367,19 @@ const renderText = (report) => {
373
367
  "",
374
368
  " architecture conformance # which files, nodes and allowances",
375
369
  ]),
376
- ...renderCampaigns(report),
370
+ ...renderCampaigns(policy, report),
377
371
  ];
378
372
  };
379
373
  export const check = (policy, roots, options) => Effect.gen(function* () {
380
374
  const report_ = checkReport(policy, roots, options.manifestPath);
381
375
  // JSON is one object on stdout and nothing else there; the failure, when
382
376
  // there is one, is a sentence on stderr and the exit code, as in text.
383
- yield* report(options.format === "json" ? [JSON.stringify(report_, null, 2)] : renderText(report_));
384
- const failure = failureOf(report_, shortfallsOf(report_.coverage));
377
+ yield* report(options.format === "json" ? [JSON.stringify(report_, null, 2)] : renderText(policy, report_));
378
+ const failure = failureOf(policy, report_, shortfallsOf(report_.coverage));
385
379
  if (failure !== null)
386
380
  return yield* Effect.fail(failure);
387
381
  });
388
382
  const percent = (fraction) => `${String(Math.floor(fraction * 100))}%`;
389
- const count = (n, noun, plural = `${noun}s`) => `${String(n)} ${n === 1 ? noun : plural}`;
390
383
  // The conformance snapshot: `check`'s report grown with what no family
391
384
  // reaches, what the allowlists permit and nothing uses, the cycle count and
392
385
  // the size of the debt — the whole distance between the tree and the
@@ -407,7 +400,7 @@ export const snapshotOf = (policy, roots, manifestPath) => {
407
400
  const byHeight = heightOfViolation(left) - heightOfViolation(right);
408
401
  return byHeight !== 0 ? byHeight : left.fingerprint.localeCompare(right.fingerprint);
409
402
  });
410
- const campaigns = snapshotCampaignsOf(policy, findings.campaigns);
403
+ const campaigns = policy.campaigns.snapshotOf(policy, findings.campaigns);
411
404
  return {
412
405
  version: SNAPSHOT_VERSION,
413
406
  manifest: report_.manifest,
@@ -441,7 +434,7 @@ const unreachedMark = (covered, total, ceiling) => {
441
434
  return ` ${String(left)} unreached ≤ ${String(ceiling)} ✓${left < ceiling ? `, lower it to ${String(left)}` : ""}`;
442
435
  };
443
436
  const outsideNote = (outside) => outside === 0 ? "" : `, ${count(outside, "file")} outside the limits`;
444
- const renderSnapshot = (snapshot) => {
437
+ const renderSnapshot = (policy, snapshot) => {
445
438
  const reportable = snapshot.violations.filter((one) => !one.baselined && !one.ledgered);
446
439
  const carried = snapshot.violations.length - reportable.length;
447
440
  const row = (family) => {
@@ -512,7 +505,7 @@ const renderSnapshot = (snapshot) => {
512
505
  : "") +
513
506
  (snapshot.campaigns.some((one) => one.complete && one.ledgered)
514
507
  ? `, ${count(snapshot.campaigns.filter((one) => one.complete && one.ledgered).length, "complete", "complete")}`
515
- : ""), renderCampaignRows(snapshot.campaigns))),
508
+ : ""), policy.campaigns.renderRows(snapshot.campaigns))),
516
509
  "",
517
510
  `cycles: ${String(snapshot.cycles)}`,
518
511
  `baseline: ${count(snapshot.baseline.size, "entry", "entries")}`,
@@ -525,7 +518,9 @@ const renderSnapshot = (snapshot) => {
525
518
  // `ok` in the document says what `check` would have done.
526
519
  export const conformance = (policy, roots, options) => Effect.gen(function* () {
527
520
  const snapshot = snapshotOf(policy, roots, options.manifestPath);
528
- yield* report(options.format === "json" ? [JSON.stringify(snapshot, null, 2)] : renderSnapshot(snapshot));
521
+ yield* report(options.format === "json"
522
+ ? [JSON.stringify(snapshot, null, 2)]
523
+ : renderSnapshot(policy, snapshot));
529
524
  });
530
525
  // How much of the tree the policy reaches. A probe proves a rule can fire;
531
526
  // this is whether the files are there to fire on. Reported per family, with the
@@ -610,56 +605,9 @@ export const explain = (policy, file, roots = ["packages"]) => Effect.gen(functi
610
605
  .map((rule) => `${named(rule)} (reach)`),
611
606
  ];
612
607
  const section = (title, lines) => lines.length === 0 ? [] : ["", title, ...lines];
613
- // Each campaign selecting the file: the sector the file is in, its
614
- // phase and definedness, the objectives in window that fire on it and
615
- // the nearest remaining holdouts — which needs the campaign evaluated
616
- // over its files, since a sector's phase is derived from all of them —
617
- // then each objective's truth table: one line per leaf term and what
618
- // it answered here, so a detector that "should fire" and does not shows
619
- // which term is not saying what its author thinks.
620
- const selectedCampaigns = campaignsSelecting(campaignsOf(policy).campaignRules, relative);
621
- const evaluations = selectedCampaigns.length === 0 ? [] : collectFindings(policy, roots).campaigns;
622
- const campaignLines = selectedCampaigns.flatMap((rule) => {
623
- const at = path.join(policy.repoRoot, relative);
624
- const text = existsSync(at) ? readFileSync(at, "utf8") : "";
625
- const input = {
626
- file: relative,
627
- text,
628
- facts: policy.extractor.factsOf(relative, text),
629
- resolver: policy.resolver,
630
- fileSystem: policy.fileSystem,
631
- syntax: policy.syntax.parse(relative, text),
632
- functions: campaignsOf(policy).functions,
633
- reports: campaignsOf(policy).reports,
634
- };
635
- const evaluation = evaluations.find((one) => one.rule.id === rule.id);
636
- return [
637
- ...(evaluation === undefined ? [] : explainCampaignLines(policy, evaluation, relative)),
638
- ...rule.objectives.flatMap((objective) => {
639
- const measure = objective.measure;
640
- if (measure !== null) {
641
- // A scalar: what this file adds to its sector's number.
642
- if (measure.kind === "command")
643
- return [];
644
- const parts = measureFile(measure, input);
645
- const here = measure.kind === "ratio"
646
- ? `${String(parts.of)} of ${String(parts.per)} here`
647
- : `${String(valueOfParts(measure, parts))} here`;
648
- return [
649
- ` ${objective.name} — ${firstSentence(objective.intent ?? objective.message)} (measure, ${objective.direction}; ${here})`,
650
- ];
651
- }
652
- const table = explainObjective(objective, input);
653
- if (table.length === 0)
654
- return [];
655
- const fired = table.length > 0 && table.every((line) => line.answer);
656
- return [
657
- ` ${objective.name} — ${firstSentence(objective.intent ?? objective.message)} (${objective.holdout}; ${fired ? "fires here" : "no hit"})`,
658
- ...table.map((line) => ` ${line.answer ? "✓" : "✗"} ${line.term}${line.count === undefined ? "" : ` (${String(line.count)})`}`),
659
- ];
660
- }),
661
- ];
662
- });
608
+ // The campaigns selecting the file, evaluated over their files only
609
+ // when one does: a sector's phase is derived from all of them.
610
+ const campaignLines = policy.campaigns.explainLines(policy, relative, () => collectFindings(policy, roots).campaigns);
663
611
  yield* report([
664
612
  relative,
665
613
  "",
@@ -865,485 +813,21 @@ export const migrate = (repoRoot, configFilename) => Effect.gen(function* () {
865
813
  `Then delete ${path.basename(from)}: a repository with two manifests is refused.`,
866
814
  ]);
867
815
  });
868
- // The campaign commands. `campaigns` alone is the status table; `status
869
- // --changed` is the nudge; `attest` and `note` write a sector's record;
870
- // `history` replays the ledgers' git history. The ledgers themselves are
871
- // written by `objectives clear` (the ledger reconciled with the code
872
- // wherever that is not a regression) and `objectives concede` (the one way
873
- // a holdout is added by hand, with a reason).
874
- const flagOf = (argv, flag) => {
875
- const at = argv.indexOf(flag);
876
- const value = at === -1 ? undefined : argv[at + 1];
877
- return value === undefined || value.startsWith("--") ? undefined : value;
878
- };
879
- const CAMPAIGN_SUBCOMMANDS = ["status", "attest", "note", "history", "clear", "concede"];
880
- const OBJECTIVE_SUBCOMMANDS = ["clear", "concede"];
881
- const VALUE_FLAGS = [
882
- "--reason",
883
- "--by",
884
- "--holdouts",
885
- "--entries",
886
- "--sector",
887
- "--campaign",
888
- "--evidence",
889
- "--base",
890
- "--since",
891
- "--hotfix",
892
- ];
893
- // The verbs the family shipped with, refused by name: each has a new name
894
- // or no place.
895
- const RETIRED = {
896
- init: "`campaigns init` is gone: `objectives clear <campaign>` writes a first ledger, recording each sector's initial.",
897
- prune: "`campaigns prune` is now `objectives clear`.",
898
- allow: "`campaigns allow` is now `objectives concede`.",
899
- };
900
- // The positionals after the subcommand, with the value flags and their
901
- // values stepped over: whatever is left names the roots to walk, as it
902
- // does for every other command.
903
- const positionalsOf = (argv) => {
904
- const positional = [];
905
- for (let at = 0; at < argv.length; at += 1) {
906
- const one = argv[at] ?? "";
907
- if (VALUE_FLAGS.includes(one)) {
908
- at += 1;
909
- continue;
910
- }
911
- if (!one.startsWith("--"))
912
- positional.push(one);
913
- }
914
- return positional;
915
- };
916
- // `campaigns [status | attest <sector> <phase> | note <sector> "<text>" |
917
- // history [<campaign>]] [roots…]`: the subcommand and its arguments come
918
- // first.
919
- export const campaignArgsOf = (argv, ids) => {
920
- const positional = positionalsOf(argv);
921
- const [first, ...rest] = positional;
922
- if (first === undefined)
923
- return { subcommand: undefined, args: [], roots: [] };
924
- if (first in RETIRED)
925
- return { subcommand: first, args: [], roots: rest };
926
- if (!CAMPAIGN_SUBCOMMANDS.includes(first)) {
927
- return { subcommand: undefined, args: [], roots: positional };
928
- }
929
- switch (first) {
930
- case "attest":
931
- return { subcommand: first, args: rest.slice(0, 2), roots: rest.slice(2) };
932
- case "note":
933
- return { subcommand: first, args: rest.slice(0, 2), roots: rest.slice(2) };
934
- case "history": {
935
- const [second] = rest;
936
- return second !== undefined && ids.includes(second)
937
- ? { subcommand: first, args: [second], roots: rest.slice(1) }
938
- : { subcommand: first, args: [], roots: rest };
939
- }
940
- case "clear":
941
- case "concede": {
942
- const [second] = rest;
943
- return second !== undefined && ids.includes(second.split("/")[0] ?? "")
944
- ? { subcommand: first, args: [second], roots: rest.slice(1) }
945
- : { subcommand: first, args: [], roots: rest };
946
- }
947
- default:
948
- return { subcommand: first, args: [], roots: rest };
949
- }
950
- };
951
- // `objectives [clear [<campaign>[/<objective>]] | concede <campaign>[/<objective>]
952
- // --reason <text>] [roots…]`.
953
- export const objectiveArgsOf = (argv, ids) => {
954
- const positional = positionalsOf(argv);
955
- const [first, second, ...rest] = positional;
956
- if (first === undefined || !OBJECTIVE_SUBCOMMANDS.includes(first)) {
957
- return { subcommand: first, target: null, roots: positional.slice(1) };
958
- }
959
- const [campaign = "", objective] = (second ?? "").split("/");
960
- const named = second !== undefined && ids.includes(campaign);
961
- return {
962
- subcommand: first,
963
- target: named ? { campaign, objective: objective ?? null } : null,
964
- roots: named ? rest : second === undefined ? [] : [second, ...rest],
965
- };
966
- };
967
- // The one campaign, when there is one, else the one `--campaign` names.
968
- const campaignFor = (policy, argv) => {
969
- const named = flagOf(argv, "--campaign");
970
- if (named !== undefined) {
971
- const found = campaignsOf(policy).campaignRules.find((rule) => rule.id === named);
972
- return found === undefined
973
- ? Result.fail(`no campaign is named "${named}"`)
974
- : Result.succeed(found);
975
- }
976
- const [only] = campaignsOf(policy).campaignRules;
977
- if (campaignsOf(policy).campaignRules.length === 1 && only !== undefined)
978
- return Result.succeed(only);
979
- return Result.fail(`this policy declares ${String(campaignsOf(policy).campaignRules.length)} campaigns; say which with --campaign <id>.`);
980
- };
981
- const objectiveFor = (rule, objective) => {
982
- if (objective !== null) {
983
- return rule.objectives.some((one) => one.id === objective)
984
- ? Result.succeed(objective)
985
- : Result.fail(`no objective of ${rule.id} is named "${objective}"`);
986
- }
987
- const [only] = rule.objectives;
988
- if (rule.objectives.length === 1 && only !== undefined)
989
- return Result.succeed(only.id);
990
- return Result.fail(`campaign ${rule.id} declares ${String(rule.objectives.length)} objectives; say which as ${rule.id}/<objective>.`);
991
- };
992
- export const objectives = (policy, defaultRoots, argv) => Effect.gen(function* () {
993
- const parsed = objectiveArgsOf(argv, campaignsOf(policy).campaignRules.map((rule) => rule.id));
994
- const roots = parsed.roots.length > 0 ? parsed.roots : defaultRoots;
995
- if (campaignsOf(policy).campaignRules.length === 0) {
996
- return yield* report(["this policy declares no campaigns."]);
997
- }
998
- const evaluations = () => collectFindings(policy, roots).campaigns;
999
- switch (parsed.subcommand) {
1000
- case "clear": {
1001
- const targets = parsed.target === null
1002
- ? campaignsOf(policy).campaignRules
1003
- : campaignsOf(policy).campaignRules.filter((rule) => rule.id === parsed.target?.campaign);
1004
- const by = authorOf(flagOf(argv, "--by")) ?? "unknown";
1005
- const all = evaluations();
1006
- const lines = [];
1007
- for (const rule of targets) {
1008
- const evaluation = all.find((one) => one.rule.id === rule.id);
1009
- if (evaluation === undefined)
1010
- continue;
1011
- const only = parsed.target?.objective ?? null;
1012
- if (only !== null && !rule.objectives.some((one) => one.id === only)) {
1013
- return yield* Effect.fail(fail(`no objective of ${rule.id} is named "${only}"`));
1014
- }
1015
- const outcomes = yield* Effect.try({
1016
- try: () => clear(policy, evaluation, only, by),
1017
- catch: (cause) => fail(String(cause)),
1018
- });
1019
- // Where each sector went, read off the ledgers as they stood: a
1020
- // clear of one objective places no sector, so it says nothing.
1021
- const moves = only === null ? sectorMovesOf(policy, evaluation) : [];
1022
- for (const outcome of outcomes) {
1023
- const parts = [
1024
- ...(outcome.entered.length > 0
1025
- ? [
1026
- `${count(outcome.entered.length, "sector")} entered (${outcome.entered.join(", ")})`,
1027
- ]
1028
- : []),
1029
- ...(outcome.cleared > 0 ? [`${count(outcome.cleared, "holdout")} cleared`] : []),
1030
- ...(outcome.rewritten > 0
1031
- ? [`${count(outcome.rewritten, "holdout")} rewritten`]
1032
- : []),
1033
- ...(outcome.closed > 0 ? [`${count(outcome.closed, "holdout")} closed`] : []),
1034
- ...(outcome.rebaselined.length > 0
1035
- ? [
1036
- `${count(outcome.rebaselined.length, "sector")} re-baselined (${outcome.rebaselined.join(", ")})`,
1037
- ]
1038
- : []),
1039
- ];
1040
- if (outcome.measure !== undefined) {
1041
- const moved = [
1042
- ...(outcome.entered.length > 0
1043
- ? [
1044
- `${count(outcome.entered.length, "sector")} entered (${outcome.entered.join(", ")})`,
1045
- ]
1046
- : []),
1047
- ...(outcome.measure.improved.length > 0
1048
- ? [
1049
- `${count(outcome.measure.improved.length, "sector")} improved (${outcome.measure.improved
1050
- .map((one) => `${one.sector} ${String(one.from)} → ${String(one.to)}`)
1051
- .join(", ")})`,
1052
- ]
1053
- : []),
1054
- ...(outcome.measure.grown.length > 0
1055
- ? [
1056
- `${count(outcome.measure.grown.length, "sector")} grown (${outcome.measure.grown
1057
- .map((one) => `${one.sector} ${String(one.from)} → ${String(one.to)}, ${one.phase === null ? "measured, not held" : `as ${one.phase} expects`}`)
1058
- .join(", ")})`,
1059
- ]
1060
- : []),
1061
- ...(outcome.closed > 0 ? [`${count(outcome.closed, "sector")} closed`] : []),
1062
- ...(outcome.rebaselined.length > 0
1063
- ? [
1064
- `${count(outcome.rebaselined.length, "sector")} re-baselined (${outcome.rebaselined.join(", ")})`,
1065
- ]
1066
- : []),
1067
- ];
1068
- lines.push(`${outcome.campaign}/${outcome.objective}: ${moved.length === 0 ? "nothing to clear" : moved.join(", ")}; held to ${String(outcome.measure.recorded)}.`);
1069
- continue;
1070
- }
1071
- lines.push(`${outcome.campaign}/${outcome.objective}: ${parts.length === 0 ? "nothing to clear" : parts.join(", ")}; ${count(outcome.left, "holdout")} left.`);
1072
- }
1073
- for (const move of moves) {
1074
- lines.push(`${move.campaign}: ${move.sector} ${move.back ? "went back" : "moved"} ${move.from ?? "done"} → ${move.to ?? "done"}` +
1075
- (move.passed.length === 0
1076
- ? "."
1077
- : `, passing ${move.passed.join(", ")} in the same clear: nothing there was ever counted for it.`));
1078
- }
1079
- }
1080
- return yield* report(lines);
1081
- }
1082
- case "concede": {
1083
- if (parsed.target === null) {
1084
- return yield* Effect.fail(fail("objectives concede needs a campaign: `objectives concede <campaign>[/<objective>] --reason <text>`"));
1085
- }
1086
- const rule = campaignsOf(policy).campaignRules.find((one) => one.id === parsed.target?.campaign);
1087
- if (rule === undefined)
1088
- return yield* Effect.fail(fail(`no campaign is named "${parsed.target.campaign}"`));
1089
- const objective = objectiveFor(rule, parsed.target.objective);
1090
- if (Result.isFailure(objective))
1091
- return yield* Effect.fail(fail(objective.failure));
1092
- const reason = flagOf(argv, "--reason");
1093
- if (reason === undefined) {
1094
- return yield* Effect.fail(fail("objectives concede needs --reason <text>: growth is recorded with why, or not at all."));
1095
- }
1096
- const by = authorOf(flagOf(argv, "--by"));
1097
- if (by === null) {
1098
- return yield* Effect.fail(fail("objectives concede needs an author: pass --by <email>, or set git's user.email."));
1099
- }
1100
- const evaluation = evaluations().find((one) => one.rule.id === rule.id);
1101
- if (evaluation === undefined)
1102
- return yield* Effect.fail(fail(`campaign ${rule.id} was not evaluated`));
1103
- // `--holdouts` concedes a subset and refuses the rest: a pull
1104
- // request that legitimately adds one hit while another is an
1105
- // accident.
1106
- const chosen = (flagOf(argv, "--holdouts") ?? flagOf(argv, "--entries"))
1107
- ?.split(",")
1108
- .map((one) => one.trim()) ?? null;
1109
- const outcome = concede(policy, evaluation, objective.success, chosen, flagOf(argv, "--sector") ?? null, {
1110
- at: policy.now,
1111
- by,
1112
- reason,
1113
- });
1114
- if (Result.isFailure(outcome))
1115
- return yield* Effect.fail(fail(outcome.failure));
1116
- // A concession that sends a sector back a phase says so, and names
1117
- // the attestations it revoked on the way.
1118
- const sentBack = outcome.success.sentBack.flatMap((one) => [
1119
- "",
1120
- `this concession moves ${one.sector} back ${one.from ?? "done"} → ${one.to ?? "done"}.`,
1121
- ...(one.revoked.length === 0
1122
- ? []
1123
- : [
1124
- `It revokes ${one.sector}'s attestation of ${one.revoked.join(", ")}: what was attested no longer holds. Once it does again, attest it anew:`,
1125
- ...one.revoked.map((phase) => ` architecture campaigns attest ${one.sector} ${phase} --reason "…" --campaign ${rule.id}`),
1126
- ]),
1127
- ]);
1128
- const scalar = rule.objectives.find((one) => one.id === objective.success)?.measure !== null;
1129
- if (scalar) {
1130
- if (outcome.success.conceded.length === 0) {
1131
- return yield* report([
1132
- `${rule.id}/${objective.success}: nothing to concede; no sector measures past its record.`,
1133
- ]);
1134
- }
1135
- return yield* report([
1136
- `${rule.id}/${objective.success}: a rise conceded in ${count(outcome.success.conceded.length, "sector")}, recorded by ${by}.`,
1137
- ...outcome.success.conceded.map((one) => ` ${one.sector} · ${one.entry}`),
1138
- ...(outcome.success.left.length === 0
1139
- ? []
1140
- : [
1141
- "",
1142
- `${count(outcome.success.left.length, "sector")} left past the record; check still fails on them.`,
1143
- ]),
1144
- ...sentBack,
1145
- ]);
1146
- }
1147
- if (outcome.success.conceded.length === 0) {
1148
- return yield* report([
1149
- `${rule.id}/${objective.success}: nothing to concede; every hit is in the ledger.`,
1150
- ]);
1151
- }
1152
- return yield* report([
1153
- `${rule.id}/${objective.success}: ${count(outcome.success.conceded.length, "holdout")} conceded, recorded by ${by}.`,
1154
- ...outcome.success.conceded.map((one) => ` ${one.sector} · ${one.entry}`),
1155
- ...(outcome.success.left.length === 0
1156
- ? []
1157
- : [
1158
- "",
1159
- `${count(outcome.success.left.length, "hit")} left unrecorded; check still fails on them.`,
1160
- ]),
1161
- ...sentBack,
1162
- ]);
1163
- }
1164
- default:
1165
- 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]`));
1166
- }
1167
- });
1168
- export const campaigns = (policy, defaultRoots, argv, configFilename) => Effect.gen(function* () {
1169
- const parsed = campaignArgsOf(argv, campaignsOf(policy).campaignRules.map((rule) => rule.id));
1170
- const roots = parsed.roots.length > 0 ? parsed.roots : defaultRoots;
1171
- if (campaignsOf(policy).campaignRules.length === 0) {
1172
- return yield* report(["this policy declares no campaigns."]);
1173
- }
1174
- const json = argv.includes("--json");
1175
- const retired = parsed.subcommand === undefined ? undefined : RETIRED[parsed.subcommand];
1176
- if (retired !== undefined)
1177
- return yield* Effect.fail(fail(retired));
1178
- // A positional that is no path is refused, not walked: most often it is
1179
- // a sector's name, and `--sector` is what asks about one.
1180
- for (const root of parsed.roots) {
1181
- if (existsSync(path.resolve(policy.repoRoot, root)))
1182
- continue;
1183
- const named = collectFindings(policy, defaultRoots).campaigns.some((one) => one.sectors.has(root));
1184
- return yield* Effect.fail(fail(`campaigns: ${root} is not a path in the repository, so there is nothing to walk there.` +
1185
- (named ? ` It names a sector: \`campaigns status --sector ${root}\`.` : "")));
1186
- }
1187
- const sector = flagOf(argv, "--sector");
1188
- if (parsed.subcommand === "status" && sector !== undefined) {
1189
- const only = argv.includes("--campaign") ? campaignFor(policy, argv) : null;
1190
- if (only !== null && Result.isFailure(only))
1191
- return yield* Effect.fail(fail(only.failure));
1192
- const views = collectFindings(policy, roots)
1193
- .campaigns.filter((one) => only === null || one.rule.id === only.success.id)
1194
- .flatMap((one) => {
1195
- const view = sectorViewOf(policy, one, sector);
1196
- return view === null ? [] : [view];
1197
- });
1198
- if (views.length === 0) {
1199
- return yield* Effect.fail(fail(`campaigns: no campaign under ${roots.join(", ")} has a sector named ${sector}.`));
1200
- }
1201
- return yield* report(json
1202
- ? [JSON.stringify({ version: 1, sectors: views }, null, 2)]
1203
- : views.flatMap((view, i) => [...(i === 0 ? [] : [""]), ...renderSectorView(view)]));
1204
- }
1205
- // `status` without `--changed` is the overview, in text or JSON: the
1206
- // nudge alone is scoped to a diff.
1207
- const subcommand = parsed.subcommand === "status" && !argv.includes("--changed") ? undefined : parsed.subcommand;
1208
- switch (subcommand) {
1209
- case undefined: {
1210
- const snapshot = snapshotOf(policy, roots, manifestPathOf(policy.repoRoot, configFilename));
1211
- if (json) {
1212
- // Each campaign as the conformance snapshot carries it: its
1213
- // burn-down, its ladder, and a row per sector.
1214
- return yield* report([
1215
- JSON.stringify({ version: 1, campaigns: snapshot.campaigns }, null, 2),
1216
- ]);
1217
- }
1218
- return yield* report([
1219
- `${count(snapshot.campaigns.length, "campaign")} under ${roots.join(", ")}`,
1220
- "",
1221
- ...renderCampaignRows(snapshot.campaigns),
1222
- "",
1223
- " architecture campaigns status --changed [--base <ref>] [--json] # what a diff touches, and what to do",
1224
- " architecture campaigns status --sector <sector> [--json] # where one sector stands, and what holds it",
1225
- " architecture objectives clear [<campaign>[/<objective>]] # reconcile the ledgers with the code",
1226
- ' architecture objectives concede <campaign>[/<objective>] --reason "<why>" # record why a count may rise',
1227
- ' architecture campaigns attest <sector> <phase> --reason "<why>" [--evidence <url>]',
1228
- ' architecture campaigns note <sector> "<text>"',
1229
- " architecture campaigns history [<campaign>] [--since <ref>]",
1230
- ]);
1231
- }
1232
- case "status": {
1233
- const base = flagOf(argv, "--base") ?? null;
1234
- const diff = yield* Effect.try({
1235
- try: () => readDiff(policy.repoRoot, base),
1236
- catch: (cause) => fail(`could not read the diff: ${String(cause)}`),
1237
- });
1238
- const current = collectFindings(policy, roots).campaigns;
1239
- const baseSide = base === null
1240
- ? null
1241
- : yield* Effect.tryPromise({
1242
- try: () => baseSideAt(policy, base, roots, (repoRoot) => loadPolicyFromFile(repoRoot, configFilename)),
1243
- catch: (cause) => fail(`could not evaluate the base tree at ${base}: ${String(cause)}`),
1244
- });
1245
- // The ledger mode's "before" for work done ahead of the plan is the
1246
- // HEAD tree, evaluated once per commit and cached: no ledger counts
1247
- // the holdouts of a phase a sector has not entered.
1248
- const headTree = base !== null || !current.some((one) => one.rule.phases.length > 0)
1249
- ? null
1250
- : yield* Effect.tryPromise({
1251
- try: () => baseSideAt(policy, "HEAD", roots, (repoRoot) => loadPolicyFromFile(repoRoot, configFilename)),
1252
- catch: (cause) => fail(`could not evaluate the tree at HEAD: ${String(cause)}`),
1253
- });
1254
- const hotfix = flagOf(argv, "--hotfix") ?? null;
1255
- const nudge = nudgeOf(policy, current, diff, baseSide, hotfix, hotfix === null ? null : authorOf(flagOf(argv, "--by")), headTree);
1256
- yield* report(json ? [JSON.stringify(nudge, null, 2)] : renderNudge(nudge, policy.now));
1257
- if (!nudge.ok) {
1258
- const ahead = nudge.sectors.some((one) => one.verdict === "ahead");
1259
- const back = nudge.sectors.some((one) => one.verdict !== "ok" && one.verdict !== "ahead" && one.verdict !== "hotfix");
1260
- return yield* Effect.fail(fail([
1261
- ...(back ? ["the diff sends a sector back under its onTouch"] : []),
1262
- ...(ahead ? ["the diff works ahead of a sector's phase under its onAhead"] : []),
1263
- ].join("; ")));
1264
- }
1265
- return;
1266
- }
1267
- case "attest": {
1268
- const [sector, phase] = parsed.args;
1269
- if (sector === undefined || phase === undefined) {
1270
- return yield* Effect.fail(fail('campaigns attest needs a sector and a phase: `campaigns attest <sector> <phase> --reason "<why>"`'));
1271
- }
1272
- const reason = flagOf(argv, "--reason");
1273
- if (reason === undefined)
1274
- return yield* Effect.fail(fail("campaigns attest needs --reason <text>."));
1275
- const by = authorOf(flagOf(argv, "--by"));
1276
- if (by === null)
1277
- return yield* Effect.fail(fail("campaigns attest needs an author: pass --by <email>, or set git's user.email."));
1278
- const rule = campaignFor(policy, argv);
1279
- if (Result.isFailure(rule))
1280
- return yield* Effect.fail(fail(rule.failure));
1281
- const evaluation = collectFindings(policy, roots).campaigns.find((one) => one.rule.id === rule.success.id);
1282
- if (evaluation === undefined)
1283
- return yield* Effect.fail(fail(`campaign ${rule.success.id} was not evaluated`));
1284
- const written = attest(policy, evaluation, sector, phase, {
1285
- reason,
1286
- evidence: flagOf(argv, "--evidence"),
1287
- by,
1288
- });
1289
- if (Result.isFailure(written))
1290
- return yield* Effect.fail(fail(written.failure));
1291
- return yield* report([
1292
- `${rule.success.id}: sector ${sector} attested at ${phase} by ${by}, in ${written.success}.`,
1293
- "Run `objectives clear` to move it on.",
1294
- ]);
1295
- }
1296
- case "note": {
1297
- const [sector, text] = parsed.args;
1298
- if (sector === undefined || text === undefined) {
1299
- return yield* Effect.fail(fail('campaigns note needs a sector and a text: `campaigns note <sector> "<text>"`'));
1300
- }
1301
- const by = authorOf(flagOf(argv, "--by"));
1302
- if (by === null)
1303
- return yield* Effect.fail(fail("campaigns note needs an author: pass --by <email>, or set git's user.email."));
1304
- const rule = campaignFor(policy, argv);
1305
- if (Result.isFailure(rule))
1306
- return yield* Effect.fail(fail(rule.failure));
1307
- const evaluation = collectFindings(policy, roots).campaigns.find((one) => one.rule.id === rule.success.id);
1308
- if (evaluation === undefined)
1309
- return yield* Effect.fail(fail(`campaign ${rule.success.id} was not evaluated`));
1310
- const written = note(policy, evaluation, sector, text, by);
1311
- if (Result.isFailure(written))
1312
- return yield* Effect.fail(fail(written.failure));
1313
- return yield* report([
1314
- `${rule.success.id}: note left on ${sector}, in ${written.success}.`,
1315
- ]);
1316
- }
1317
- case "history": {
1318
- const [named] = parsed.args;
1319
- const targets = named === undefined
1320
- ? campaignsOf(policy).campaignRules
1321
- : campaignsOf(policy).campaignRules.filter((rule) => rule.id === named);
1322
- const manifestPath = path
1323
- .relative(policy.repoRoot, manifestPathOf(policy.repoRoot, configFilename))
1324
- .replaceAll(path.sep, "/");
1325
- const lines = [];
1326
- for (const rule of targets) {
1327
- const rows = historyOf(policy, rule, flagOf(argv, "--since") ?? null, [manifestPath]);
1328
- if (json) {
1329
- lines.push(JSON.stringify({ campaign: rule.id, rows }, null, 2));
1330
- continue;
1331
- }
1332
- if (lines.length > 0)
1333
- lines.push("");
1334
- for (const line of renderHistory(rule, rows))
1335
- lines.push(line);
1336
- }
1337
- return yield* report(lines);
1338
- }
1339
- case "clear":
1340
- case "concede":
1341
- // The ledger verbs answer under `objectives`; accepted here too.
1342
- return yield* objectives(policy, defaultRoots, argv);
1343
- default:
1344
- 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>]`));
1345
- }
816
+ // The campaign commands, answered by the family the host was composed with:
817
+ // `campaigns` alone is the status table, `status --changed` the nudge,
818
+ // `attest` and `note` write a sector's record, `history` replays the ledgers;
819
+ // `objectives clear` and `objectives concede` write the ledgers. Without
820
+ // `@goodbones/campaigns` installed, each says what to install.
821
+ const verbContext = (policy, defaultRoots, argv, configFilename) => ({
822
+ policy,
823
+ defaultRoots,
824
+ argv,
825
+ evaluate: (roots) => collectFindings(policy, roots).campaigns,
826
+ reload: (repoRoot) => loadPolicyFromFile(repoRoot, configFilename),
827
+ manifestPath: manifestPathOf(policy.repoRoot, configFilename),
1346
828
  });
829
+ export const objectives = (policy, defaultRoots, argv) => policy.campaigns.objectives(verbContext(policy, defaultRoots, argv));
830
+ export const campaigns = (policy, defaultRoots, argv, configFilename) => policy.campaigns.campaigns(verbContext(policy, defaultRoots, argv, configFilename));
1347
831
  // The commands that judge a campaign, and so ask its report source.
1348
832
  const READS_REPORTS = new Set([
1349
833
  "check",
@@ -1393,7 +877,7 @@ configFilename) => Effect.gen(function* () {
1393
877
  // for the campaign to report; a report that does not parse is refused.
1394
878
  if (READS_REPORTS.has(command)) {
1395
879
  yield* Effect.tryPromise({
1396
- try: () => Promise.all(reportSpecsOf(campaignsOf(policy).campaignRules).map((spec) => campaignsOf(policy).reports.read?.(spec))),
880
+ try: () => policy.campaigns.readReports(policy),
1397
881
  catch: (cause) => fail(String(cause)),
1398
882
  });
1399
883
  }