@goodbones/cli 0.1.0-beta.6 → 0.1.0-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/run.ts CHANGED
@@ -1,22 +1,30 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { createHash } from "node:crypto";
2
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
4
  import * as path from "node:path";
4
5
 
5
6
  import {
7
+ allowed,
6
8
  type Baseline,
7
9
  baselineOf,
10
+ type CampaignHit,
11
+ campaignsSelecting,
12
+ type CompiledCampaign,
8
13
  type CoverageFamily,
9
14
  coverageOf,
10
15
  cyclesIn,
11
16
  decodeBaseline,
12
17
  decodeManifest,
13
18
  EMPTY_BASELINE,
19
+ entryOf,
20
+ evaluateCampaigns,
14
21
  evaluateGraph,
15
22
  evaluateMemberSite,
16
23
  evaluateResolvedEdge,
17
24
  evaluateSelectedBindings,
18
25
  evaluateStructure,
19
26
  evaluateSurface,
27
+ explainCampaign,
20
28
  exportRulesSelecting,
21
29
  findManifestFile,
22
30
  fingerprintOf,
@@ -26,20 +34,31 @@ import {
26
34
  type Graph,
27
35
  hasGraphRules,
28
36
  heightOf,
37
+ isComplete,
38
+ isStalled,
39
+ leafTermsOf,
40
+ type Ledger,
41
+ ledgerArithmeticHolds,
42
+ ledgerOf,
29
43
  listSourceFiles,
30
44
  makeBaselineFilter,
31
45
  MANIFEST_FILENAMES,
32
46
  MANIFEST_SCHEMA_ID,
33
47
  memberRulesSelecting,
34
48
  type ObservedEdge,
49
+ progressOf,
50
+ pruned,
35
51
  readManifestFile,
52
+ reconcile,
36
53
  requiredSiblingsOf,
37
54
  residueOf,
38
55
  rulesSelecting,
39
56
  serializeBaseline,
57
+ serializeLedger,
40
58
  slackOf,
41
59
  type Snapshot,
42
60
  SNAPSHOT_VERSION,
61
+ type SnapshotCampaign,
43
62
  type SourceFacts,
44
63
  staleEntriesOf,
45
64
  surfaceRulesSelecting,
@@ -61,10 +80,10 @@ import { sourceFactsOf } from "./source-facts.js";
61
80
  // second way to ask the same question — and the only way to write a baseline,
62
81
  // since that needs every finding at once rather than one file at a time.
63
82
  //
64
- // It covers all four families. The two that need a syntax tree read TypeScript's
65
- // rather than oxlint's; both adapters meet at the same vocabulary — a specifier,
66
- // a binding, a member site — so they answer to the same core rather than to each
67
- // other.
83
+ // It covers all four families. The ones that need a syntax tree read it through
84
+ // the language pack's own parse rather than oxlint's; both adapters meet at the
85
+ // same vocabulary — a specifier, a binding, a member site — so they answer to
86
+ // the same core rather than to each other.
68
87
 
69
88
  export type CliFailure = { readonly _tag: "CliFailure"; readonly message: string };
70
89
 
@@ -80,6 +99,10 @@ export type UnresolvedEdge = {
80
99
 
81
100
  export type Findings = {
82
101
  readonly violations: ReadonlyArray<Violation>;
102
+ // Every campaign hit, ledgered or not. Kept apart from the violations: a
103
+ // hit is debt a campaign is paying down, judged against its ledger rather
104
+ // than the baseline.
105
+ readonly campaigns: ReadonlyArray<CampaignHit>;
83
106
  readonly unresolved: ReadonlyArray<UnresolvedEdge>;
84
107
  readonly files: number;
85
108
  // Every edge resolved from a file under an import rule — what the slack
@@ -103,16 +126,25 @@ export const collectFindings = (
103
126
  ): Findings => {
104
127
  const files = listSourceFiles(policy.repoRoot, roots, policy.languages);
105
128
  const violations: Array<Violation> = [];
129
+ const campaigns: Array<CampaignHit> = [];
106
130
  const unresolved: Array<UnresolvedEdge> = [];
107
131
  const edges: Array<ObservedEdge> = [];
108
132
 
109
- // Each file is parsed at most once, whether the per-file families or the
110
- // graph pass asks first.
133
+ // Each file is read and parsed at most once, whether the per-file
134
+ // families, the graph pass or a campaign asks first.
135
+ const texts = new Map<string, string>();
136
+ const textOf = (file: string): string => {
137
+ const cached = texts.get(file);
138
+ if (cached !== undefined) return cached;
139
+ const text = readFileSync(path.join(policy.repoRoot, file), "utf8");
140
+ texts.set(file, text);
141
+ return text;
142
+ };
111
143
  const parsed = new Map<string, SourceFacts>();
112
144
  const factsOf = (file: string): SourceFacts => {
113
145
  const cached = parsed.get(file);
114
146
  if (cached !== undefined) return cached;
115
- const facts = sourceFactsOf(policy.repoRoot, file, policy.extractor);
147
+ const facts = policy.extractor.factsOf(file, textOf(file));
116
148
  parsed.set(file, facts);
117
149
  return facts;
118
150
  };
@@ -132,6 +164,31 @@ export const collectFindings = (
132
164
  violations.push(violation);
133
165
  }
134
166
 
167
+ // A campaign selects by its scope. The file is parsed by the scope's
168
+ // matcher once, and only when a term of some selected campaign reads the
169
+ // syntax tree.
170
+ const selectedCampaigns = campaignsSelecting(policy.campaignRules, file);
171
+ if (selectedCampaigns.length > 0) {
172
+ const text = textOf(file);
173
+ const needsSyntax = selectedCampaigns.some((rule) =>
174
+ leafTermsOf(rule.detect).some(
175
+ (leaf) => leaf === "syntax" || leaf === "report" || leaf === "fn",
176
+ ),
177
+ );
178
+ for (const hit of evaluateCampaigns(selectedCampaigns, {
179
+ file,
180
+ text,
181
+ facts: factsOf(file),
182
+ resolver: policy.resolver,
183
+ fileSystem: policy.fileSystem,
184
+ syntax: needsSyntax ? policy.syntax.parse(file, text) : null,
185
+ functions: policy.functions,
186
+ reports: policy.reports,
187
+ })) {
188
+ campaigns.push(hit);
189
+ }
190
+ }
191
+
135
192
  const selectedImports = rulesSelecting(policy.importRules, file);
136
193
  const selectedExports = exportRulesSelecting(policy.exportRules, file);
137
194
  const selectedMembers = memberRulesSelecting(policy.memberRules, file);
@@ -186,7 +243,7 @@ export const collectFindings = (
186
243
  }
187
244
  }
188
245
 
189
- return { violations, unresolved, files: files.length, edges, graph };
246
+ return { violations, campaigns, unresolved, files: files.length, edges, graph };
190
247
  };
191
248
 
192
249
  const baselinePathOf = (policy: LoadedPolicy): string | null =>
@@ -218,6 +275,29 @@ const describe = (violation: Violation): string =>
218
275
  export type ReportedViolation = Violation & {
219
276
  readonly fingerprint: string;
220
277
  readonly baselined: boolean;
278
+ // For a campaign hit: carried by the campaign's ledger, so `check` does
279
+ // not fail on it. The campaign analogue of `baselined`.
280
+ readonly ledgered: boolean;
281
+ };
282
+
283
+ // One campaign, as `check` sees it: how many hits, which are new, which
284
+ // ledger entries no longer fire, and whether the ledger adds up.
285
+ export type CampaignReport = {
286
+ readonly id: string;
287
+ readonly count: number;
288
+ // Hits the ledger does not carry — unrecorded growth, as entries.
289
+ readonly new: ReadonlyArray<string>;
290
+ // Ledger entries no hit produces — fixed, and waiting to be pruned.
291
+ readonly stale: ReadonlyArray<string>;
292
+ // Entries whose hash moved under a still-present anchor.
293
+ readonly drifted: number;
294
+ // No ledger file: `campaigns init` has not been run.
295
+ readonly missingLedger: boolean;
296
+ // `entries.length === initial + Σ delta − fixed`.
297
+ readonly arithmetic: boolean;
298
+ readonly complete: boolean;
299
+ readonly stalled: boolean;
300
+ readonly onComplete: "keep" | "remove";
221
301
  };
222
302
 
223
303
  export type CoverageReport = Readonly<
@@ -245,8 +325,77 @@ export type CheckReport = {
245
325
  readonly unrestricted: ReadonlyArray<string>;
246
326
  readonly partial: ReadonlyArray<string>;
247
327
  };
328
+ readonly campaigns: ReadonlyArray<CampaignReport>;
248
329
  };
249
330
 
331
+ // Each campaign's hits against its ledger. A campaign with no ledger has
332
+ // every hit new; one with no hits and no ledger has nothing to say.
333
+ const campaignReportsOf = (
334
+ policy: LoadedPolicy,
335
+ hits: ReadonlyArray<CampaignHit>,
336
+ ): ReadonlyArray<CampaignReport> =>
337
+ policy.campaignRules.map((rule) => {
338
+ const own = hits.filter((hit) => hit.campaign === rule.id).map((hit) => hit.violation);
339
+ const ledger = policy.ledgers.get(rule.id);
340
+ if (ledger === undefined) {
341
+ return {
342
+ id: rule.id,
343
+ count: own.length,
344
+ new: [...new Set(own.map(entryOf))].sort(),
345
+ stale: [],
346
+ drifted: 0,
347
+ missingLedger: true,
348
+ arithmetic: true,
349
+ complete: own.length === 0,
350
+ stalled: false,
351
+ onComplete: rule.onComplete,
352
+ };
353
+ }
354
+ const state = reconcile(ledger, own, rule.unit);
355
+ return {
356
+ id: rule.id,
357
+ count: own.length,
358
+ new: [...new Set(state.unrecorded.map(entryOf))].sort(),
359
+ stale: state.stale,
360
+ drifted: state.drifted.length,
361
+ missingLedger: false,
362
+ arithmetic: ledgerArithmeticHolds(ledger),
363
+ complete: isComplete(ledger) && own.length === 0,
364
+ stalled: isStalled(rule, ledger, policy.now),
365
+ onComplete: rule.onComplete,
366
+ };
367
+ });
368
+
369
+ // Whether a campaign hit is carried by its ledger — exactly, or by anchor.
370
+ const ledgeredFilter = (
371
+ policy: LoadedPolicy,
372
+ hits: ReadonlyArray<CampaignHit>,
373
+ ): ((hit: CampaignHit) => boolean) => {
374
+ const carried = new Set<Violation>();
375
+ for (const rule of policy.campaignRules) {
376
+ const ledger = policy.ledgers.get(rule.id);
377
+ if (ledger === undefined) continue;
378
+ const own = hits.filter((hit) => hit.campaign === rule.id).map((hit) => hit.violation);
379
+ for (const one of reconcile(ledger, own, rule.unit).ledgered) carried.add(one);
380
+ }
381
+ return (hit) => carried.has(hit.violation);
382
+ };
383
+
384
+ // Why a campaign report is not ok, in the order `check` explains it.
385
+ const campaignFailuresOf = (campaigns: ReadonlyArray<CampaignReport>): ReadonlyArray<string> => [
386
+ ...campaigns.filter((one) => one.stale.length > 0).map(() => "stale ledger entries"),
387
+ ...campaigns.filter((one) => !one.arithmetic).map(() => "ledger arithmetic does not hold"),
388
+ ...campaigns
389
+ .filter((one) => one.missingLedger && one.count > 0)
390
+ .map((one) => `campaign ${one.id} has no ledger`),
391
+ ...campaigns
392
+ .filter((one) => !one.missingLedger && one.new.length > 0)
393
+ .map(() => "unrecorded campaign growth"),
394
+ ...campaigns
395
+ .filter((one) => one.complete && !one.missingLedger && one.onComplete === "remove")
396
+ .map((one) => `campaign ${one.id} is complete and declared onComplete: remove`),
397
+ ];
398
+
250
399
  const COVERAGE_FAMILIES: ReadonlyArray<CoverageFamily> = [
251
400
  "imports",
252
401
  "structure",
@@ -278,11 +427,22 @@ const reportOf = (
278
427
  const baseline = readBaseline(policy);
279
428
  const stale = staleEntriesOf(baseline, findings.violations);
280
429
  const { isBaselined } = makeBaselineFilter(baseline);
281
- const violations = findings.violations.map((violation) => ({
282
- ...violation,
283
- fingerprint: fingerprintOf(violation),
284
- baselined: isBaselined(violation),
285
- }));
430
+ const isLedgered = ledgeredFilter(policy, findings.campaigns);
431
+ const violations = [
432
+ ...findings.violations.map((violation) => ({
433
+ ...violation,
434
+ fingerprint: fingerprintOf(violation),
435
+ baselined: isBaselined(violation),
436
+ ledgered: false,
437
+ })),
438
+ ...findings.campaigns.map((hit) => ({
439
+ ...hit.violation,
440
+ fingerprint: fingerprintOf(hit.violation),
441
+ baselined: false,
442
+ ledgered: isLedgered(hit),
443
+ })),
444
+ ];
445
+ const campaigns = campaignReportsOf(policy, findings.campaigns);
286
446
 
287
447
  // The floors. A policy states how much of the tree it reaches, per
288
448
  // family; falling under is a policy that quietly stopped covering files.
@@ -305,7 +465,7 @@ const reportOf = (
305
465
  ) as CoverageReport;
306
466
  const shortfalls = shortfallsOf(coverage);
307
467
 
308
- const reportable = violations.filter((one) => !one.baselined).length;
468
+ const reportable = violations.filter((one) => !one.baselined && !one.ledgered).length;
309
469
  return {
310
470
  version: 1,
311
471
  files: findings.files,
@@ -314,7 +474,8 @@ const reportOf = (
314
474
  reportable === 0 &&
315
475
  findings.unresolved.length === 0 &&
316
476
  stale.length === 0 &&
317
- shortfalls.length === 0,
477
+ shortfalls.length === 0 &&
478
+ campaignFailuresOf(campaigns).length === 0,
318
479
  manifest: {
319
480
  path: path.relative(policy.repoRoot, manifestPath).replaceAll(path.sep, "/"),
320
481
  sha256: sha256Of(manifestPath),
@@ -327,6 +488,7 @@ const reportOf = (
327
488
  unrestricted: policy.adoption.unrestricted,
328
489
  partial: policy.adoption.partial,
329
490
  },
491
+ campaigns,
330
492
  };
331
493
  };
332
494
 
@@ -351,14 +513,80 @@ const failureOf = (
351
513
  shortfalls: ReadonlyArray<Shortfall>,
352
514
  ): CliFailure | null => {
353
515
  if (report.stale.length > 0) return fail("stale baseline entries");
516
+ const [campaignFailure] = campaignFailuresOf(report.campaigns);
517
+ if (campaignFailure !== undefined) return fail(campaignFailure);
354
518
  if (shortfalls.length > 0) return fail("coverage below floor");
355
519
  if (report.ok) return null;
356
520
  return fail("architecture violations");
357
521
  };
358
522
 
523
+ // A campaign hit, with the campaign's `how` as its instruction.
524
+ const describeHit = (violation: ReportedViolation): string =>
525
+ ` ${violation.file}${violation.subject === null ? "" : ` (${violation.subject})`}\n ${formatMessage(violation)}`;
526
+
527
+ const renderCampaigns = (report: CheckReport): ReadonlyArray<string> => {
528
+ const hits = report.violations.filter((one) => one.kind === "campaign");
529
+ return report.campaigns.flatMap((campaign): ReadonlyArray<string> => {
530
+ const rule = `campaign/${campaign.id}`;
531
+ const fresh = new Set(campaign.new);
532
+ const own = hits.filter((one) => one.ruleName === rule && fresh.has(entryOf(one)));
533
+ if (campaign.missingLedger && campaign.count > 0) {
534
+ return [
535
+ "",
536
+ `campaign ${campaign.id}: ${count(campaign.count, "hit")} and no ledger. Record them before they count as growth:`,
537
+ "",
538
+ ` architecture campaigns init ${campaign.id}`,
539
+ ];
540
+ }
541
+ const complete = campaign.complete && !campaign.missingLedger;
542
+ return [
543
+ ...(campaign.new.length === 0
544
+ ? []
545
+ : [
546
+ "",
547
+ `campaign ${campaign.id}: ${count(campaign.new.length, "new hit")} the ledger does not carry. Fix them, or record why the count may rise:`,
548
+ ...own.map(describeHit),
549
+ "",
550
+ ` architecture campaigns allow ${campaign.id} --reason "<why>"`,
551
+ ]),
552
+ ...(campaign.stale.length === 0
553
+ ? []
554
+ : [
555
+ "",
556
+ `campaign ${campaign.id}: ${count(campaign.stale.length, "ledger entry", "ledger entries")} no longer fire. The code was fixed; prune them:`,
557
+ ...campaign.stale.map((entry) => ` ${entry}`),
558
+ "",
559
+ ` architecture campaigns prune ${campaign.id}`,
560
+ ]),
561
+ ...(campaign.arithmetic
562
+ ? []
563
+ : [
564
+ "",
565
+ `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\`.`,
566
+ ]),
567
+ ...(complete && campaign.onComplete === "remove"
568
+ ? [
569
+ "",
570
+ `campaign ${campaign.id} is complete and declares onComplete: remove. Delete it from the manifest, and its ledger.`,
571
+ ]
572
+ : []),
573
+ ...(campaign.stalled
574
+ ? [
575
+ "",
576
+ `notice: campaign ${campaign.id} has stalled — no entry has left its ledger within its staleAfter.`,
577
+ ]
578
+ : []),
579
+ ...(complete && campaign.onComplete === "keep"
580
+ ? ["", `notice: campaign ${campaign.id} is complete, and stays as a guard.`]
581
+ : []),
582
+ ];
583
+ });
584
+ };
585
+
359
586
  const renderText = (report: CheckReport): ReadonlyArray<string> => {
360
- const reportable = report.violations.filter((one) => !one.baselined);
361
- const carried = report.violations.length - reportable.length;
587
+ const reportable = report.violations.filter((one) => one.kind !== "campaign" && !one.baselined);
588
+ const carried =
589
+ report.violations.filter((one) => one.kind !== "campaign").length - reportable.length;
362
590
  const shortfalls = shortfallsOf(report.coverage);
363
591
  return [
364
592
  ...reportable.map(describe),
@@ -390,6 +618,7 @@ const renderText = (report: CheckReport): ReadonlyArray<string> => {
390
618
  "",
391
619
  " architecture coverage # which files no rule reaches",
392
620
  ]),
621
+ ...renderCampaigns(report),
393
622
  ];
394
623
  };
395
624
 
@@ -416,6 +645,9 @@ export const check = (
416
645
 
417
646
  const percent = (fraction: number): string => `${String(Math.floor(fraction * 100))}%`;
418
647
 
648
+ const count = (n: number, noun: string, plural = `${noun}s`): string =>
649
+ `${String(n)} ${n === 1 ? noun : plural}`;
650
+
419
651
  // The conformance snapshot: `check`'s report grown with what no family
420
652
  // reaches, what the allowlists permit and nothing uses, the cycle count and
421
653
  // the size of the debt — the whole distance between the tree and the
@@ -449,6 +681,41 @@ export const snapshotOf = (
449
681
  // that rather than as lines nobody needs.
450
682
  const { concentration, slack } = slackOf(policy.importRules, findings.edges, files);
451
683
 
684
+ const campaigns: ReadonlyArray<SnapshotCampaign> = policy.campaignRules.map((rule) => {
685
+ const ledger = policy.ledgers.get(rule.id);
686
+ const own = findings.campaigns.filter((hit) => hit.campaign === rule.id).length;
687
+ const base: SnapshotCampaign = {
688
+ id: rule.id,
689
+ ...(rule.title === null ? {} : { title: rule.title }),
690
+ ...(rule.owner === null ? {} : { owner: rule.owner }),
691
+ initial: own,
692
+ allowed: 0,
693
+ count: own,
694
+ fixed: 0,
695
+ progress: 0,
696
+ lastProgress: new Date(policy.now).toISOString(),
697
+ regressions: 0,
698
+ stalled: false,
699
+ complete: own === 0,
700
+ onComplete: rule.onComplete,
701
+ ledgered: false,
702
+ };
703
+ if (ledger === undefined) return base;
704
+ return {
705
+ ...base,
706
+ initial: ledger.initial,
707
+ allowed: ledger.regressions.reduce((sum, one) => sum + one.delta, 0),
708
+ count: ledger.entries.length,
709
+ fixed: ledger.fixed,
710
+ progress: progressOf(ledger),
711
+ lastProgress: ledger.lastProgress,
712
+ regressions: ledger.regressions.length,
713
+ stalled: isStalled(rule, ledger, policy.now),
714
+ complete: isComplete(ledger),
715
+ ledgered: true,
716
+ };
717
+ });
718
+
452
719
  return {
453
720
  version: SNAPSHOT_VERSION,
454
721
  manifest: report_.manifest,
@@ -466,14 +733,33 @@ export const snapshotOf = (
466
733
  slack,
467
734
  concentration,
468
735
  adoption: report_.adoption,
736
+ campaigns,
469
737
  };
470
738
  };
471
739
 
740
+ // The campaigns, stalled and complete first, then by progress.
741
+ const renderCampaignRows = (campaigns: ReadonlyArray<SnapshotCampaign>): ReadonlyArray<string> => {
742
+ const width = Math.max(0, ...campaigns.map((one) => one.id.length));
743
+ const state = (one: SnapshotCampaign): string =>
744
+ !one.ledgered ? "no ledger" : one.complete ? "complete" : one.stalled ? "stalled" : "";
745
+ const ordered = [...campaigns].sort((left, right) => {
746
+ const rank = (one: SnapshotCampaign): number =>
747
+ one.stalled ? 0 : one.complete && one.ledgered ? 1 : 2;
748
+ const byRank = rank(left) - rank(right);
749
+ return byRank !== 0 ? byRank : left.progress - right.progress;
750
+ });
751
+ return ordered.map(
752
+ (one) =>
753
+ ` ${one.id.padEnd(width)} ${percent(one.progress).padStart(4)} ${String(one.count).padStart(5)} left` +
754
+ ` ${String(one.fixed)} fixed ${String(one.allowed)} allowed` +
755
+ (one.owner === undefined ? "" : ` ${one.owner}`) +
756
+ (state(one) === "" ? "" : ` ${state(one)}`),
757
+ );
758
+ };
759
+
472
760
  const renderSnapshot = (snapshot: Snapshot): ReadonlyArray<string> => {
473
- const reportable = snapshot.violations.filter((one) => !one.baselined);
761
+ const reportable = snapshot.violations.filter((one) => !one.baselined && !one.ledgered);
474
762
  const carried = snapshot.violations.length - reportable.length;
475
- const count = (n: number, noun: string, plural = `${noun}s`): string =>
476
- `${String(n)} ${n === 1 ? noun : plural}`;
477
763
  const row = (family: CoverageFamily): string => {
478
764
  const { covered, floor, total } = snapshot.coverage[family];
479
765
  const fraction = total === 0 ? 1 : covered / total;
@@ -520,7 +806,7 @@ const renderSnapshot = (snapshot: Snapshot): ReadonlyArray<string> => {
520
806
  ),
521
807
  ...section(
522
808
  `violations: ${count(reportable.length, "reportable")}` +
523
- (carried > 0 ? `, ${String(carried)} carried by the baseline` : "") +
809
+ (carried > 0 ? `, ${String(carried)} carried by the baseline or a ledger` : "") +
524
810
  (snapshot.stale.length > 0
525
811
  ? `, ${count(snapshot.stale.length, "stale entry", "stale entries")}`
526
812
  : "") +
@@ -550,6 +836,18 @@ const renderSnapshot = (snapshot: Snapshot): ReadonlyArray<string> => {
550
836
  ` ${one.fragment}: ${one.kind} ${JSON.stringify(one.entry)} used at ${String(one.usedAt)} of ${count(one.of, "node")}`,
551
837
  ),
552
838
  )),
839
+ ...(snapshot.campaigns.length === 0
840
+ ? []
841
+ : section(
842
+ `campaigns: ${count(snapshot.campaigns.length, "campaign")}` +
843
+ (snapshot.campaigns.some((one) => one.stalled)
844
+ ? `, ${count(snapshot.campaigns.filter((one) => one.stalled).length, "stalled", "stalled")}`
845
+ : "") +
846
+ (snapshot.campaigns.some((one) => one.complete && one.ledgered)
847
+ ? `, ${count(snapshot.campaigns.filter((one) => one.complete && one.ledgered).length, "complete", "complete")}`
848
+ : ""),
849
+ renderCampaignRows(snapshot.campaigns),
850
+ )),
553
851
  "",
554
852
  `cycles: ${String(snapshot.cycles)}`,
555
853
  `baseline: ${count(snapshot.baseline.size, "entry", "entries")}`,
@@ -669,7 +967,8 @@ export const explain = (policy: LoadedPolicy, file: string): Effect.Effect<void,
669
967
  !rule.fileNot.some((pattern) => pattern.test(relative)),
670
968
  );
671
969
 
672
- const firstSentence = (message: string) => `${message.split(". ")[0] ?? message}.`;
970
+ const firstSentence = (message: string) =>
971
+ `${(message.split(". ")[0] ?? message).replace(/\.$/, "")}.`;
673
972
  const named = (rule: { readonly name: string; readonly message: string }): string =>
674
973
  ` ${rule.name} — ${firstSentence(rule.message)}`;
675
974
 
@@ -695,6 +994,33 @@ export const explain = (policy: LoadedPolicy, file: string): Effect.Effect<void,
695
994
  const section = (title: string, lines: ReadonlyArray<string>): ReadonlyArray<string> =>
696
995
  lines.length === 0 ? [] : ["", title, ...lines];
697
996
 
997
+ // Each campaign selecting the file, with its truth table: one line per
998
+ // leaf term and what it answered here, so a detector that "should fire"
999
+ // and does not shows which term is not saying what its author thinks.
1000
+ const selectedCampaigns = campaignsSelecting(policy.campaignRules, relative);
1001
+ const campaignLines = selectedCampaigns.flatMap((rule) => {
1002
+ const at = path.join(policy.repoRoot, relative);
1003
+ const text = existsSync(at) ? readFileSync(at, "utf8") : "";
1004
+ const input = {
1005
+ file: relative,
1006
+ text,
1007
+ facts: policy.extractor.factsOf(relative, text),
1008
+ resolver: policy.resolver,
1009
+ fileSystem: policy.fileSystem,
1010
+ syntax: policy.syntax.parse(relative, text),
1011
+ functions: policy.functions,
1012
+ reports: policy.reports,
1013
+ };
1014
+ const hits = evaluateCampaigns([rule], input);
1015
+ return [
1016
+ ` ${rule.name} — ${firstSentence(rule.why)} (${rule.unit}; ${hits.length === 0 ? "no hit" : count(hits.length, "hit")})`,
1017
+ ...explainCampaign(rule, input).map(
1018
+ (line) =>
1019
+ ` ${line.answer ? "✓" : "✗"} ${line.term}${line.count === undefined ? "" : ` (${String(line.count)})`}`,
1020
+ ),
1021
+ ];
1022
+ });
1023
+
698
1024
  yield* report([
699
1025
  relative,
700
1026
  "",
@@ -727,6 +1053,7 @@ export const explain = (policy: LoadedPolicy, file: string): Effect.Effect<void,
727
1053
  ...section(" vocabulary (members):", vocabulary.map(named)),
728
1054
  ...section(" may export (surface):", surface.map(named)),
729
1055
  ...section(" graph:", graph),
1056
+ ...section(" campaigns:", campaignLines),
730
1057
  ]);
731
1058
  });
732
1059
 
@@ -830,6 +1157,21 @@ limits:
830
1157
  unrestricted: 0
831
1158
  partial: 0
832
1159
 
1160
+ # Migrations the repository is running, each with a detector, a rationale, a
1161
+ # guide, an owner and a ledger of every place the pattern still occurs. Fill
1162
+ # one in, then \`architecture campaigns init <id>\` to write its ledger.
1163
+ # https://dataquail.github.io/goodbones/architecture-rules/manifest/campaigns/
1164
+ # campaigns:
1165
+ # - id: js-to-ts
1166
+ # why: The strict tsconfig cannot land while any src file is JavaScript.
1167
+ # how: Rename to .ts, add types at the module boundary, leave the body alone.
1168
+ # scope: ["src/**"]
1169
+ # unit: file
1170
+ # detect: { path: { file: "\\.(js|jsx)$" } }
1171
+ # probes: { fires: [{ path: src/legacy/util.js }], ignores: [{ path: src/util.ts }] }
1172
+ # staleAfter: 14d
1173
+ # onComplete: remove
1174
+
833
1175
  # The repository. One open root, reaching itself and the runtime; run
834
1176
  # \`architecture check\` to see what else it reaches, and write that down here.
835
1177
  # https://dataquail.github.io/goodbones/architecture-rules/manifest/imports/
@@ -918,6 +1260,232 @@ export const migrate = (
918
1260
  ]);
919
1261
  });
920
1262
 
1263
+ // The ledgers. `campaigns` alone is the status table; `init` writes a
1264
+ // campaign's first ledger from what fires today; `prune` removes what no
1265
+ // longer fires; `allow` is the one way an entry is added, and it records why.
1266
+ const ledgerPathOf = (policy: LoadedPolicy, id: string): string =>
1267
+ path.resolve(policy.repoRoot, policy.ledgerDir, `${id}.json`);
1268
+
1269
+ const writeLedger = (policy: LoadedPolicy, ledger: Ledger): void => {
1270
+ const at = ledgerPathOf(policy, ledger.id);
1271
+ mkdirSync(path.dirname(at), { recursive: true });
1272
+ writeFileSync(at, serializeLedger(ledger));
1273
+ };
1274
+
1275
+ const campaignNamed = (policy: LoadedPolicy, id: string): CompiledCampaign | null =>
1276
+ policy.campaignRules.find((rule) => rule.id === id) ?? null;
1277
+
1278
+ // The author of a regression: `--by`, else git's user.email, else the
1279
+ // GIT_AUTHOR_EMAIL the environment carries. Without one the record is refused
1280
+ // rather than written blank, since the record is the point.
1281
+ const authorOf = (given: string | undefined): string | null => {
1282
+ if (given !== undefined && given !== "") return given;
1283
+ try {
1284
+ const email = execFileSync("git", ["config", "user.email"], {
1285
+ encoding: "utf8",
1286
+ stdio: ["ignore", "pipe", "ignore"],
1287
+ }).trim();
1288
+ if (email !== "") return email;
1289
+ } catch {
1290
+ // git absent, or no email configured
1291
+ }
1292
+ const fromEnvironment = process.env.GIT_AUTHOR_EMAIL;
1293
+ return fromEnvironment === undefined || fromEnvironment === "" ? null : fromEnvironment;
1294
+ };
1295
+
1296
+ const flagOf = (argv: ReadonlyArray<string>, flag: string): string | undefined => {
1297
+ const at = argv.indexOf(flag);
1298
+ const value = at === -1 ? undefined : argv[at + 1];
1299
+ return value === undefined || value.startsWith("--") ? undefined : value;
1300
+ };
1301
+
1302
+ const CAMPAIGN_SUBCOMMANDS = ["init", "prune", "allow"] as const;
1303
+ const CAMPAIGN_VALUE_FLAGS = ["--reason", "--by", "--entries"] as const;
1304
+
1305
+ // `campaigns [init <id> | prune [<id>] | allow <id> --reason <text>] [roots…]`:
1306
+ // the subcommand and its id come first; whatever positional is left names
1307
+ // the roots to walk, as it does for every other command.
1308
+ export const campaignArgsOf = (
1309
+ argv: ReadonlyArray<string>,
1310
+ ids: ReadonlyArray<string>,
1311
+ ): {
1312
+ readonly subcommand: string | undefined;
1313
+ readonly id: string | undefined;
1314
+ readonly roots: ReadonlyArray<string>;
1315
+ } => {
1316
+ const positional: Array<string> = [];
1317
+ for (let at = 0; at < argv.length; at += 1) {
1318
+ const one = argv[at] ?? "";
1319
+ if ((CAMPAIGN_VALUE_FLAGS as ReadonlyArray<string>).includes(one)) {
1320
+ at += 1;
1321
+ continue;
1322
+ }
1323
+ if (!one.startsWith("--")) positional.push(one);
1324
+ }
1325
+ const [first, second, ...rest] = positional;
1326
+ if (first === undefined || !(CAMPAIGN_SUBCOMMANDS as ReadonlyArray<string>).includes(first)) {
1327
+ return { subcommand: undefined, id: undefined, roots: positional };
1328
+ }
1329
+ // `prune` takes an optional id; the next word is one only if a campaign
1330
+ // has that name, else it is a root.
1331
+ const takesId = first !== "prune" || (second !== undefined && ids.includes(second));
1332
+ return takesId
1333
+ ? { subcommand: first, id: second, roots: rest }
1334
+ : { subcommand: first, id: undefined, roots: second === undefined ? [] : [second, ...rest] };
1335
+ };
1336
+
1337
+ export const campaigns = (
1338
+ policy: LoadedPolicy,
1339
+ defaultRoots: ReadonlyArray<string>,
1340
+ argv: ReadonlyArray<string>,
1341
+ ): Effect.Effect<void, CliFailure> =>
1342
+ Effect.gen(function* () {
1343
+ const parsed = campaignArgsOf(
1344
+ argv,
1345
+ policy.campaignRules.map((rule) => rule.id),
1346
+ );
1347
+ const { id, subcommand } = parsed;
1348
+ const roots = parsed.roots.length > 0 ? parsed.roots : defaultRoots;
1349
+ if (policy.campaignRules.length === 0) {
1350
+ return yield* report(["this policy declares no campaigns."]);
1351
+ }
1352
+ const hitsOf = (): ReadonlyArray<CampaignHit> => collectFindings(policy, roots).campaigns;
1353
+ const own = (hits: ReadonlyArray<CampaignHit>, campaign: string): ReadonlyArray<Violation> =>
1354
+ hits.filter((hit) => hit.campaign === campaign).map((hit) => hit.violation);
1355
+
1356
+ switch (subcommand) {
1357
+ case undefined: {
1358
+ const snapshot = snapshotOf(policy, roots, manifestPathOf(policy.repoRoot));
1359
+ return yield* report([
1360
+ `${count(snapshot.campaigns.length, "campaign")} under ${roots.join(", ")}`,
1361
+ "",
1362
+ ...renderCampaignRows(snapshot.campaigns),
1363
+ "",
1364
+ " architecture campaigns init <id> # write a ledger from what fires today",
1365
+ " architecture campaigns prune [<id>] # drop entries that no longer fire",
1366
+ ' architecture campaigns allow <id> --reason "<why>" # record why the count may rise',
1367
+ ]);
1368
+ }
1369
+ case "init": {
1370
+ if (id === undefined) return yield* Effect.fail(fail("campaigns init needs a campaign id"));
1371
+ const rule = campaignNamed(policy, id);
1372
+ if (rule === null) return yield* Effect.fail(fail(`no campaign is named "${id}"`));
1373
+ if (policy.ledgers.has(id)) {
1374
+ return yield* Effect.fail(
1375
+ fail(
1376
+ `${path.relative(policy.repoRoot, ledgerPathOf(policy, id))} already exists. \`init\` ` +
1377
+ `writes a campaign's first ledger and does not overwrite one; \`prune\` and \`allow\` ` +
1378
+ `are how it changes.`,
1379
+ ),
1380
+ );
1381
+ }
1382
+ const ledger = ledgerOf(id, own(hitsOf(), id), policy.now);
1383
+ yield* Effect.sync(() => {
1384
+ writeLedger(policy, ledger);
1385
+ });
1386
+ return yield* report([
1387
+ `${count(ledger.entries.length, "hit")} recorded in ${path.relative(policy.repoRoot, ledgerPathOf(policy, id))}.`,
1388
+ "Each one is a place the campaign has yet to reach. Fixing one means pruning its line.",
1389
+ ]);
1390
+ }
1391
+ case "prune": {
1392
+ const targets =
1393
+ id === undefined
1394
+ ? policy.campaignRules
1395
+ : [campaignNamed(policy, id)].filter((one) => one !== null);
1396
+ if (id !== undefined && targets.length === 0) {
1397
+ return yield* Effect.fail(fail(`no campaign is named "${id}"`));
1398
+ }
1399
+ const hits = hitsOf();
1400
+ const lines: Array<string> = [];
1401
+ for (const rule of targets) {
1402
+ const ledger = policy.ledgers.get(rule.id);
1403
+ if (ledger === undefined) {
1404
+ lines.push(`${rule.id}: no ledger to prune (run \`campaigns init ${rule.id}\`).`);
1405
+ continue;
1406
+ }
1407
+ const next = pruned(ledger, own(hits, rule.id), rule.unit, policy.now);
1408
+ const removed = ledger.entries.length - next.entries.length;
1409
+ const rewritten = next.entries.filter((entry) => !ledger.entries.includes(entry)).length;
1410
+ if (removed === 0 && rewritten === 0) {
1411
+ lines.push(`${rule.id}: nothing to prune.`);
1412
+ continue;
1413
+ }
1414
+ yield* Effect.sync(() => {
1415
+ writeLedger(policy, next);
1416
+ });
1417
+ lines.push(
1418
+ `${rule.id}: ${count(removed, "entry", "entries")} pruned` +
1419
+ (rewritten > 0 ? `, ${count(rewritten, "entry", "entries")} rewritten` : "") +
1420
+ `; ${count(next.entries.length, "entry", "entries")} left.`,
1421
+ );
1422
+ }
1423
+ return yield* report(lines);
1424
+ }
1425
+ case "allow": {
1426
+ if (id === undefined)
1427
+ return yield* Effect.fail(fail("campaigns allow needs a campaign id"));
1428
+ const rule = campaignNamed(policy, id);
1429
+ if (rule === null) return yield* Effect.fail(fail(`no campaign is named "${id}"`));
1430
+ const ledger = policy.ledgers.get(id);
1431
+ if (ledger === undefined) {
1432
+ return yield* Effect.fail(
1433
+ fail(`campaign ${id} has no ledger yet; run \`campaigns init ${id}\` first.`),
1434
+ );
1435
+ }
1436
+ const reason = flagOf(argv, "--reason");
1437
+ if (reason === undefined) {
1438
+ return yield* Effect.fail(
1439
+ fail(
1440
+ "campaigns allow needs --reason <text>: growth is recorded with why, or not at all.",
1441
+ ),
1442
+ );
1443
+ }
1444
+ const by = authorOf(flagOf(argv, "--by"));
1445
+ if (by === null) {
1446
+ return yield* Effect.fail(
1447
+ fail("campaigns allow needs an author: pass --by <email>, or set git's user.email."),
1448
+ );
1449
+ }
1450
+ const state = reconcile(ledger, own(hitsOf(), id), rule.unit);
1451
+ const unrecorded = [...new Set(state.unrecorded.map(entryOf))].sort();
1452
+ // `--entries` allows a subset and refuses the rest: a pull request
1453
+ // that legitimately adds one hit while another is an accident.
1454
+ const chosen =
1455
+ flagOf(argv, "--entries")
1456
+ ?.split(",")
1457
+ .map((one) => one.trim()) ?? unrecorded;
1458
+ const unknown = chosen.filter((entry) => !unrecorded.includes(entry));
1459
+ if (unknown.length > 0) {
1460
+ return yield* Effect.fail(
1461
+ fail(`these entries are not unrecorded hits of ${id}: ${unknown.join(", ")}`),
1462
+ );
1463
+ }
1464
+ if (chosen.length === 0) {
1465
+ return yield* report([`${id}: nothing to allow; every hit is in the ledger.`]);
1466
+ }
1467
+ const next = allowed(ledger, chosen, { at: policy.now, by, reason });
1468
+ yield* Effect.sync(() => {
1469
+ writeLedger(policy, next);
1470
+ });
1471
+ const left = unrecorded.filter((entry) => !chosen.includes(entry));
1472
+ return yield* report([
1473
+ `${id}: ${count(chosen.length, "entry", "entries")} allowed, recorded as a regression by ${by}.`,
1474
+ ...chosen.map((entry) => ` ${entry}`),
1475
+ ...(left.length === 0
1476
+ ? []
1477
+ : ["", `${count(left.length, "hit")} left unrecorded; check still fails on them.`]),
1478
+ ]);
1479
+ }
1480
+ default:
1481
+ return yield* Effect.fail(
1482
+ fail(
1483
+ `unknown campaigns subcommand "${subcommand}". Try: campaigns | campaigns init <id> | campaigns prune [<id>] | campaigns allow <id> --reason <text> [--by <email>] [--entries a,b]`,
1484
+ ),
1485
+ );
1486
+ }
1487
+ });
1488
+
921
1489
  export const run = (
922
1490
  repoRoot: string,
923
1491
  argv: ReadonlyArray<string>,
@@ -969,6 +1537,8 @@ export const run = (
969
1537
  const roots = positional.length > 0 ? positional : ["packages"];
970
1538
 
971
1539
  switch (command) {
1540
+ case "campaigns":
1541
+ return yield* campaigns(policy, ["packages"], rest);
972
1542
  case "check":
973
1543
  return yield* check(policy, roots, {
974
1544
  format: json ? "json" : "text",
@@ -996,7 +1566,7 @@ export const run = (
996
1566
  default:
997
1567
  return yield* Effect.fail(
998
1568
  fail(
999
- `unknown command "${command}". Try: check [--json] | conformance [--json] [--against <manifest>] | baseline | coverage | explain <file> | facts <file> [--json] | init | infer | migrate`,
1569
+ `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`,
1000
1570
  ),
1001
1571
  );
1002
1572
  }