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

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,40 +1,79 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
4
  import * as path from "node:path";
3
5
 
4
6
  import {
7
+ allowed,
5
8
  type Baseline,
6
9
  baselineOf,
10
+ type CampaignHit,
11
+ campaignsSelecting,
12
+ type CompiledCampaign,
13
+ CONFORMANCE_MEASURES,
14
+ type ConformanceMeasure,
15
+ type CoverageFamily,
7
16
  coverageOf,
8
- coverageShortfalls,
17
+ cyclesIn,
9
18
  decodeBaseline,
19
+ decodeManifest,
10
20
  EMPTY_BASELINE,
21
+ entryOf,
22
+ evaluateCampaigns,
11
23
  evaluateGraph,
12
24
  evaluateMemberSite,
25
+ evaluateResolvedEdge,
13
26
  evaluateSelectedBindings,
14
- evaluateSelectedEdge,
15
27
  evaluateStructure,
16
28
  evaluateSurface,
29
+ explainCampaign,
17
30
  exportRulesSelecting,
31
+ findManifestFile,
18
32
  fingerprintOf,
33
+ formatManifestYaml,
19
34
  formatMessage,
20
35
  fractionsOf,
36
+ type Graph,
21
37
  hasGraphRules,
38
+ heightOf,
39
+ isComplete,
40
+ isStalled,
41
+ leafTermsOf,
42
+ type Ledger,
43
+ ledgerArithmeticHolds,
44
+ ledgerOf,
22
45
  listSourceFiles,
46
+ makeBaselineFilter,
47
+ MANIFEST_FILENAMES,
48
+ MANIFEST_SCHEMA_ID,
23
49
  memberRulesSelecting,
50
+ type ObservedEdge,
51
+ progressOf,
52
+ pruned,
53
+ readManifestFile,
54
+ reconcile,
55
+ reportSpecsOf,
24
56
  requiredSiblingsOf,
57
+ residueOf,
25
58
  rulesSelecting,
26
59
  serializeBaseline,
60
+ serializeLedger,
61
+ slackOf,
62
+ type Snapshot,
63
+ SNAPSHOT_VERSION,
64
+ type SnapshotCampaign,
27
65
  type SourceFacts,
28
66
  staleEntriesOf,
29
67
  surfaceRulesSelecting,
30
- unbaselined,
68
+ vacancyOf,
31
69
  type Violation,
32
70
  } from "@goodbones/core";
33
71
  import * as Effect from "effect/Effect";
34
72
  import * as Result from "effect/Result";
35
73
 
36
- import { type LoadedPolicy, loadPolicyFromFile } from "./config-loader.js";
74
+ import { type LoadedPolicy, loadPolicyFromFile, manifestPathOf } from "./config-loader.js";
37
75
  import { buildGraph } from "./graph.js";
76
+ import { infer } from "./infer.js";
38
77
  import { sourceFactsOf } from "./source-facts.js";
39
78
 
40
79
  // The policy, run with no linter in the loop.
@@ -44,46 +83,83 @@ import { sourceFactsOf } from "./source-facts.js";
44
83
  // second way to ask the same question — and the only way to write a baseline,
45
84
  // since that needs every finding at once rather than one file at a time.
46
85
  //
47
- // It covers all four families. The two that need a syntax tree read TypeScript's
48
- // rather than oxlint's; both adapters meet at the same vocabulary — a specifier,
49
- // a binding, a member site — so they answer to the same core rather than to each
50
- // other.
86
+ // It covers all four families. The ones that need a syntax tree read it through
87
+ // the language pack's own parse rather than oxlint's; both adapters meet at the
88
+ // same vocabulary — a specifier, a binding, a member site — so they answer to
89
+ // the same core rather than to each other.
51
90
 
52
91
  export type CliFailure = { readonly _tag: "CliFailure"; readonly message: string };
53
92
 
54
93
  const fail = (message: string): CliFailure => ({ _tag: "CliFailure", message });
55
94
 
95
+ // An edge the resolver could not turn into a file. It is reported on its own,
96
+ // since every import rule about it enforces nothing.
97
+ export type UnresolvedEdge = {
98
+ readonly file: string;
99
+ readonly specifier: string;
100
+ readonly detail: string;
101
+ };
102
+
56
103
  export type Findings = {
57
104
  readonly violations: ReadonlyArray<Violation>;
58
- readonly unresolved: ReadonlyArray<string>;
105
+ // Every campaign hit, ledgered or not. Kept apart from the violations: a
106
+ // hit is debt a campaign is paying down, judged against its ledger rather
107
+ // than the baseline.
108
+ readonly campaigns: ReadonlyArray<CampaignHit>;
109
+ readonly unresolved: ReadonlyArray<UnresolvedEdge>;
59
110
  readonly files: number;
111
+ // Every edge resolved from a file under an import rule — what the slack
112
+ // report reads. An edge from a file no import rule selects is not here,
113
+ // since no allowlist could have admitted it.
114
+ readonly edges: ReadonlyArray<ObservedEdge>;
115
+ // The import graph, when a graph rule needed it or the caller asked.
116
+ readonly graph: Graph | null;
117
+ };
118
+
119
+ export type CollectOptions = {
120
+ // Build the graph even when no rule needs it — the snapshot counts cycles
121
+ // and orders violations by it.
122
+ readonly graph?: boolean;
60
123
  };
61
124
 
62
- export const collectFindings = (policy: LoadedPolicy, roots: ReadonlyArray<string>): Findings => {
125
+ export const collectFindings = (
126
+ policy: LoadedPolicy,
127
+ roots: ReadonlyArray<string>,
128
+ options: CollectOptions = {},
129
+ ): Findings => {
63
130
  const files = listSourceFiles(policy.repoRoot, roots, policy.languages);
64
131
  const violations: Array<Violation> = [];
65
- const unresolved: Array<string> = [];
132
+ const campaigns: Array<CampaignHit> = [];
133
+ const unresolved: Array<UnresolvedEdge> = [];
134
+ const edges: Array<ObservedEdge> = [];
66
135
 
67
- // Each file is parsed at most once, whether the per-file families or the
68
- // graph pass asks first.
136
+ // Each file is read and parsed at most once, whether the per-file
137
+ // families, the graph pass or a campaign asks first.
138
+ const texts = new Map<string, string>();
139
+ const textOf = (file: string): string => {
140
+ const cached = texts.get(file);
141
+ if (cached !== undefined) return cached;
142
+ const text = readFileSync(path.join(policy.repoRoot, file), "utf8");
143
+ texts.set(file, text);
144
+ return text;
145
+ };
69
146
  const parsed = new Map<string, SourceFacts>();
70
147
  const factsOf = (file: string): SourceFacts => {
71
148
  const cached = parsed.get(file);
72
149
  if (cached !== undefined) return cached;
73
- const facts = sourceFactsOf(policy.repoRoot, file, policy.extractor);
150
+ const facts = policy.extractor.factsOf(file, textOf(file));
74
151
  parsed.set(file, facts);
75
152
  return facts;
76
153
  };
77
154
 
78
155
  // The graph is the whole repository resolved at once — the one question no
79
156
  // per-file adapter can ask — and is built only when a rule needs it.
80
- if (hasGraphRules(policy.graph)) {
81
- for (const violation of evaluateGraph(
82
- policy.graph,
83
- buildGraph(files, policy.resolver, factsOf),
84
- )) {
85
- violations.push(violation);
86
- }
157
+ const graph =
158
+ options.graph === true || hasGraphRules(policy.graph)
159
+ ? buildGraph(files, policy.resolver, factsOf)
160
+ : null;
161
+ if (graph !== null && hasGraphRules(policy.graph)) {
162
+ for (const violation of evaluateGraph(policy.graph, graph)) violations.push(violation);
87
163
  }
88
164
 
89
165
  for (const file of files) {
@@ -91,6 +167,31 @@ export const collectFindings = (policy: LoadedPolicy, roots: ReadonlyArray<strin
91
167
  violations.push(violation);
92
168
  }
93
169
 
170
+ // A campaign selects by its scope. The file is parsed by the scope's
171
+ // matcher once, and only when a term of some selected campaign reads the
172
+ // syntax tree.
173
+ const selectedCampaigns = campaignsSelecting(policy.campaignRules, file);
174
+ if (selectedCampaigns.length > 0) {
175
+ const text = textOf(file);
176
+ const needsSyntax = selectedCampaigns.some((rule) =>
177
+ leafTermsOf(rule.detect).some(
178
+ (leaf) => leaf === "syntax" || leaf === "report" || leaf === "fn",
179
+ ),
180
+ );
181
+ for (const hit of evaluateCampaigns(selectedCampaigns, {
182
+ file,
183
+ text,
184
+ facts: factsOf(file),
185
+ resolver: policy.resolver,
186
+ fileSystem: policy.fileSystem,
187
+ syntax: needsSyntax ? policy.syntax.parse(file, text) : null,
188
+ functions: policy.functions,
189
+ reports: policy.reports,
190
+ })) {
191
+ campaigns.push(hit);
192
+ }
193
+ }
194
+
94
195
  const selectedImports = rulesSelecting(policy.importRules, file);
95
196
  const selectedExports = exportRulesSelecting(policy.exportRules, file);
96
197
  const selectedMembers = memberRulesSelecting(policy.memberRules, file);
@@ -118,14 +219,21 @@ export const collectFindings = (policy: LoadedPolicy, roots: ReadonlyArray<strin
118
219
  for (const specifier of facts.specifiers) {
119
220
  const edge = { importer: file, specifier };
120
221
 
121
- const imported = evaluateSelectedEdge(selectedImports, policy.resolver, edge);
122
- if (Result.isFailure(imported)) {
123
- if (policy.config.resolve.unresolved === "off") continue;
124
- if (policy.ignoreUnresolved.some((pattern) => pattern.test(specifier))) continue;
125
- unresolved.push(`${file} → ${specifier} (${imported.failure.detail})`);
126
- continue;
222
+ // A file no import rule selects never needs its imports resolved, which
223
+ // is what keeps resolution off the hot path for the bulk of the repo.
224
+ if (selectedImports.length > 0) {
225
+ const resolved = policy.resolver.resolve(file, specifier);
226
+ if (Result.isFailure(resolved)) {
227
+ if (policy.config.resolve.unresolved === "off") continue;
228
+ if (policy.ignoreUnresolved.some((pattern) => pattern.test(specifier))) continue;
229
+ unresolved.push({ file, specifier, detail: resolved.failure.detail });
230
+ continue;
231
+ }
232
+ edges.push({ importer: file, target: resolved.success });
233
+ for (const violation of evaluateResolvedEdge(selectedImports, file, resolved.success)) {
234
+ violations.push(violation);
235
+ }
127
236
  }
128
- for (const violation of imported.success) violations.push(violation);
129
237
 
130
238
  const bound = facts.bindings.get(specifier) ?? [];
131
239
  const exported = evaluateSelectedBindings(selectedExports, policy.resolver, {
@@ -138,7 +246,7 @@ export const collectFindings = (policy: LoadedPolicy, roots: ReadonlyArray<strin
138
246
  }
139
247
  }
140
248
 
141
- return { violations, unresolved, files: files.length };
249
+ return { violations, campaigns, unresolved, files: files.length, edges, graph };
142
250
  };
143
251
 
144
252
  const baselinePathOf = (policy: LoadedPolicy): string | null =>
@@ -164,69 +272,726 @@ const report = (lines: ReadonlyArray<string>): Effect.Effect<void> =>
164
272
  const describe = (violation: Violation): string =>
165
273
  ` ${violation.file}\n ${formatMessage(violation)}`;
166
274
 
167
- export const check = (
275
+ // Everything `check` has to say, as one value: the two renderers below read
276
+ // it, and nothing else computes a finding. `version` is here so a document
277
+ // that grows this shape (a conformance snapshot) can say which one it grew.
278
+ export type ReportedViolation = Violation & {
279
+ readonly fingerprint: string;
280
+ readonly baselined: boolean;
281
+ // For a campaign hit: carried by the campaign's ledger, so `check` does
282
+ // not fail on it. The campaign analogue of `baselined`.
283
+ readonly ledgered: boolean;
284
+ };
285
+
286
+ // One campaign, as `check` sees it: how many hits, which are new, which
287
+ // ledger entries no longer fire, and whether the ledger adds up.
288
+ export type CampaignReport = {
289
+ readonly id: string;
290
+ readonly count: number;
291
+ // Hits the ledger does not carry — unrecorded growth, as entries.
292
+ readonly new: ReadonlyArray<string>;
293
+ // Ledger entries no hit produces — fixed, and waiting to be pruned.
294
+ readonly stale: ReadonlyArray<string>;
295
+ // Entries whose hash moved under a still-present anchor.
296
+ readonly drifted: number;
297
+ // No ledger file: `campaigns init` has not been run.
298
+ readonly missingLedger: boolean;
299
+ // `entries.length === initial + Σ delta − fixed`.
300
+ readonly arithmetic: boolean;
301
+ readonly complete: boolean;
302
+ readonly stalled: boolean;
303
+ readonly onComplete: "keep" | "remove";
304
+ };
305
+
306
+ export type CoverageReport = Readonly<
307
+ Record<
308
+ CoverageFamily,
309
+ { readonly covered: number; readonly total: number; readonly floor?: number }
310
+ >
311
+ >;
312
+
313
+ // The conformance measures as counts, each beside the ceiling the manifest's
314
+ // `limits.conformance` states for it. `conformance` names what each counts;
315
+ // `check` holds the counts to the ceilings.
316
+ export type ConformanceReport = Readonly<
317
+ Record<ConformanceMeasure, { readonly count: number; readonly ceiling?: number }>
318
+ >;
319
+
320
+ export type CheckReport = {
321
+ readonly version: 1;
322
+ readonly files: number;
323
+ readonly roots: ReadonlyArray<string>;
324
+ readonly ok: boolean;
325
+ // The file the policy was read from, repo-relative, and a hash of its
326
+ // bytes — the root file only, when the manifest is split with `include`.
327
+ readonly manifest: { readonly path: string; readonly sha256: string };
328
+ // Every finding, baselined ones included; `baselined` says which.
329
+ readonly violations: ReadonlyArray<ReportedViolation>;
330
+ readonly unresolved: ReadonlyArray<UnresolvedEdge>;
331
+ // Baseline entries the code no longer produces.
332
+ readonly stale: ReadonlyArray<string>;
333
+ readonly coverage: CoverageReport;
334
+ readonly conformance: ConformanceReport;
335
+ readonly adoption: {
336
+ readonly unrestricted: ReadonlyArray<string>;
337
+ readonly partial: ReadonlyArray<string>;
338
+ };
339
+ readonly campaigns: ReadonlyArray<CampaignReport>;
340
+ };
341
+
342
+ // Each campaign's hits against its ledger. A campaign with no ledger has
343
+ // every hit new; one with no hits and no ledger has nothing to say.
344
+ const campaignReportsOf = (
345
+ policy: LoadedPolicy,
346
+ hits: ReadonlyArray<CampaignHit>,
347
+ ): ReadonlyArray<CampaignReport> =>
348
+ policy.campaignRules.map((rule) => {
349
+ const own = hits.filter((hit) => hit.campaign === rule.id).map((hit) => hit.violation);
350
+ const ledger = policy.ledgers.get(rule.id);
351
+ if (ledger === undefined) {
352
+ return {
353
+ id: rule.id,
354
+ count: own.length,
355
+ new: [...new Set(own.map(entryOf))].sort(),
356
+ stale: [],
357
+ drifted: 0,
358
+ missingLedger: true,
359
+ arithmetic: true,
360
+ complete: own.length === 0,
361
+ stalled: false,
362
+ onComplete: rule.onComplete,
363
+ };
364
+ }
365
+ const state = reconcile(ledger, own, rule.unit);
366
+ return {
367
+ id: rule.id,
368
+ count: own.length,
369
+ new: [...new Set(state.unrecorded.map(entryOf))].sort(),
370
+ stale: state.stale,
371
+ drifted: state.drifted.length,
372
+ missingLedger: false,
373
+ arithmetic: ledgerArithmeticHolds(ledger),
374
+ complete: isComplete(ledger) && own.length === 0,
375
+ stalled: isStalled(rule, ledger, policy.now),
376
+ onComplete: rule.onComplete,
377
+ };
378
+ });
379
+
380
+ // Whether a campaign hit is carried by its ledger — exactly, or by anchor.
381
+ const ledgeredFilter = (
382
+ policy: LoadedPolicy,
383
+ hits: ReadonlyArray<CampaignHit>,
384
+ ): ((hit: CampaignHit) => boolean) => {
385
+ const carried = new Set<Violation>();
386
+ for (const rule of policy.campaignRules) {
387
+ const ledger = policy.ledgers.get(rule.id);
388
+ if (ledger === undefined) continue;
389
+ const own = hits.filter((hit) => hit.campaign === rule.id).map((hit) => hit.violation);
390
+ for (const one of reconcile(ledger, own, rule.unit).ledgered) carried.add(one);
391
+ }
392
+ return (hit) => carried.has(hit.violation);
393
+ };
394
+
395
+ // Why a campaign report is not ok, in the order `check` explains it.
396
+ const campaignFailuresOf = (campaigns: ReadonlyArray<CampaignReport>): ReadonlyArray<string> => [
397
+ ...campaigns.filter((one) => one.stale.length > 0).map(() => "stale ledger entries"),
398
+ ...campaigns.filter((one) => !one.arithmetic).map(() => "ledger arithmetic does not hold"),
399
+ ...campaigns
400
+ .filter((one) => one.missingLedger && one.count > 0)
401
+ .map((one) => `campaign ${one.id} has no ledger`),
402
+ ...campaigns
403
+ .filter((one) => !one.missingLedger && one.new.length > 0)
404
+ .map(() => "unrecorded campaign growth"),
405
+ ...campaigns
406
+ .filter((one) => one.complete && !one.missingLedger && one.onComplete === "remove")
407
+ .map((one) => `campaign ${one.id} is complete and declared onComplete: remove`),
408
+ ];
409
+
410
+ const COVERAGE_FAMILIES: ReadonlyArray<CoverageFamily> = [
411
+ "imports",
412
+ "structure",
413
+ "members",
414
+ "surface",
415
+ "graph",
416
+ ];
417
+
418
+ const sha256Of = (file: string): string => {
419
+ try {
420
+ return createHash("sha256").update(readFileSync(file)).digest("hex");
421
+ } catch {
422
+ return "";
423
+ }
424
+ };
425
+
426
+ export const checkReport = (
168
427
  policy: LoadedPolicy,
169
428
  roots: ReadonlyArray<string>,
170
- ): Effect.Effect<void, CliFailure> =>
171
- Effect.gen(function* () {
172
- const findings = collectFindings(policy, roots);
173
- const baseline = readBaseline(policy);
174
- const reportable = unbaselined(baseline, findings.violations);
175
- const stale = staleEntriesOf(baseline, findings.violations);
429
+ manifestPath: string,
430
+ ): CheckReport => reportOf(policy, roots, manifestPath, collectFindings(policy, roots)).report;
176
431
 
177
- yield* report(reportable.map(describe));
178
- yield* report(findings.unresolved.map((one) => ` unresolved: ${one}`));
432
+ // The four conformance measures, as `conformance` names them: what no
433
+ // family reaches, what no file is under, what nothing imports through, and
434
+ // the fragment entries concentrated at fewer than half the nodes granted.
435
+ type Measures = {
436
+ readonly residue: ReturnType<typeof residueOf>;
437
+ readonly vacant: ReturnType<typeof vacancyOf>;
438
+ readonly slack: ReturnType<typeof slackOf>["slack"];
439
+ readonly concentration: ReturnType<typeof slackOf>["concentration"];
440
+ };
179
441
 
180
- const carried = findings.violations.length - reportable.length;
181
- yield* report([
182
- "",
183
- `${String(findings.files)} files, ${String(reportable.length)} violations` +
184
- (carried > 0 ? `, ${String(carried)} carried by the baseline` : ""),
185
- ]);
442
+ const measuresOf = (
443
+ policy: LoadedPolicy,
444
+ files: ReadonlyArray<string>,
445
+ edges: ReadonlyArray<ObservedEdge>,
446
+ ): Measures => {
447
+ // Slack is measured over the walked files as well as the edges: an
448
+ // allowlist that selects no file is vacant, and its entries are reported as
449
+ // that rather than as lines nobody needs.
450
+ const { concentration, slack } = slackOf(policy.importRules, edges, files);
451
+ return {
452
+ residue: residueOf(policy, files),
453
+ vacant: vacancyOf(policy.importRules, files),
454
+ slack,
455
+ concentration,
456
+ };
457
+ };
186
458
 
187
- if (stale.length > 0) {
188
- // The ratchet: a fixed violation must leave the baseline, or the floor
189
- // never rises and the file stops describing anything real.
190
- yield* report([
191
- "",
192
- `${String(stale.length)} baseline entries no longer fire. The code was fixed; prune them:`,
193
- ...stale.map((entry) => ` ${entry}`),
194
- "",
195
- " architecture baseline # rewrites the file from what still fires",
196
- ]);
197
- return yield* Effect.fail(fail("stale baseline entries"));
198
- }
459
+ // A fragment entry is concentrated when it is used at fewer than half the
460
+ // nodes granted it — the text report's threshold, and the ceiling's.
461
+ const isConcentrated = (one: { readonly usedAt: number; readonly of: number }): boolean =>
462
+ one.usedAt * 2 < one.of;
199
463
 
200
- // The floors. A policy states how much of the tree it reaches, per
201
- // family; falling under is a policy that quietly stopped covering files.
202
- const floors = policy.config.limits?.coverage;
203
- const shortfalls =
204
- floors === undefined
205
- ? []
206
- : coverageShortfalls(
207
- coverageOf(policy, listSourceFiles(policy.repoRoot, roots, policy.languages)),
208
- floors,
209
- );
210
- if (shortfalls.length > 0) {
211
- yield* report([
464
+ const countsOf = (measures: Measures): Readonly<Record<ConformanceMeasure, number>> => ({
465
+ residue: measures.residue.files.length,
466
+ vacant: measures.vacant.length,
467
+ slack: measures.slack.length,
468
+ concentration: measures.concentration.filter(isConcentrated).length,
469
+ });
470
+
471
+ const reportOf = (
472
+ policy: LoadedPolicy,
473
+ roots: ReadonlyArray<string>,
474
+ manifestPath: string,
475
+ findings: Findings,
476
+ ): {
477
+ readonly report: CheckReport;
478
+ readonly measures: Measures;
479
+ readonly files: ReadonlyArray<string>;
480
+ } => {
481
+ const baseline = readBaseline(policy);
482
+ const stale = staleEntriesOf(baseline, findings.violations);
483
+ const { isBaselined } = makeBaselineFilter(baseline);
484
+ const isLedgered = ledgeredFilter(policy, findings.campaigns);
485
+ const violations = [
486
+ ...findings.violations.map((violation) => ({
487
+ ...violation,
488
+ fingerprint: fingerprintOf(violation),
489
+ baselined: isBaselined(violation),
490
+ ledgered: false,
491
+ })),
492
+ ...findings.campaigns.map((hit) => ({
493
+ ...hit.violation,
494
+ fingerprint: fingerprintOf(hit.violation),
495
+ baselined: false,
496
+ ledgered: isLedgered(hit),
497
+ })),
498
+ ];
499
+ const campaigns = campaignReportsOf(policy, findings.campaigns);
500
+
501
+ // The floors. A policy states how much of the tree it reaches, per
502
+ // family; falling under is a policy that quietly stopped covering files.
503
+ const floors = policy.config.limits?.coverage ?? {};
504
+ const files = listSourceFiles(policy.repoRoot, roots, policy.languages);
505
+ const found = coverageOf(policy, files);
506
+ const covered = (family: CoverageFamily): number =>
507
+ family === "structure" ? found.structure.enumerated : found[family].covered;
508
+ const coverage = Object.fromEntries(
509
+ COVERAGE_FAMILIES.map((family) => {
510
+ const floor = floors[family];
511
+ return [
512
+ family,
513
+ {
514
+ covered: covered(family),
515
+ total: found.files,
516
+ ...(floor === undefined ? {} : { floor }),
517
+ },
518
+ ];
519
+ }),
520
+ ) as CoverageReport;
521
+ const shortfalls = shortfallsOf(coverage);
522
+
523
+ // The ceilings. What no family reaches, what no file is under and what
524
+ // nothing imports through are each a count the policy may hold itself
525
+ // to; rising over one is a manifest that quietly widened.
526
+ const ceilings = policy.config.limits?.conformance ?? {};
527
+ const measures = measuresOf(policy, files, findings.edges);
528
+ const counts = countsOf(measures);
529
+ const conformance = Object.fromEntries(
530
+ CONFORMANCE_MEASURES.map((measure) => {
531
+ const ceiling = ceilings[measure];
532
+ return [measure, { count: counts[measure], ...(ceiling === undefined ? {} : { ceiling }) }];
533
+ }),
534
+ ) as ConformanceReport;
535
+ const excesses = excessesOf(conformance);
536
+
537
+ const reportable = violations.filter((one) => !one.baselined && !one.ledgered).length;
538
+ const report: CheckReport = {
539
+ version: 1,
540
+ files: findings.files,
541
+ roots,
542
+ ok:
543
+ reportable === 0 &&
544
+ findings.unresolved.length === 0 &&
545
+ stale.length === 0 &&
546
+ shortfalls.length === 0 &&
547
+ excesses.length === 0 &&
548
+ campaignFailuresOf(campaigns).length === 0,
549
+ manifest: {
550
+ path: path.relative(policy.repoRoot, manifestPath).replaceAll(path.sep, "/"),
551
+ sha256: sha256Of(manifestPath),
552
+ },
553
+ violations,
554
+ unresolved: findings.unresolved,
555
+ stale,
556
+ coverage,
557
+ conformance,
558
+ adoption: {
559
+ unrestricted: policy.adoption.unrestricted,
560
+ partial: policy.adoption.partial,
561
+ },
562
+ campaigns,
563
+ };
564
+ return { report, measures, files };
565
+ };
566
+
567
+ // Why a report is not `ok`, in the order the text renderer explains it: a
568
+ // stale baseline first, since nothing else is trustworthy until the file
569
+ // describes something real.
570
+ type Shortfall = {
571
+ readonly family: CoverageFamily;
572
+ readonly actual: number;
573
+ readonly floor: number;
574
+ };
575
+
576
+ const shortfallsOf = (coverage: CoverageReport): ReadonlyArray<Shortfall> =>
577
+ COVERAGE_FAMILIES.flatMap((family) => {
578
+ const { covered, floor, total } = coverage[family];
579
+ const actual = total === 0 ? 1 : covered / total;
580
+ return floor === undefined || actual >= floor ? [] : [{ family, actual, floor }];
581
+ });
582
+
583
+ // A conformance measure over the ceiling the policy states for it.
584
+ type Excess = {
585
+ readonly measure: ConformanceMeasure;
586
+ readonly count: number;
587
+ readonly ceiling: number;
588
+ };
589
+
590
+ const excessesOf = (conformance: ConformanceReport): ReadonlyArray<Excess> =>
591
+ CONFORMANCE_MEASURES.flatMap((measure) => {
592
+ const { ceiling, count } = conformance[measure];
593
+ return ceiling === undefined || count <= ceiling ? [] : [{ measure, count, ceiling }];
594
+ });
595
+
596
+ const failureOf = (
597
+ report: CheckReport,
598
+ shortfalls: ReadonlyArray<Shortfall>,
599
+ ): CliFailure | null => {
600
+ if (report.stale.length > 0) return fail("stale baseline entries");
601
+ const [campaignFailure] = campaignFailuresOf(report.campaigns);
602
+ if (campaignFailure !== undefined) return fail(campaignFailure);
603
+ if (shortfalls.length > 0) return fail("coverage below floor");
604
+ if (excessesOf(report.conformance).length > 0) return fail("conformance above ceiling");
605
+ if (report.ok) return null;
606
+ return fail("architecture violations");
607
+ };
608
+
609
+ // What each measure counts, as the failure names it.
610
+ const MEASURE_NOUNS: Readonly<Record<ConformanceMeasure, readonly [string, string]>> = {
611
+ residue: ["file no family reaches", "files no family reaches"],
612
+ vacant: ["node no file is under", "nodes no file is under"],
613
+ slack: ["allowance nothing imports through", "allowances nothing imports through"],
614
+ concentration: [
615
+ "allowance used at fewer than half the nodes granted",
616
+ "allowances used at fewer than half the nodes granted",
617
+ ],
618
+ };
619
+
620
+ const describeExcess = (one: Excess): string => {
621
+ const [singular, plural] = MEASURE_NOUNS[one.measure];
622
+ return ` ${one.measure}: ${count(one.count, singular, plural)}, ceiling ${String(one.ceiling)}`;
623
+ };
624
+
625
+ // A campaign hit, with the campaign's `how` as its instruction.
626
+ const describeHit = (violation: ReportedViolation): string =>
627
+ ` ${violation.file}${violation.subject === null ? "" : ` (${violation.subject})`}\n ${formatMessage(violation)}`;
628
+
629
+ const renderCampaigns = (report: CheckReport): ReadonlyArray<string> => {
630
+ const hits = report.violations.filter((one) => one.kind === "campaign");
631
+ return report.campaigns.flatMap((campaign): ReadonlyArray<string> => {
632
+ const rule = `campaign/${campaign.id}`;
633
+ const fresh = new Set(campaign.new);
634
+ const own = hits.filter((one) => one.ruleName === rule && fresh.has(entryOf(one)));
635
+ if (campaign.missingLedger && campaign.count > 0) {
636
+ return [
212
637
  "",
213
- "coverage is below the floor the policy states for itself:",
214
- ...shortfalls.map(
215
- (one) => ` ${one.family}: ${percent(one.actual)} covered, floor ${percent(one.floor)}`,
216
- ),
638
+ `campaign ${campaign.id}: ${count(campaign.count, "hit")} and no ledger. Record them before they count as growth:`,
217
639
  "",
218
- " architecture coverage # which files no rule reaches",
219
- ]);
220
- return yield* Effect.fail(fail("coverage below floor"));
640
+ ` architecture campaigns init ${campaign.id}`,
641
+ ];
221
642
  }
643
+ const complete = campaign.complete && !campaign.missingLedger;
644
+ return [
645
+ ...(campaign.new.length === 0
646
+ ? []
647
+ : [
648
+ "",
649
+ `campaign ${campaign.id}: ${count(campaign.new.length, "new hit")} the ledger does not carry. Fix them, or record why the count may rise:`,
650
+ ...own.map(describeHit),
651
+ "",
652
+ ` architecture campaigns allow ${campaign.id} --reason "<why>"`,
653
+ ]),
654
+ ...(campaign.stale.length === 0
655
+ ? []
656
+ : [
657
+ "",
658
+ `campaign ${campaign.id}: ${count(campaign.stale.length, "ledger entry", "ledger entries")} no longer fire. The code was fixed; prune them:`,
659
+ ...campaign.stale.map((entry) => ` ${entry}`),
660
+ "",
661
+ ` architecture campaigns prune ${campaign.id}`,
662
+ ]),
663
+ ...(campaign.arithmetic
664
+ ? []
665
+ : [
666
+ "",
667
+ `campaign ${campaign.id}: the ledger does not add up (entries ≠ initial + allowed − fixed). An entry was added by hand; remove it, or record it with \`campaigns allow\`.`,
668
+ ]),
669
+ ...(complete && campaign.onComplete === "remove"
670
+ ? [
671
+ "",
672
+ `campaign ${campaign.id} is complete and declares onComplete: remove. Delete it from the manifest, and its ledger.`,
673
+ ]
674
+ : []),
675
+ ...(campaign.stalled
676
+ ? [
677
+ "",
678
+ `notice: campaign ${campaign.id} has stalled — no entry has left its ledger within its staleAfter.`,
679
+ ]
680
+ : []),
681
+ ...(complete && campaign.onComplete === "keep"
682
+ ? ["", `notice: campaign ${campaign.id} is complete, and stays as a guard.`]
683
+ : []),
684
+ ];
685
+ });
686
+ };
222
687
 
223
- if (reportable.length > 0 || findings.unresolved.length > 0) {
224
- return yield* Effect.fail(fail("architecture violations"));
225
- }
688
+ const renderText = (report: CheckReport): ReadonlyArray<string> => {
689
+ const reportable = report.violations.filter((one) => one.kind !== "campaign" && !one.baselined);
690
+ const carried =
691
+ report.violations.filter((one) => one.kind !== "campaign").length - reportable.length;
692
+ const shortfalls = shortfallsOf(report.coverage);
693
+ const excesses = excessesOf(report.conformance);
694
+ return [
695
+ ...reportable.map(describe),
696
+ ...report.unresolved.map(
697
+ (one) => ` unresolved: ${one.file} → ${one.specifier} (${one.detail})`,
698
+ ),
699
+ "",
700
+ `${String(report.files)} files, ${String(reportable.length)} violations` +
701
+ (carried > 0 ? `, ${String(carried)} carried by the baseline` : ""),
702
+ // The ratchet: a fixed violation must leave the baseline, or the floor
703
+ // never rises and the file stops describing anything real.
704
+ ...(report.stale.length === 0
705
+ ? []
706
+ : [
707
+ "",
708
+ `${String(report.stale.length)} baseline entries no longer fire. The code was fixed; prune them:`,
709
+ ...report.stale.map((entry) => ` ${entry}`),
710
+ "",
711
+ " architecture baseline # rewrites the file from what still fires",
712
+ ]),
713
+ ...(shortfalls.length === 0
714
+ ? []
715
+ : [
716
+ "",
717
+ "coverage is below the floor the policy states for itself:",
718
+ ...shortfalls.map(
719
+ (one) => ` ${one.family}: ${percent(one.actual)} covered, floor ${percent(one.floor)}`,
720
+ ),
721
+ "",
722
+ " architecture coverage # which files no rule reaches",
723
+ ]),
724
+ ...(excesses.length === 0
725
+ ? []
726
+ : [
727
+ "",
728
+ "conformance is above the ceiling the policy states for itself:",
729
+ ...excesses.map(describeExcess),
730
+ "",
731
+ " architecture conformance # which files, nodes and allowances",
732
+ ]),
733
+ ...renderCampaigns(report),
734
+ ];
735
+ };
736
+
737
+ export type CheckOptions = {
738
+ readonly format: "text" | "json";
739
+ readonly manifestPath: string;
740
+ };
741
+
742
+ export const check = (
743
+ policy: LoadedPolicy,
744
+ roots: ReadonlyArray<string>,
745
+ options: CheckOptions,
746
+ ): Effect.Effect<void, CliFailure> =>
747
+ Effect.gen(function* () {
748
+ const report_ = checkReport(policy, roots, options.manifestPath);
749
+ // JSON is one object on stdout and nothing else there; the failure, when
750
+ // there is one, is a sentence on stderr and the exit code, as in text.
751
+ yield* report(
752
+ options.format === "json" ? [JSON.stringify(report_, null, 2)] : renderText(report_),
753
+ );
754
+ const failure = failureOf(report_, shortfallsOf(report_.coverage));
755
+ if (failure !== null) return yield* Effect.fail(failure);
226
756
  });
227
757
 
228
758
  const percent = (fraction: number): string => `${String(Math.floor(fraction * 100))}%`;
229
759
 
760
+ const count = (n: number, noun: string, plural = `${noun}s`): string =>
761
+ `${String(n)} ${n === 1 ? noun : plural}`;
762
+
763
+ // The conformance snapshot: `check`'s report grown with what no family
764
+ // reaches, what the allowlists permit and nothing uses, the cycle count and
765
+ // the size of the debt — the whole distance between the tree and the
766
+ // manifest, as one document another run can be compared against. Its shape
767
+ // is the core's `Snapshot`, and the schema published beside the manifest's.
768
+ export const snapshotOf = (
769
+ policy: LoadedPolicy,
770
+ roots: ReadonlyArray<string>,
771
+ manifestPath: string,
772
+ ): Snapshot => {
773
+ const findings = collectFindings(policy, roots, { graph: true });
774
+ const { files, measures, report: report_ } = reportOf(policy, roots, manifestPath, findings);
775
+ const graph = findings.graph ?? { files, edges: new Map() };
776
+ const heights = heightOf(graph);
777
+
778
+ // Leaf edges first. A violation names a target when it is about an edge;
779
+ // the cost of fixing it is roughly how much of the graph stands beneath
780
+ // that target, so the ones nearest the ground come first and a reader
781
+ // starting at the top of the list is starting where a fix stays local.
782
+ // Ties keep the fingerprint order, so the list is the same on every run.
783
+ const heightOfViolation = (one: ReportedViolation): number =>
784
+ heights.get(one.subject ?? "") ?? heights.get(one.file) ?? 0;
785
+ const violations = [...report_.violations].sort((left, right) => {
786
+ const byHeight = heightOfViolation(left) - heightOfViolation(right);
787
+ return byHeight !== 0 ? byHeight : left.fingerprint.localeCompare(right.fingerprint);
788
+ });
789
+
790
+ const campaigns: ReadonlyArray<SnapshotCampaign> = policy.campaignRules.map((rule) => {
791
+ const ledger = policy.ledgers.get(rule.id);
792
+ const own = findings.campaigns.filter((hit) => hit.campaign === rule.id).length;
793
+ const base: SnapshotCampaign = {
794
+ id: rule.id,
795
+ ...(rule.title === null ? {} : { title: rule.title }),
796
+ ...(rule.owner === null ? {} : { owner: rule.owner }),
797
+ initial: own,
798
+ allowed: 0,
799
+ count: own,
800
+ fixed: 0,
801
+ progress: 0,
802
+ lastProgress: new Date(policy.now).toISOString(),
803
+ regressions: 0,
804
+ stalled: false,
805
+ complete: own === 0,
806
+ onComplete: rule.onComplete,
807
+ ledgered: false,
808
+ };
809
+ if (ledger === undefined) return base;
810
+ return {
811
+ ...base,
812
+ initial: ledger.initial,
813
+ allowed: ledger.regressions.reduce((sum, one) => sum + one.delta, 0),
814
+ count: ledger.entries.length,
815
+ fixed: ledger.fixed,
816
+ progress: progressOf(ledger),
817
+ lastProgress: ledger.lastProgress,
818
+ regressions: ledger.regressions.length,
819
+ stalled: isStalled(rule, ledger, policy.now),
820
+ complete: isComplete(ledger),
821
+ ledgered: true,
822
+ };
823
+ });
824
+
825
+ return {
826
+ version: SNAPSHOT_VERSION,
827
+ manifest: report_.manifest,
828
+ roots: report_.roots,
829
+ files: report_.files,
830
+ ok: report_.ok,
831
+ coverage: report_.coverage,
832
+ conformance: report_.conformance,
833
+ residue: measures.residue,
834
+ vacant: measures.vacant,
835
+ violations,
836
+ unresolved: report_.unresolved,
837
+ stale: report_.stale,
838
+ baseline: { size: readBaseline(policy).entries.length },
839
+ cycles: cyclesIn(graph).length,
840
+ slack: measures.slack,
841
+ concentration: measures.concentration,
842
+ adoption: report_.adoption,
843
+ campaigns,
844
+ };
845
+ };
846
+
847
+ // The campaigns, stalled and complete first, then by progress.
848
+ const renderCampaignRows = (campaigns: ReadonlyArray<SnapshotCampaign>): ReadonlyArray<string> => {
849
+ const width = Math.max(0, ...campaigns.map((one) => one.id.length));
850
+ const state = (one: SnapshotCampaign): string =>
851
+ !one.ledgered ? "no ledger" : one.complete ? "complete" : one.stalled ? "stalled" : "";
852
+ const ordered = [...campaigns].sort((left, right) => {
853
+ const rank = (one: SnapshotCampaign): number =>
854
+ one.stalled ? 0 : one.complete && one.ledgered ? 1 : 2;
855
+ const byRank = rank(left) - rank(right);
856
+ return byRank !== 0 ? byRank : left.progress - right.progress;
857
+ });
858
+ return ordered.map(
859
+ (one) =>
860
+ ` ${one.id.padEnd(width)} ${percent(one.progress).padStart(4)} ${String(one.count).padStart(5)} left` +
861
+ ` ${String(one.fixed)} fixed ${String(one.allowed)} allowed` +
862
+ (one.owner === undefined ? "" : ` ${one.owner}`) +
863
+ (state(one) === "" ? "" : ` ${state(one)}`),
864
+ );
865
+ };
866
+
867
+ const renderSnapshot = (snapshot: Snapshot): ReadonlyArray<string> => {
868
+ const reportable = snapshot.violations.filter((one) => !one.baselined && !one.ledgered);
869
+ const carried = snapshot.violations.length - reportable.length;
870
+ const row = (family: CoverageFamily): string => {
871
+ const { covered, floor, total } = snapshot.coverage[family];
872
+ const fraction = total === 0 ? 1 : covered / total;
873
+ const mark =
874
+ floor === undefined
875
+ ? ""
876
+ : fraction >= floor
877
+ ? ` ≥ ${percent(floor)} ✓`
878
+ : ` < ${percent(floor)} ✗`;
879
+ return ` ${family.padEnd(10)} ${String(covered).padStart(5)}/${String(total)} ${percent(fraction).padStart(4)}${mark}`;
880
+ };
881
+ const section = (title: string, lines: ReadonlyArray<string>): ReadonlyArray<string> => [
882
+ "",
883
+ title,
884
+ ...lines,
885
+ ];
886
+ const vacantWidth = Math.max(0, ...snapshot.vacant.map((one) => one.node.length));
887
+ // The document carries every partly-used fragment entry; the text shows
888
+ // the ones concentrated enough to read as a per-file rule written wide.
889
+ const concentrated = snapshot.concentration.filter(isConcentrated);
890
+ // The ceiling beside each measure that has one, and the ratchet's nudge
891
+ // when the count has fallen under it: a ceiling is lowered by hand.
892
+ const ceilingMark = (measure: ConformanceMeasure): string => {
893
+ const { ceiling, count: actual } = snapshot.conformance[measure];
894
+ if (ceiling === undefined) return "";
895
+ if (actual > ceiling) return ` > ${String(ceiling)} ✗`;
896
+ return ` ≤ ${String(ceiling)} ✓${actual < ceiling ? `, lower it to ${String(actual)}` : ""}`;
897
+ };
898
+
899
+ return [
900
+ `${String(snapshot.files)} files under ${snapshot.roots.join(", ")}, against ${snapshot.manifest.path}`,
901
+ ...section("coverage", COVERAGE_FAMILIES.map(row)),
902
+ ...section(
903
+ `residue: ${count(snapshot.residue.files.length, "file")} no family reaches` +
904
+ (snapshot.residue.folders.length === 0
905
+ ? ""
906
+ : `, ${count(snapshot.residue.folders.length, "folder")} wholly`) +
907
+ ceilingMark("residue"),
908
+ [
909
+ ...snapshot.residue.folders.map((folder) => ` ${folder}/`),
910
+ ...snapshot.residue.files
911
+ .filter(
912
+ (file) => !snapshot.residue.folders.some((folder) => file.startsWith(`${folder}/`)),
913
+ )
914
+ .map((file) => ` ${file}`),
915
+ ],
916
+ ),
917
+ ...section(
918
+ `vacant: ${count(snapshot.vacant.length, "node")} ${snapshot.vacant.length === 1 ? "selects" : "select"} no file` +
919
+ ceilingMark("vacant"),
920
+ snapshot.vacant.map(
921
+ (one) => ` ${one.node.padEnd(vacantWidth)} ${count(one.allowances, "allowance")}`,
922
+ ),
923
+ ),
924
+ ...section(
925
+ `violations: ${count(reportable.length, "reportable")}` +
926
+ (carried > 0 ? `, ${String(carried)} carried by the baseline or a ledger` : "") +
927
+ (snapshot.stale.length > 0
928
+ ? `, ${count(snapshot.stale.length, "stale entry", "stale entries")}`
929
+ : "") +
930
+ (reportable.length > 0 ? " — nearest the ground first" : ""),
931
+ reportable.map(describe),
932
+ ),
933
+ ...(snapshot.unresolved.length === 0
934
+ ? []
935
+ : section(
936
+ `unresolved: ${count(snapshot.unresolved.length, "import")} no rule can police`,
937
+ snapshot.unresolved.map((one) => ` ${one.file} → ${one.specifier} (${one.detail})`),
938
+ )),
939
+ ...section(
940
+ `slack: ${count(snapshot.slack.length, "allowance")} nothing imports through` +
941
+ ceilingMark("slack"),
942
+ snapshot.slack.map(
943
+ (one) =>
944
+ ` ${one.node}: ${one.kind} ${JSON.stringify(one.entry)}` +
945
+ (one.of === undefined ? "" : ` (via use, at ${count(one.of, "node")})`),
946
+ ),
947
+ ),
948
+ ...(concentrated.length === 0 && snapshot.conformance.concentration.ceiling === undefined
949
+ ? []
950
+ : section(
951
+ `concentrated: ${count(concentrated.length, "allowance")} used at fewer than half the nodes granted` +
952
+ ceilingMark("concentration"),
953
+ concentrated.map(
954
+ (one) =>
955
+ ` ${one.fragment}: ${one.kind} ${JSON.stringify(one.entry)} used at ${String(one.usedAt)} of ${count(one.of, "node")}`,
956
+ ),
957
+ )),
958
+ ...(snapshot.campaigns.length === 0
959
+ ? []
960
+ : section(
961
+ `campaigns: ${count(snapshot.campaigns.length, "campaign")}` +
962
+ (snapshot.campaigns.some((one) => one.stalled)
963
+ ? `, ${count(snapshot.campaigns.filter((one) => one.stalled).length, "stalled", "stalled")}`
964
+ : "") +
965
+ (snapshot.campaigns.some((one) => one.complete && one.ledgered)
966
+ ? `, ${count(snapshot.campaigns.filter((one) => one.complete && one.ledgered).length, "complete", "complete")}`
967
+ : ""),
968
+ renderCampaignRows(snapshot.campaigns),
969
+ )),
970
+ "",
971
+ `cycles: ${String(snapshot.cycles)}`,
972
+ `baseline: ${count(snapshot.baseline.size, "entry", "entries")}`,
973
+ `adoption: ${count(snapshot.adoption.unrestricted.length, "unrestricted tier")}, ${count(snapshot.adoption.partial.length, "partial tier")}`,
974
+ ];
975
+ };
976
+
977
+ export type ConformanceOptions = CheckOptions;
978
+
979
+ // The report of the tree against the manifest. Unlike `check`, it never
980
+ // fails: it is a measurement, and the manifest it measures against may be one
981
+ // the tree was never expected to satisfy yet — `--against` names a target.
982
+ // `ok` in the document says what `check` would have done.
983
+ export const conformance = (
984
+ policy: LoadedPolicy,
985
+ roots: ReadonlyArray<string>,
986
+ options: ConformanceOptions,
987
+ ): Effect.Effect<void, CliFailure> =>
988
+ Effect.gen(function* () {
989
+ const snapshot = snapshotOf(policy, roots, options.manifestPath);
990
+ yield* report(
991
+ options.format === "json" ? [JSON.stringify(snapshot, null, 2)] : renderSnapshot(snapshot),
992
+ );
993
+ });
994
+
230
995
  // How much of the tree the policy reaches. A probe proves a rule can fire;
231
996
  // this is whether the files are there to fire on. Reported per family, with the
232
997
  // adoption backlog — the tiers that said "not tightened yet" — beneath it.
@@ -321,7 +1086,8 @@ export const explain = (policy: LoadedPolicy, file: string): Effect.Effect<void,
321
1086
  !rule.fileNot.some((pattern) => pattern.test(relative)),
322
1087
  );
323
1088
 
324
- const firstSentence = (message: string) => `${message.split(". ")[0] ?? message}.`;
1089
+ const firstSentence = (message: string) =>
1090
+ `${(message.split(". ")[0] ?? message).replace(/\.$/, "")}.`;
325
1091
  const named = (rule: { readonly name: string; readonly message: string }): string =>
326
1092
  ` ${rule.name} — ${firstSentence(rule.message)}`;
327
1093
 
@@ -347,6 +1113,33 @@ export const explain = (policy: LoadedPolicy, file: string): Effect.Effect<void,
347
1113
  const section = (title: string, lines: ReadonlyArray<string>): ReadonlyArray<string> =>
348
1114
  lines.length === 0 ? [] : ["", title, ...lines];
349
1115
 
1116
+ // Each campaign selecting the file, with its truth table: one line per
1117
+ // leaf term and what it answered here, so a detector that "should fire"
1118
+ // and does not shows which term is not saying what its author thinks.
1119
+ const selectedCampaigns = campaignsSelecting(policy.campaignRules, relative);
1120
+ const campaignLines = selectedCampaigns.flatMap((rule) => {
1121
+ const at = path.join(policy.repoRoot, relative);
1122
+ const text = existsSync(at) ? readFileSync(at, "utf8") : "";
1123
+ const input = {
1124
+ file: relative,
1125
+ text,
1126
+ facts: policy.extractor.factsOf(relative, text),
1127
+ resolver: policy.resolver,
1128
+ fileSystem: policy.fileSystem,
1129
+ syntax: policy.syntax.parse(relative, text),
1130
+ functions: policy.functions,
1131
+ reports: policy.reports,
1132
+ };
1133
+ const hits = evaluateCampaigns([rule], input);
1134
+ return [
1135
+ ` ${rule.name} — ${firstSentence(rule.why)} (${rule.unit}; ${hits.length === 0 ? "no hit" : count(hits.length, "hit")})`,
1136
+ ...explainCampaign(rule, input).map(
1137
+ (line) =>
1138
+ ` ${line.answer ? "✓" : "✗"} ${line.term}${line.count === undefined ? "" : ` (${String(line.count)})`}`,
1139
+ ),
1140
+ ];
1141
+ });
1142
+
350
1143
  yield* report([
351
1144
  relative,
352
1145
  "",
@@ -379,6 +1172,7 @@ export const explain = (policy: LoadedPolicy, file: string): Effect.Effect<void,
379
1172
  ...section(" vocabulary (members):", vocabulary.map(named)),
380
1173
  ...section(" may export (surface):", surface.map(named)),
381
1174
  ...section(" graph:", graph),
1175
+ ...section(" campaigns:", campaignLines),
382
1176
  ]);
383
1177
  });
384
1178
 
@@ -445,25 +1239,460 @@ export const facts = (
445
1239
  ]);
446
1240
  });
447
1241
 
1242
+ const SCHEMA_HEADER = `# yaml-language-server: $schema=${MANIFEST_SCHEMA_ID}\n`;
1243
+
1244
+ // A first manifest: one open root that reaches itself, the ceilings at zero,
1245
+ // and a comment per section naming the page that explains it. Tight enough to
1246
+ // fire on the first external import — which is the moment the author learns
1247
+ // where the allowlist is — and small enough to read in one sitting.
1248
+ const STARTER_MANIFEST = `${SCHEMA_HEADER}#
1249
+ # The architecture policy: one manifest of this repository.
1250
+ # https://dataquail.github.io/goodbones/architecture-rules/manifest/
1251
+ #
1252
+ # A key ending in \`/\` is a folder; anything else is a file. The default is
1253
+ # tight: a folder admits only the children it lists, and a file may import only
1254
+ # what it or an ancestor allows. Laxity is opted into, by name, at the node that
1255
+ # wants it. Quote every glob — \`*\` and \`@\` mean something else to YAML bare.
1256
+
1257
+ # How an import specifier becomes a file. Every pattern below is matched
1258
+ # against a resolved path, so this is what makes the rest mean anything.
1259
+ # https://dataquail.github.io/goodbones/architecture-rules/enforcement/resolution/
1260
+ resolve:
1261
+ scopes:
1262
+ - files: ""
1263
+ language: typescript
1264
+ options: { tsconfig: tsconfig.json }
1265
+ unresolved: error
1266
+
1267
+ # Violations this repository is carrying while it adopts the policy, keyed by
1268
+ # fingerprint. Written by \`architecture baseline\`; the floor only rises.
1269
+ # https://dataquail.github.io/goodbones/architecture-rules/enforcement/baseline/
1270
+ baseline: .architecture-baseline.json
1271
+
1272
+ # Ceilings on how many tiers may say "not tightened yet". At zero, raising one
1273
+ # is a line in this file a reviewer sees. The same block takes coverage floors
1274
+ # and ceilings on what \`architecture conformance\` measures, once there are
1275
+ # numbers to write.
1276
+ # https://dataquail.github.io/goodbones/architecture-rules/enforcement/adoption/
1277
+ limits:
1278
+ unrestricted: 0
1279
+ partial: 0
1280
+
1281
+ # Migrations the repository is running, each with a detector, a rationale, a
1282
+ # guide, an owner and a ledger of every place the pattern still occurs. Fill
1283
+ # one in, then \`architecture campaigns init <id>\` to write its ledger.
1284
+ # https://dataquail.github.io/goodbones/architecture-rules/manifest/campaigns/
1285
+ # campaigns:
1286
+ # - id: js-to-ts
1287
+ # why: The strict tsconfig cannot land while any src file is JavaScript.
1288
+ # how: Rename to .ts, add types at the module boundary, leave the body alone.
1289
+ # scope: ["src/**"]
1290
+ # unit: file
1291
+ # detect: { path: { file: "\\.(js|jsx)$" } }
1292
+ # probes: { fires: [{ path: src/legacy/util.js }], ignores: [{ path: src/util.ts }] }
1293
+ # staleAfter: 14d
1294
+ # onComplete: remove
1295
+
1296
+ # The repository. One open root, reaching itself and the runtime; run
1297
+ # \`architecture check\` to see what else it reaches, and write that down here.
1298
+ # https://dataquail.github.io/goodbones/architecture-rules/manifest/imports/
1299
+ tree:
1300
+ "src/":
1301
+ message: "src/ is the whole program. Nothing in it is layered yet."
1302
+ layout: open
1303
+ imports:
1304
+ message: "This import is not on the allowlist."
1305
+ allow: ["src/**", "node:**"]
1306
+ # npm packages this tier may reach, by name.
1307
+ external: []
1308
+ children: {}
1309
+ `;
1310
+
1311
+ // A starter manifest, for a repository that has none.
1312
+ export const init = (repoRoot: string): Effect.Effect<void, CliFailure> =>
1313
+ Effect.gen(function* () {
1314
+ const present = MANIFEST_FILENAMES.filter((name) => existsSync(path.resolve(repoRoot, name)));
1315
+ if (present.length > 0) {
1316
+ return yield* Effect.fail(
1317
+ fail(
1318
+ `${present.join(", ")} already exists. \`init\` writes a starter manifest for a ` +
1319
+ `repository that has none, and does not overwrite one.`,
1320
+ ),
1321
+ );
1322
+ }
1323
+ yield* Effect.sync(() => {
1324
+ writeFileSync(path.resolve(repoRoot, "architecture.yaml"), STARTER_MANIFEST);
1325
+ });
1326
+ yield* report([
1327
+ "wrote architecture.yaml.",
1328
+ "",
1329
+ " architecture check # what src/ reaches today; add it to the allowlist by name",
1330
+ " architecture coverage # how much of the tree the policy reaches",
1331
+ ]);
1332
+ });
1333
+
1334
+ // The same manifest as a data file. Nothing is hoisted into `defs` — which
1335
+ // subtrees are worth naming is the author's call — and comments do not
1336
+ // survive, since no tool carries them across; the report says so.
1337
+ export const migrate = (
1338
+ repoRoot: string,
1339
+ configFilename?: string,
1340
+ ): Effect.Effect<void, CliFailure> =>
1341
+ Effect.gen(function* () {
1342
+ const from = yield* Effect.try({
1343
+ try: () =>
1344
+ configFilename === undefined
1345
+ ? findManifestFile(repoRoot)
1346
+ : path.resolve(repoRoot, configFilename),
1347
+ catch: (cause) => fail(String(cause)),
1348
+ });
1349
+ if (![".mjs", ".js", ".cjs"].includes(path.extname(from))) {
1350
+ return yield* Effect.fail(
1351
+ fail(
1352
+ `${path.basename(from)} is already a data file. \`migrate\` reads a JavaScript ` +
1353
+ `manifest and writes the same policy as architecture.yaml.`,
1354
+ ),
1355
+ );
1356
+ }
1357
+ const to = path.resolve(repoRoot, "architecture.yaml");
1358
+ if (existsSync(to)) {
1359
+ return yield* Effect.fail(
1360
+ fail("architecture.yaml already exists; `migrate` does not overwrite it."),
1361
+ );
1362
+ }
1363
+
1364
+ const read = yield* Effect.tryPromise({
1365
+ try: () => readManifestFile(from),
1366
+ catch: (cause) => fail(String(cause)),
1367
+ });
1368
+ // Written only if it decodes: a manifest that does not load as a module
1369
+ // is not going to load as YAML either, and the error names why.
1370
+ const decoded = decodeManifest(from, read.manifest);
1371
+ if (Result.isFailure(decoded)) return yield* Effect.fail(fail(decoded.failure.message));
1372
+
1373
+ yield* Effect.sync(() => {
1374
+ writeFileSync(to, `${SCHEMA_HEADER}\n${formatManifestYaml(read.manifest)}`);
1375
+ });
1376
+ yield* report([
1377
+ `wrote architecture.yaml from ${path.basename(from)}.`,
1378
+ "",
1379
+ "Comments were not carried over; port the ones worth keeping by hand.",
1380
+ `Then delete ${path.basename(from)}: a repository with two manifests is refused.`,
1381
+ ]);
1382
+ });
1383
+
1384
+ // The ledgers. `campaigns` alone is the status table; `init` writes a
1385
+ // campaign's first ledger from what fires today; `prune` removes what no
1386
+ // longer fires; `allow` is the one way an entry is added, and it records why.
1387
+ const ledgerPathOf = (policy: LoadedPolicy, id: string): string =>
1388
+ path.resolve(policy.repoRoot, policy.ledgerDir, `${id}.json`);
1389
+
1390
+ const writeLedger = (policy: LoadedPolicy, ledger: Ledger): void => {
1391
+ const at = ledgerPathOf(policy, ledger.id);
1392
+ mkdirSync(path.dirname(at), { recursive: true });
1393
+ writeFileSync(at, serializeLedger(ledger));
1394
+ };
1395
+
1396
+ const campaignNamed = (policy: LoadedPolicy, id: string): CompiledCampaign | null =>
1397
+ policy.campaignRules.find((rule) => rule.id === id) ?? null;
1398
+
1399
+ // The author of a regression: `--by`, else git's user.email, else the
1400
+ // GIT_AUTHOR_EMAIL the environment carries. Without one the record is refused
1401
+ // rather than written blank, since the record is the point.
1402
+ const authorOf = (given: string | undefined): string | null => {
1403
+ if (given !== undefined && given !== "") return given;
1404
+ try {
1405
+ const email = execFileSync("git", ["config", "user.email"], {
1406
+ encoding: "utf8",
1407
+ stdio: ["ignore", "pipe", "ignore"],
1408
+ }).trim();
1409
+ if (email !== "") return email;
1410
+ } catch {
1411
+ // git absent, or no email configured
1412
+ }
1413
+ const fromEnvironment = process.env.GIT_AUTHOR_EMAIL;
1414
+ return fromEnvironment === undefined || fromEnvironment === "" ? null : fromEnvironment;
1415
+ };
1416
+
1417
+ const flagOf = (argv: ReadonlyArray<string>, flag: string): string | undefined => {
1418
+ const at = argv.indexOf(flag);
1419
+ const value = at === -1 ? undefined : argv[at + 1];
1420
+ return value === undefined || value.startsWith("--") ? undefined : value;
1421
+ };
1422
+
1423
+ const CAMPAIGN_SUBCOMMANDS = ["init", "prune", "allow"] as const;
1424
+ const CAMPAIGN_VALUE_FLAGS = ["--reason", "--by", "--entries"] as const;
1425
+
1426
+ // `campaigns [init <id> | prune [<id>] | allow <id> --reason <text>] [roots…]`:
1427
+ // the subcommand and its id come first; whatever positional is left names
1428
+ // the roots to walk, as it does for every other command.
1429
+ export const campaignArgsOf = (
1430
+ argv: ReadonlyArray<string>,
1431
+ ids: ReadonlyArray<string>,
1432
+ ): {
1433
+ readonly subcommand: string | undefined;
1434
+ readonly id: string | undefined;
1435
+ readonly roots: ReadonlyArray<string>;
1436
+ } => {
1437
+ const positional: Array<string> = [];
1438
+ for (let at = 0; at < argv.length; at += 1) {
1439
+ const one = argv[at] ?? "";
1440
+ if ((CAMPAIGN_VALUE_FLAGS as ReadonlyArray<string>).includes(one)) {
1441
+ at += 1;
1442
+ continue;
1443
+ }
1444
+ if (!one.startsWith("--")) positional.push(one);
1445
+ }
1446
+ const [first, second, ...rest] = positional;
1447
+ if (first === undefined || !(CAMPAIGN_SUBCOMMANDS as ReadonlyArray<string>).includes(first)) {
1448
+ return { subcommand: undefined, id: undefined, roots: positional };
1449
+ }
1450
+ // `prune` takes an optional id; the next word is one only if a campaign
1451
+ // has that name, else it is a root.
1452
+ const takesId = first !== "prune" || (second !== undefined && ids.includes(second));
1453
+ return takesId
1454
+ ? { subcommand: first, id: second, roots: rest }
1455
+ : { subcommand: first, id: undefined, roots: second === undefined ? [] : [second, ...rest] };
1456
+ };
1457
+
1458
+ export const campaigns = (
1459
+ policy: LoadedPolicy,
1460
+ defaultRoots: ReadonlyArray<string>,
1461
+ argv: ReadonlyArray<string>,
1462
+ ): Effect.Effect<void, CliFailure> =>
1463
+ Effect.gen(function* () {
1464
+ const parsed = campaignArgsOf(
1465
+ argv,
1466
+ policy.campaignRules.map((rule) => rule.id),
1467
+ );
1468
+ const { id, subcommand } = parsed;
1469
+ const roots = parsed.roots.length > 0 ? parsed.roots : defaultRoots;
1470
+ if (policy.campaignRules.length === 0) {
1471
+ return yield* report(["this policy declares no campaigns."]);
1472
+ }
1473
+ const hitsOf = (): ReadonlyArray<CampaignHit> => collectFindings(policy, roots).campaigns;
1474
+ const own = (hits: ReadonlyArray<CampaignHit>, campaign: string): ReadonlyArray<Violation> =>
1475
+ hits.filter((hit) => hit.campaign === campaign).map((hit) => hit.violation);
1476
+
1477
+ switch (subcommand) {
1478
+ case undefined: {
1479
+ const snapshot = snapshotOf(policy, roots, manifestPathOf(policy.repoRoot));
1480
+ return yield* report([
1481
+ `${count(snapshot.campaigns.length, "campaign")} under ${roots.join(", ")}`,
1482
+ "",
1483
+ ...renderCampaignRows(snapshot.campaigns),
1484
+ "",
1485
+ " architecture campaigns init <id> # write a ledger from what fires today",
1486
+ " architecture campaigns prune [<id>] # drop entries that no longer fire",
1487
+ ' architecture campaigns allow <id> --reason "<why>" # record why the count may rise',
1488
+ ]);
1489
+ }
1490
+ case "init": {
1491
+ if (id === undefined) return yield* Effect.fail(fail("campaigns init needs a campaign id"));
1492
+ const rule = campaignNamed(policy, id);
1493
+ if (rule === null) return yield* Effect.fail(fail(`no campaign is named "${id}"`));
1494
+ if (policy.ledgers.has(id)) {
1495
+ return yield* Effect.fail(
1496
+ fail(
1497
+ `${path.relative(policy.repoRoot, ledgerPathOf(policy, id))} already exists. \`init\` ` +
1498
+ `writes a campaign's first ledger and does not overwrite one; \`prune\` and \`allow\` ` +
1499
+ `are how it changes.`,
1500
+ ),
1501
+ );
1502
+ }
1503
+ const ledger = ledgerOf(id, own(hitsOf(), id), policy.now);
1504
+ yield* Effect.sync(() => {
1505
+ writeLedger(policy, ledger);
1506
+ });
1507
+ return yield* report([
1508
+ `${count(ledger.entries.length, "hit")} recorded in ${path.relative(policy.repoRoot, ledgerPathOf(policy, id))}.`,
1509
+ "Each one is a place the campaign has yet to reach. Fixing one means pruning its line.",
1510
+ ]);
1511
+ }
1512
+ case "prune": {
1513
+ const targets =
1514
+ id === undefined
1515
+ ? policy.campaignRules
1516
+ : [campaignNamed(policy, id)].filter((one) => one !== null);
1517
+ if (id !== undefined && targets.length === 0) {
1518
+ return yield* Effect.fail(fail(`no campaign is named "${id}"`));
1519
+ }
1520
+ const hits = hitsOf();
1521
+ const lines: Array<string> = [];
1522
+ for (const rule of targets) {
1523
+ const ledger = policy.ledgers.get(rule.id);
1524
+ if (ledger === undefined) {
1525
+ lines.push(`${rule.id}: no ledger to prune (run \`campaigns init ${rule.id}\`).`);
1526
+ continue;
1527
+ }
1528
+ const next = pruned(ledger, own(hits, rule.id), rule.unit, policy.now);
1529
+ const removed = ledger.entries.length - next.entries.length;
1530
+ const rewritten = next.entries.filter((entry) => !ledger.entries.includes(entry)).length;
1531
+ if (removed === 0 && rewritten === 0) {
1532
+ lines.push(`${rule.id}: nothing to prune.`);
1533
+ continue;
1534
+ }
1535
+ yield* Effect.sync(() => {
1536
+ writeLedger(policy, next);
1537
+ });
1538
+ lines.push(
1539
+ `${rule.id}: ${count(removed, "entry", "entries")} pruned` +
1540
+ (rewritten > 0 ? `, ${count(rewritten, "entry", "entries")} rewritten` : "") +
1541
+ `; ${count(next.entries.length, "entry", "entries")} left.`,
1542
+ );
1543
+ }
1544
+ return yield* report(lines);
1545
+ }
1546
+ case "allow": {
1547
+ if (id === undefined)
1548
+ return yield* Effect.fail(fail("campaigns allow needs a campaign id"));
1549
+ const rule = campaignNamed(policy, id);
1550
+ if (rule === null) return yield* Effect.fail(fail(`no campaign is named "${id}"`));
1551
+ const ledger = policy.ledgers.get(id);
1552
+ if (ledger === undefined) {
1553
+ return yield* Effect.fail(
1554
+ fail(`campaign ${id} has no ledger yet; run \`campaigns init ${id}\` first.`),
1555
+ );
1556
+ }
1557
+ const reason = flagOf(argv, "--reason");
1558
+ if (reason === undefined) {
1559
+ return yield* Effect.fail(
1560
+ fail(
1561
+ "campaigns allow needs --reason <text>: growth is recorded with why, or not at all.",
1562
+ ),
1563
+ );
1564
+ }
1565
+ const by = authorOf(flagOf(argv, "--by"));
1566
+ if (by === null) {
1567
+ return yield* Effect.fail(
1568
+ fail("campaigns allow needs an author: pass --by <email>, or set git's user.email."),
1569
+ );
1570
+ }
1571
+ const state = reconcile(ledger, own(hitsOf(), id), rule.unit);
1572
+ const unrecorded = [...new Set(state.unrecorded.map(entryOf))].sort();
1573
+ // `--entries` allows a subset and refuses the rest: a pull request
1574
+ // that legitimately adds one hit while another is an accident.
1575
+ const chosen =
1576
+ flagOf(argv, "--entries")
1577
+ ?.split(",")
1578
+ .map((one) => one.trim()) ?? unrecorded;
1579
+ const unknown = chosen.filter((entry) => !unrecorded.includes(entry));
1580
+ if (unknown.length > 0) {
1581
+ return yield* Effect.fail(
1582
+ fail(`these entries are not unrecorded hits of ${id}: ${unknown.join(", ")}`),
1583
+ );
1584
+ }
1585
+ if (chosen.length === 0) {
1586
+ return yield* report([`${id}: nothing to allow; every hit is in the ledger.`]);
1587
+ }
1588
+ const next = allowed(ledger, chosen, { at: policy.now, by, reason });
1589
+ yield* Effect.sync(() => {
1590
+ writeLedger(policy, next);
1591
+ });
1592
+ const left = unrecorded.filter((entry) => !chosen.includes(entry));
1593
+ return yield* report([
1594
+ `${id}: ${count(chosen.length, "entry", "entries")} allowed, recorded as a regression by ${by}.`,
1595
+ ...chosen.map((entry) => ` ${entry}`),
1596
+ ...(left.length === 0
1597
+ ? []
1598
+ : ["", `${count(left.length, "hit")} left unrecorded; check still fails on them.`]),
1599
+ ]);
1600
+ }
1601
+ default:
1602
+ return yield* Effect.fail(
1603
+ fail(
1604
+ `unknown campaigns subcommand "${subcommand}". Try: campaigns | campaigns init <id> | campaigns prune [<id>] | campaigns allow <id> --reason <text> [--by <email>] [--entries a,b]`,
1605
+ ),
1606
+ );
1607
+ }
1608
+ });
1609
+
1610
+ // The commands that judge a campaign, and so ask its report source.
1611
+ const READS_REPORTS: ReadonlySet<string> = new Set([
1612
+ "check",
1613
+ "conformance",
1614
+ "baseline",
1615
+ "campaigns",
1616
+ "explain",
1617
+ ]);
1618
+
448
1619
  export const run = (
449
1620
  repoRoot: string,
450
1621
  argv: ReadonlyArray<string>,
1622
+ // From ARCHITECTURE_CONFIG. Absent, the manifest is discovered by name.
1623
+ configFilename?: string,
451
1624
  ): Effect.Effect<void, CliFailure> =>
452
1625
  Effect.gen(function* () {
453
1626
  const [command = "check", ...rest] = argv;
1627
+
1628
+ // The three commands that write a manifest rather than read one.
1629
+ if (command === "init") return yield* init(repoRoot);
1630
+ if (command === "migrate") return yield* migrate(repoRoot, configFilename);
1631
+ if (command === "infer") {
1632
+ yield* infer(repoRoot, rest, configFilename);
1633
+ return;
1634
+ }
1635
+
1636
+ // `--against <file>` measures the tree against a manifest other than the
1637
+ // repository's own — the target the team is moving toward. Only
1638
+ // `conformance` takes it: a `check` against a manifest nobody is held to
1639
+ // yet would fail for no one's benefit.
1640
+ const againstAt = rest.indexOf("--against");
1641
+ const against = againstAt === -1 ? undefined : rest[againstAt + 1];
1642
+ if (againstAt !== -1 && (against === undefined || against.startsWith("--"))) {
1643
+ return yield* Effect.fail(fail("--against needs a manifest path"));
1644
+ }
1645
+ if (against !== undefined && command !== "conformance") {
1646
+ return yield* Effect.fail(
1647
+ fail(`--against is a \`conformance\` flag; ${command} does not take it`),
1648
+ );
1649
+ }
1650
+ const manifestFilename = against ?? configFilename;
1651
+
454
1652
  const policy = yield* Effect.tryPromise({
455
- try: () => loadPolicyFromFile(repoRoot),
1653
+ try: () => loadPolicyFromFile(repoRoot, manifestFilename),
456
1654
  catch: (cause) => fail(String(cause)),
457
1655
  });
458
1656
  yield* Effect.sync(() => {
459
1657
  for (const notice of policy.notices) process.stderr.write(`deprecated: ${notice}\n`);
460
1658
  });
461
1659
 
462
- const roots = rest.length > 0 ? rest : ["packages"];
1660
+ // Every `report` a campaign names is read now, before any file asks:
1661
+ // a term's several commands run at once rather than one after another
1662
+ // the first time a campaign selects a file. What cannot be read is kept
1663
+ // for the campaign to report; a report that does not parse is refused.
1664
+ if (READS_REPORTS.has(command)) {
1665
+ yield* Effect.tryPromise({
1666
+ try: () =>
1667
+ Promise.all(
1668
+ reportSpecsOf(policy.campaignRules).map((spec) => policy.reports.read?.(spec)),
1669
+ ),
1670
+ catch: (cause) => fail(String(cause)),
1671
+ });
1672
+ }
1673
+
1674
+ const json = rest.includes("--json");
1675
+ const positional = rest.filter(
1676
+ (argument, index) =>
1677
+ argument !== "--json" &&
1678
+ argument !== "--against" &&
1679
+ (againstAt === -1 || index !== againstAt + 1),
1680
+ );
1681
+ const roots = positional.length > 0 ? positional : ["packages"];
463
1682
 
464
1683
  switch (command) {
1684
+ case "campaigns":
1685
+ return yield* campaigns(policy, ["packages"], rest);
465
1686
  case "check":
466
- return yield* check(policy, roots);
1687
+ return yield* check(policy, roots, {
1688
+ format: json ? "json" : "text",
1689
+ manifestPath: manifestPathOf(repoRoot, configFilename),
1690
+ });
1691
+ case "conformance":
1692
+ return yield* conformance(policy, roots, {
1693
+ format: json ? "json" : "text",
1694
+ manifestPath: manifestPathOf(repoRoot, manifestFilename),
1695
+ });
467
1696
  case "baseline":
468
1697
  return yield* writeBaseline(policy, roots);
469
1698
  case "explain": {
@@ -474,14 +1703,14 @@ export const run = (
474
1703
  case "coverage":
475
1704
  return yield* coverage(policy, roots);
476
1705
  case "facts": {
477
- const [file] = rest.filter((argument) => argument !== "--json");
1706
+ const [file] = positional;
478
1707
  if (file === undefined) return yield* Effect.fail(fail("facts needs a file path"));
479
- return yield* facts(policy, file, rest.includes("--json") ? "json" : "text");
1708
+ return yield* facts(policy, file, json ? "json" : "text");
480
1709
  }
481
1710
  default:
482
1711
  return yield* Effect.fail(
483
1712
  fail(
484
- `unknown command "${command}". Try: check | baseline | coverage | explain <file> | facts <file> [--json]`,
1713
+ `unknown command "${command}". Try: check [--json] | conformance [--json] [--against <manifest>] | baseline | campaigns [init <id> | prune [<id>] | allow <id> --reason <text>] | coverage | explain <file> | facts <file> [--json] | init | infer | migrate`,
485
1714
  ),
486
1715
  );
487
1716
  }