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

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,88 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
3
  import * as path from "node:path";
3
4
 
5
+ import {
6
+ attest,
7
+ authorOf,
8
+ baseSideAt,
9
+ type CampaignEvaluation,
10
+ campaignFailuresOf,
11
+ type CampaignReport,
12
+ campaignReportsOf,
13
+ campaignsOf,
14
+ campaignsSelecting,
15
+ clear,
16
+ concede,
17
+ evaluateCampaigns,
18
+ explainCampaignLines,
19
+ explainObjective,
20
+ historyOf,
21
+ hitsInWindow,
22
+ ledgeredFilter,
23
+ note,
24
+ nudgeOf,
25
+ readDiff,
26
+ renderCampaignReports,
27
+ renderCampaignRows,
28
+ renderHistory,
29
+ renderNudge,
30
+ reportSpecsOf,
31
+ snapshotCampaignsOf,
32
+ widenedExtensions,
33
+ } from "@goodbones/campaigns";
4
34
  import {
5
35
  type Baseline,
6
36
  baselineOf,
37
+ CONFORMANCE_MEASURES,
38
+ type ConformanceMeasure,
39
+ type CoverageFamily,
7
40
  coverageOf,
8
- coverageShortfalls,
41
+ cyclesIn,
9
42
  decodeBaseline,
43
+ decodeManifest,
10
44
  EMPTY_BASELINE,
11
45
  evaluateGraph,
12
46
  evaluateMemberSite,
47
+ evaluateResolvedEdge,
13
48
  evaluateSelectedBindings,
14
- evaluateSelectedEdge,
15
49
  evaluateStructure,
16
50
  evaluateSurface,
17
51
  exportRulesSelecting,
52
+ findManifestFile,
18
53
  fingerprintOf,
54
+ formatManifestYaml,
19
55
  formatMessage,
20
56
  fractionsOf,
57
+ type Graph,
21
58
  hasGraphRules,
59
+ heightOf,
22
60
  listSourceFiles,
61
+ makeBaselineFilter,
62
+ MANIFEST_FILENAMES,
63
+ MANIFEST_SCHEMA_ID,
23
64
  memberRulesSelecting,
65
+ type ObservedEdge,
66
+ readManifestFile,
24
67
  requiredSiblingsOf,
68
+ residueOf,
25
69
  rulesSelecting,
26
70
  serializeBaseline,
71
+ slackOf,
72
+ type Snapshot,
73
+ SNAPSHOT_VERSION,
27
74
  type SourceFacts,
28
75
  staleEntriesOf,
29
76
  surfaceRulesSelecting,
30
- unbaselined,
77
+ vacancyOf,
31
78
  type Violation,
32
79
  } from "@goodbones/core";
33
80
  import * as Effect from "effect/Effect";
34
81
  import * as Result from "effect/Result";
35
82
 
36
- import { type LoadedPolicy, loadPolicyFromFile } from "./config-loader.js";
83
+ import { type LoadedPolicy, loadPolicyFromFile, manifestPathOf } from "./config-loader.js";
37
84
  import { buildGraph } from "./graph.js";
85
+ import { infer } from "./infer.js";
38
86
  import { sourceFactsOf } from "./source-facts.js";
39
87
 
40
88
  // The policy, run with no linter in the loop.
@@ -44,48 +92,97 @@ import { sourceFactsOf } from "./source-facts.js";
44
92
  // second way to ask the same question — and the only way to write a baseline,
45
93
  // since that needs every finding at once rather than one file at a time.
46
94
  //
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.
95
+ // It covers all four families. The ones that need a syntax tree read it through
96
+ // the language pack's own parse rather than oxlint's; both adapters meet at the
97
+ // same vocabulary — a specifier, a binding, a member site — so they answer to
98
+ // the same core rather than to each other.
51
99
 
52
100
  export type CliFailure = { readonly _tag: "CliFailure"; readonly message: string };
53
101
 
54
102
  const fail = (message: string): CliFailure => ({ _tag: "CliFailure", message });
55
103
 
104
+ // An edge the resolver could not turn into a file. It is reported on its own,
105
+ // since every import rule about it enforces nothing.
106
+ export type UnresolvedEdge = {
107
+ readonly file: string;
108
+ readonly specifier: string;
109
+ readonly detail: string;
110
+ };
111
+
56
112
  export type Findings = {
57
113
  readonly violations: ReadonlyArray<Violation>;
58
- readonly unresolved: ReadonlyArray<string>;
114
+ // Every campaign, evaluated over the files it sees: its sectors, every
115
+ // hit placed in one, each sector's phase. Kept apart from the violations:
116
+ // a hit is debt a campaign is paying down, judged against its ledger
117
+ // rather than the baseline.
118
+ readonly campaigns: ReadonlyArray<CampaignEvaluation>;
119
+ readonly unresolved: ReadonlyArray<UnresolvedEdge>;
59
120
  readonly files: number;
121
+ // Every edge resolved from a file under an import rule — what the slack
122
+ // report reads. An edge from a file no import rule selects is not here,
123
+ // since no allowlist could have admitted it.
124
+ readonly edges: ReadonlyArray<ObservedEdge>;
125
+ // The import graph, when a graph rule needed it or the caller asked.
126
+ readonly graph: Graph | null;
60
127
  };
61
128
 
62
- export const collectFindings = (policy: LoadedPolicy, roots: ReadonlyArray<string>): Findings => {
63
- const files = listSourceFiles(policy.repoRoot, roots, policy.languages);
129
+ export type CollectOptions = {
130
+ // Build the graph even when no rule needs it — the snapshot counts cycles
131
+ // and orders violations by it.
132
+ readonly graph?: boolean;
133
+ };
134
+
135
+ export const collectFindings = (
136
+ policy: LoadedPolicy,
137
+ roots: ReadonlyArray<string>,
138
+ options: CollectOptions = {},
139
+ ): Findings => {
140
+ // A campaign may widen the walk past the packs' extensions; a file only a
141
+ // campaign asked for is seen by the campaigns and by no other family.
142
+ const walked = listSourceFiles(
143
+ policy.repoRoot,
144
+ roots,
145
+ policy.languages,
146
+ widenedExtensions(policy),
147
+ );
148
+ const known = new Set(policy.languages.flatMap((one) => one.extensions));
149
+ const files = walked.filter((file) => known.has(path.extname(file)));
64
150
  const violations: Array<Violation> = [];
65
- const unresolved: Array<string> = [];
151
+ const unresolved: Array<UnresolvedEdge> = [];
152
+ const edges: Array<ObservedEdge> = [];
66
153
 
67
- // Each file is parsed at most once, whether the per-file families or the
68
- // graph pass asks first.
154
+ // Each file is read and parsed at most once, whether the per-file
155
+ // families, the graph pass or a campaign asks first.
156
+ const texts = new Map<string, string>();
157
+ const textOf = (file: string): string => {
158
+ const cached = texts.get(file);
159
+ if (cached !== undefined) return cached;
160
+ const text = readFileSync(path.join(policy.repoRoot, file), "utf8");
161
+ texts.set(file, text);
162
+ return text;
163
+ };
69
164
  const parsed = new Map<string, SourceFacts>();
70
165
  const factsOf = (file: string): SourceFacts => {
71
166
  const cached = parsed.get(file);
72
167
  if (cached !== undefined) return cached;
73
- const facts = sourceFactsOf(policy.repoRoot, file, policy.extractor);
168
+ const facts = policy.extractor.factsOf(file, textOf(file));
74
169
  parsed.set(file, facts);
75
170
  return facts;
76
171
  };
77
172
 
78
173
  // The graph is the whole repository resolved at once — the one question no
79
174
  // 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
- }
175
+ const graph =
176
+ options.graph === true || hasGraphRules(policy.graph)
177
+ ? buildGraph(files, policy.resolver, factsOf)
178
+ : null;
179
+ if (graph !== null && hasGraphRules(policy.graph)) {
180
+ for (const violation of evaluateGraph(policy.graph, graph)) violations.push(violation);
87
181
  }
88
182
 
183
+ // The campaigns, over every walked file, through the same caches.
184
+ const campaigns = evaluateCampaigns(policy, roots, walked, { textOf, factsOf });
185
+
89
186
  for (const file of files) {
90
187
  for (const violation of evaluateStructure(policy.structure, policy.fileSystem, file)) {
91
188
  violations.push(violation);
@@ -118,14 +215,21 @@ export const collectFindings = (policy: LoadedPolicy, roots: ReadonlyArray<strin
118
215
  for (const specifier of facts.specifiers) {
119
216
  const edge = { importer: file, specifier };
120
217
 
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;
218
+ // A file no import rule selects never needs its imports resolved, which
219
+ // is what keeps resolution off the hot path for the bulk of the repo.
220
+ if (selectedImports.length > 0) {
221
+ const resolved = policy.resolver.resolve(file, specifier);
222
+ if (Result.isFailure(resolved)) {
223
+ if (policy.config.resolve.unresolved === "off") continue;
224
+ if (policy.ignoreUnresolved.some((pattern) => pattern.test(specifier))) continue;
225
+ unresolved.push({ file, specifier, detail: resolved.failure.detail });
226
+ continue;
227
+ }
228
+ edges.push({ importer: file, target: resolved.success });
229
+ for (const violation of evaluateResolvedEdge(selectedImports, file, resolved.success)) {
230
+ violations.push(violation);
231
+ }
127
232
  }
128
- for (const violation of imported.success) violations.push(violation);
129
233
 
130
234
  const bound = facts.bindings.get(specifier) ?? [];
131
235
  const exported = evaluateSelectedBindings(selectedExports, policy.resolver, {
@@ -138,7 +242,7 @@ export const collectFindings = (policy: LoadedPolicy, roots: ReadonlyArray<strin
138
242
  }
139
243
  }
140
244
 
141
- return { violations, unresolved, files: files.length };
245
+ return { violations, campaigns, unresolved, files: files.length, edges, graph };
142
246
  };
143
247
 
144
248
  const baselinePathOf = (policy: LoadedPolicy): string | null =>
@@ -164,68 +268,554 @@ const report = (lines: ReadonlyArray<string>): Effect.Effect<void> =>
164
268
  const describe = (violation: Violation): string =>
165
269
  ` ${violation.file}\n ${formatMessage(violation)}`;
166
270
 
271
+ // Everything `check` has to say, as one value: the two renderers below read
272
+ // it, and nothing else computes a finding. `version` is here so a document
273
+ // that grows this shape (a conformance snapshot) can say which one it grew.
274
+ export type ReportedViolation = Violation & {
275
+ readonly fingerprint: string;
276
+ readonly baselined: boolean;
277
+ // For a campaign hit: carried by the objective's ledger, so `check` does
278
+ // not fail on it. The campaign analogue of `baselined`.
279
+ readonly ledgered: boolean;
280
+ // For a campaign hit: which objective, in which sector, and the ledger
281
+ // entry it is keyed by there.
282
+ readonly objective?: string;
283
+ readonly sector?: string;
284
+ readonly entry?: string;
285
+ };
286
+
287
+ export type CoverageReport = Readonly<
288
+ Record<
289
+ CoverageFamily,
290
+ { readonly covered: number; readonly total: number; readonly floor?: number }
291
+ >
292
+ >;
293
+
294
+ // The conformance measures as counts, each beside the ceiling the manifest's
295
+ // `limits.conformance` states for it. `conformance` names what each counts;
296
+ // `check` holds the counts to the ceilings.
297
+ export type ConformanceReport = Readonly<
298
+ Record<ConformanceMeasure, { readonly count: number; readonly ceiling?: number }>
299
+ >;
300
+
301
+ export type CheckReport = {
302
+ readonly version: 1;
303
+ readonly files: number;
304
+ readonly roots: ReadonlyArray<string>;
305
+ readonly ok: boolean;
306
+ // The file the policy was read from, repo-relative, and a hash of its
307
+ // bytes — the root file only, when the manifest is split with `include`.
308
+ readonly manifest: { readonly path: string; readonly sha256: string };
309
+ // Every finding, baselined ones included; `baselined` says which.
310
+ readonly violations: ReadonlyArray<ReportedViolation>;
311
+ readonly unresolved: ReadonlyArray<UnresolvedEdge>;
312
+ // Baseline entries the code no longer produces.
313
+ readonly stale: ReadonlyArray<string>;
314
+ readonly coverage: CoverageReport;
315
+ readonly conformance: ConformanceReport;
316
+ readonly adoption: {
317
+ readonly unrestricted: ReadonlyArray<string>;
318
+ readonly partial: ReadonlyArray<string>;
319
+ };
320
+ readonly campaigns: ReadonlyArray<CampaignReport>;
321
+ };
322
+
323
+ const COVERAGE_FAMILIES: ReadonlyArray<CoverageFamily> = [
324
+ "imports",
325
+ "structure",
326
+ "members",
327
+ "surface",
328
+ "graph",
329
+ ];
330
+
331
+ const sha256Of = (file: string): string => {
332
+ try {
333
+ return createHash("sha256").update(readFileSync(file)).digest("hex");
334
+ } catch {
335
+ return "";
336
+ }
337
+ };
338
+
339
+ export const checkReport = (
340
+ policy: LoadedPolicy,
341
+ roots: ReadonlyArray<string>,
342
+ manifestPath: string,
343
+ ): CheckReport => reportOf(policy, roots, manifestPath, collectFindings(policy, roots)).report;
344
+
345
+ // The four conformance measures, as `conformance` names them: what no
346
+ // family reaches, what no file is under, what nothing imports through, and
347
+ // the fragment entries concentrated at fewer than half the nodes granted.
348
+ type Measures = {
349
+ readonly residue: ReturnType<typeof residueOf>;
350
+ readonly vacant: ReturnType<typeof vacancyOf>;
351
+ readonly slack: ReturnType<typeof slackOf>["slack"];
352
+ readonly concentration: ReturnType<typeof slackOf>["concentration"];
353
+ };
354
+
355
+ const measuresOf = (
356
+ policy: LoadedPolicy,
357
+ files: ReadonlyArray<string>,
358
+ edges: ReadonlyArray<ObservedEdge>,
359
+ ): Measures => {
360
+ // Slack is measured over the walked files as well as the edges: an
361
+ // allowlist that selects no file is vacant, and its entries are reported as
362
+ // that rather than as lines nobody needs.
363
+ const { concentration, slack } = slackOf(policy.importRules, edges, files);
364
+ return {
365
+ residue: residueOf(policy, files),
366
+ vacant: vacancyOf(policy.importRules, files),
367
+ slack,
368
+ concentration,
369
+ };
370
+ };
371
+
372
+ // A fragment entry is concentrated when it is used at fewer than half the
373
+ // nodes granted it — the text report's threshold, and the ceiling's.
374
+ const isConcentrated = (one: { readonly usedAt: number; readonly of: number }): boolean =>
375
+ one.usedAt * 2 < one.of;
376
+
377
+ const countsOf = (measures: Measures): Readonly<Record<ConformanceMeasure, number>> => ({
378
+ residue: measures.residue.files.length,
379
+ vacant: measures.vacant.length,
380
+ slack: measures.slack.length,
381
+ concentration: measures.concentration.filter(isConcentrated).length,
382
+ });
383
+
384
+ const reportOf = (
385
+ policy: LoadedPolicy,
386
+ roots: ReadonlyArray<string>,
387
+ manifestPath: string,
388
+ findings: Findings,
389
+ ): {
390
+ readonly report: CheckReport;
391
+ readonly measures: Measures;
392
+ readonly files: ReadonlyArray<string>;
393
+ } => {
394
+ const baseline = readBaseline(policy);
395
+ const stale = staleEntriesOf(baseline, findings.violations);
396
+ const { isBaselined } = makeBaselineFilter(baseline);
397
+ const isLedgered = ledgeredFilter(policy, findings.campaigns);
398
+ const violations: Array<ReportedViolation> = [
399
+ ...findings.violations.map((violation) => ({
400
+ ...violation,
401
+ fingerprint: fingerprintOf(violation),
402
+ baselined: isBaselined(violation),
403
+ ledgered: false,
404
+ })),
405
+ // The hits that count: those whose objective is in window for the
406
+ // sector they fall in.
407
+ ...findings.campaigns.flatMap((evaluation) =>
408
+ hitsInWindow(evaluation).map((hit) => ({
409
+ ...hit.violation,
410
+ fingerprint: fingerprintOf(hit.violation),
411
+ baselined: false,
412
+ ledgered: isLedgered(hit),
413
+ objective: hit.objective,
414
+ sector: hit.sector,
415
+ entry: hit.entry,
416
+ })),
417
+ ),
418
+ ];
419
+ const campaigns = campaignReportsOf(policy, findings.campaigns);
420
+
421
+ // The floors. A policy states how much of the tree it reaches, per
422
+ // family; falling under is a policy that quietly stopped covering files.
423
+ const floors = policy.config.limits?.coverage ?? {};
424
+ const files = listSourceFiles(policy.repoRoot, roots, policy.languages);
425
+ const found = coverageOf(policy, files);
426
+ const covered = (family: CoverageFamily): number =>
427
+ family === "structure" ? found.structure.enumerated : found[family].covered;
428
+ const coverage = Object.fromEntries(
429
+ COVERAGE_FAMILIES.map((family) => {
430
+ const floor = floors[family];
431
+ return [
432
+ family,
433
+ {
434
+ covered: covered(family),
435
+ total: found.files,
436
+ ...(floor === undefined ? {} : { floor }),
437
+ },
438
+ ];
439
+ }),
440
+ ) as CoverageReport;
441
+ const shortfalls = shortfallsOf(coverage);
442
+
443
+ // The ceilings. What no family reaches, what no file is under and what
444
+ // nothing imports through are each a count the policy may hold itself
445
+ // to; rising over one is a manifest that quietly widened.
446
+ const ceilings = policy.config.limits?.conformance ?? {};
447
+ const measures = measuresOf(policy, files, findings.edges);
448
+ const counts = countsOf(measures);
449
+ const conformance = Object.fromEntries(
450
+ CONFORMANCE_MEASURES.map((measure) => {
451
+ const ceiling = ceilings[measure];
452
+ return [measure, { count: counts[measure], ...(ceiling === undefined ? {} : { ceiling }) }];
453
+ }),
454
+ ) as ConformanceReport;
455
+ const excesses = excessesOf(conformance);
456
+
457
+ const reportable = violations.filter((one) => !one.baselined && !one.ledgered).length;
458
+ const report: CheckReport = {
459
+ version: 1,
460
+ files: findings.files,
461
+ roots,
462
+ ok:
463
+ reportable === 0 &&
464
+ findings.unresolved.length === 0 &&
465
+ stale.length === 0 &&
466
+ shortfalls.length === 0 &&
467
+ excesses.length === 0 &&
468
+ campaignFailuresOf(campaigns).length === 0,
469
+ manifest: {
470
+ path: path.relative(policy.repoRoot, manifestPath).replaceAll(path.sep, "/"),
471
+ sha256: sha256Of(manifestPath),
472
+ },
473
+ violations,
474
+ unresolved: findings.unresolved,
475
+ stale,
476
+ coverage,
477
+ conformance,
478
+ adoption: {
479
+ unrestricted: policy.adoption.unrestricted,
480
+ partial: policy.adoption.partial,
481
+ },
482
+ campaigns,
483
+ };
484
+ return { report, measures, files };
485
+ };
486
+
487
+ // Why a report is not `ok`, in the order the text renderer explains it: a
488
+ // stale baseline first, since nothing else is trustworthy until the file
489
+ // describes something real.
490
+ type Shortfall = {
491
+ readonly family: CoverageFamily;
492
+ readonly actual: number;
493
+ readonly floor: number;
494
+ };
495
+
496
+ const shortfallsOf = (coverage: CoverageReport): ReadonlyArray<Shortfall> =>
497
+ COVERAGE_FAMILIES.flatMap((family) => {
498
+ const { covered, floor, total } = coverage[family];
499
+ const actual = total === 0 ? 1 : covered / total;
500
+ return floor === undefined || actual >= floor ? [] : [{ family, actual, floor }];
501
+ });
502
+
503
+ // A conformance measure over the ceiling the policy states for it.
504
+ type Excess = {
505
+ readonly measure: ConformanceMeasure;
506
+ readonly count: number;
507
+ readonly ceiling: number;
508
+ };
509
+
510
+ const excessesOf = (conformance: ConformanceReport): ReadonlyArray<Excess> =>
511
+ CONFORMANCE_MEASURES.flatMap((measure) => {
512
+ const { ceiling, count } = conformance[measure];
513
+ return ceiling === undefined || count <= ceiling ? [] : [{ measure, count, ceiling }];
514
+ });
515
+
516
+ const failureOf = (
517
+ report: CheckReport,
518
+ shortfalls: ReadonlyArray<Shortfall>,
519
+ ): CliFailure | null => {
520
+ if (report.stale.length > 0) return fail("stale baseline entries");
521
+ const [campaignFailure] = campaignFailuresOf(report.campaigns);
522
+ if (campaignFailure !== undefined) return fail(campaignFailure);
523
+ if (shortfalls.length > 0) return fail("coverage below floor");
524
+ if (excessesOf(report.conformance).length > 0) return fail("conformance above ceiling");
525
+ if (report.ok) return null;
526
+ return fail("architecture violations");
527
+ };
528
+
529
+ // What each measure counts, as the failure names it.
530
+ const MEASURE_NOUNS: Readonly<Record<ConformanceMeasure, readonly [string, string]>> = {
531
+ residue: ["file no family reaches", "files no family reaches"],
532
+ vacant: ["node no file is under", "nodes no file is under"],
533
+ slack: ["allowance nothing imports through", "allowances nothing imports through"],
534
+ concentration: [
535
+ "allowance used at fewer than half the nodes granted",
536
+ "allowances used at fewer than half the nodes granted",
537
+ ],
538
+ };
539
+
540
+ const describeExcess = (one: Excess): string => {
541
+ const [singular, plural] = MEASURE_NOUNS[one.measure];
542
+ return ` ${one.measure}: ${count(one.count, singular, plural)}, ceiling ${String(one.ceiling)}`;
543
+ };
544
+
545
+ const renderCampaigns = (report: CheckReport): ReadonlyArray<string> =>
546
+ renderCampaignReports(
547
+ report.campaigns,
548
+ report.violations.flatMap((one) =>
549
+ one.kind === "campaign" &&
550
+ one.objective !== undefined &&
551
+ one.sector !== undefined &&
552
+ one.entry !== undefined
553
+ ? [
554
+ {
555
+ violation: one,
556
+ objective: one.objective,
557
+ sector: one.sector,
558
+ entry: one.entry,
559
+ ledgered: one.ledgered,
560
+ },
561
+ ]
562
+ : [],
563
+ ),
564
+ );
565
+
566
+ const renderText = (report: CheckReport): ReadonlyArray<string> => {
567
+ const reportable = report.violations.filter((one) => one.kind !== "campaign" && !one.baselined);
568
+ const carried =
569
+ report.violations.filter((one) => one.kind !== "campaign").length - reportable.length;
570
+ const shortfalls = shortfallsOf(report.coverage);
571
+ const excesses = excessesOf(report.conformance);
572
+ return [
573
+ ...reportable.map(describe),
574
+ ...report.unresolved.map(
575
+ (one) => ` unresolved: ${one.file} → ${one.specifier} (${one.detail})`,
576
+ ),
577
+ "",
578
+ `${String(report.files)} files, ${String(reportable.length)} violations` +
579
+ (carried > 0 ? `, ${String(carried)} carried by the baseline` : ""),
580
+ // The ratchet: a fixed violation must leave the baseline, or the floor
581
+ // never rises and the file stops describing anything real.
582
+ ...(report.stale.length === 0
583
+ ? []
584
+ : [
585
+ "",
586
+ `${String(report.stale.length)} baseline entries no longer fire. The code was fixed; prune them:`,
587
+ ...report.stale.map((entry) => ` ${entry}`),
588
+ "",
589
+ " architecture baseline # rewrites the file from what still fires",
590
+ ]),
591
+ ...(shortfalls.length === 0
592
+ ? []
593
+ : [
594
+ "",
595
+ "coverage is below the floor the policy states for itself:",
596
+ ...shortfalls.map(
597
+ (one) => ` ${one.family}: ${percent(one.actual)} covered, floor ${percent(one.floor)}`,
598
+ ),
599
+ "",
600
+ " architecture coverage # which files no rule reaches",
601
+ ]),
602
+ ...(excesses.length === 0
603
+ ? []
604
+ : [
605
+ "",
606
+ "conformance is above the ceiling the policy states for itself:",
607
+ ...excesses.map(describeExcess),
608
+ "",
609
+ " architecture conformance # which files, nodes and allowances",
610
+ ]),
611
+ ...renderCampaigns(report),
612
+ ];
613
+ };
614
+
615
+ export type CheckOptions = {
616
+ readonly format: "text" | "json";
617
+ readonly manifestPath: string;
618
+ };
619
+
167
620
  export const check = (
168
621
  policy: LoadedPolicy,
169
622
  roots: ReadonlyArray<string>,
623
+ options: CheckOptions,
170
624
  ): Effect.Effect<void, CliFailure> =>
171
625
  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);
176
-
177
- yield* report(reportable.map(describe));
178
- yield* report(findings.unresolved.map((one) => ` unresolved: ${one}`));
626
+ const report_ = checkReport(policy, roots, options.manifestPath);
627
+ // JSON is one object on stdout and nothing else there; the failure, when
628
+ // there is one, is a sentence on stderr and the exit code, as in text.
629
+ yield* report(
630
+ options.format === "json" ? [JSON.stringify(report_, null, 2)] : renderText(report_),
631
+ );
632
+ const failure = failureOf(report_, shortfallsOf(report_.coverage));
633
+ if (failure !== null) return yield* Effect.fail(failure);
634
+ });
179
635
 
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
- ]);
636
+ const percent = (fraction: number): string => `${String(Math.floor(fraction * 100))}%`;
186
637
 
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
- }
638
+ const count = (n: number, noun: string, plural = `${noun}s`): string =>
639
+ `${String(n)} ${n === 1 ? noun : plural}`;
199
640
 
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([
212
- "",
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
- ),
217
- "",
218
- " architecture coverage # which files no rule reaches",
219
- ]);
220
- return yield* Effect.fail(fail("coverage below floor"));
221
- }
641
+ // The conformance snapshot: `check`'s report grown with what no family
642
+ // reaches, what the allowlists permit and nothing uses, the cycle count and
643
+ // the size of the debt — the whole distance between the tree and the
644
+ // manifest, as one document another run can be compared against. Its shape
645
+ // is the core's `Snapshot`, and the schema published beside the manifest's.
646
+ export const snapshotOf = (
647
+ policy: LoadedPolicy,
648
+ roots: ReadonlyArray<string>,
649
+ manifestPath: string,
650
+ ): Snapshot => {
651
+ const findings = collectFindings(policy, roots, { graph: true });
652
+ const { files, measures, report: report_ } = reportOf(policy, roots, manifestPath, findings);
653
+ const graph = findings.graph ?? { files, edges: new Map() };
654
+ const heights = heightOf(graph);
222
655
 
223
- if (reportable.length > 0 || findings.unresolved.length > 0) {
224
- return yield* Effect.fail(fail("architecture violations"));
225
- }
656
+ // Leaf edges first. A violation names a target when it is about an edge;
657
+ // the cost of fixing it is roughly how much of the graph stands beneath
658
+ // that target, so the ones nearest the ground come first and a reader
659
+ // starting at the top of the list is starting where a fix stays local.
660
+ // Ties keep the fingerprint order, so the list is the same on every run.
661
+ const heightOfViolation = (one: ReportedViolation): number =>
662
+ heights.get(one.subject ?? "") ?? heights.get(one.file) ?? 0;
663
+ const violations = [...report_.violations].sort((left, right) => {
664
+ const byHeight = heightOfViolation(left) - heightOfViolation(right);
665
+ return byHeight !== 0 ? byHeight : left.fingerprint.localeCompare(right.fingerprint);
226
666
  });
227
667
 
228
- const percent = (fraction: number): string => `${String(Math.floor(fraction * 100))}%`;
668
+ const campaigns = snapshotCampaignsOf(policy, findings.campaigns);
669
+
670
+ return {
671
+ version: SNAPSHOT_VERSION,
672
+ manifest: report_.manifest,
673
+ roots: report_.roots,
674
+ files: report_.files,
675
+ ok: report_.ok,
676
+ coverage: report_.coverage,
677
+ conformance: report_.conformance,
678
+ residue: measures.residue,
679
+ vacant: measures.vacant,
680
+ violations,
681
+ unresolved: report_.unresolved,
682
+ stale: report_.stale,
683
+ baseline: { size: readBaseline(policy).entries.length },
684
+ cycles: cyclesIn(graph).length,
685
+ slack: measures.slack,
686
+ concentration: measures.concentration,
687
+ adoption: report_.adoption,
688
+ campaigns,
689
+ };
690
+ };
691
+
692
+ const renderSnapshot = (snapshot: Snapshot): ReadonlyArray<string> => {
693
+ const reportable = snapshot.violations.filter((one) => !one.baselined && !one.ledgered);
694
+ const carried = snapshot.violations.length - reportable.length;
695
+ const row = (family: CoverageFamily): string => {
696
+ const { covered, floor, total } = snapshot.coverage[family];
697
+ const fraction = total === 0 ? 1 : covered / total;
698
+ const mark =
699
+ floor === undefined
700
+ ? ""
701
+ : fraction >= floor
702
+ ? ` ≥ ${percent(floor)} ✓`
703
+ : ` < ${percent(floor)} ✗`;
704
+ return ` ${family.padEnd(10)} ${String(covered).padStart(5)}/${String(total)} ${percent(fraction).padStart(4)}${mark}`;
705
+ };
706
+ const section = (title: string, lines: ReadonlyArray<string>): ReadonlyArray<string> => [
707
+ "",
708
+ title,
709
+ ...lines,
710
+ ];
711
+ const vacantWidth = Math.max(0, ...snapshot.vacant.map((one) => one.node.length));
712
+ // The document carries every partly-used fragment entry; the text shows
713
+ // the ones concentrated enough to read as a per-file rule written wide.
714
+ const concentrated = snapshot.concentration.filter(isConcentrated);
715
+ // The ceiling beside each measure that has one, and the ratchet's nudge
716
+ // when the count has fallen under it: a ceiling is lowered by hand.
717
+ const ceilingMark = (measure: ConformanceMeasure): string => {
718
+ const { ceiling, count: actual } = snapshot.conformance[measure];
719
+ if (ceiling === undefined) return "";
720
+ if (actual > ceiling) return ` > ${String(ceiling)} ✗`;
721
+ return ` ≤ ${String(ceiling)} ✓${actual < ceiling ? `, lower it to ${String(actual)}` : ""}`;
722
+ };
723
+
724
+ return [
725
+ `${String(snapshot.files)} files under ${snapshot.roots.join(", ")}, against ${snapshot.manifest.path}`,
726
+ ...section("coverage", COVERAGE_FAMILIES.map(row)),
727
+ ...section(
728
+ `residue: ${count(snapshot.residue.files.length, "file")} no family reaches` +
729
+ (snapshot.residue.folders.length === 0
730
+ ? ""
731
+ : `, ${count(snapshot.residue.folders.length, "folder")} wholly`) +
732
+ ceilingMark("residue"),
733
+ [
734
+ ...snapshot.residue.folders.map((folder) => ` ${folder}/`),
735
+ ...snapshot.residue.files
736
+ .filter(
737
+ (file) => !snapshot.residue.folders.some((folder) => file.startsWith(`${folder}/`)),
738
+ )
739
+ .map((file) => ` ${file}`),
740
+ ],
741
+ ),
742
+ ...section(
743
+ `vacant: ${count(snapshot.vacant.length, "node")} ${snapshot.vacant.length === 1 ? "selects" : "select"} no file` +
744
+ ceilingMark("vacant"),
745
+ snapshot.vacant.map(
746
+ (one) => ` ${one.node.padEnd(vacantWidth)} ${count(one.allowances, "allowance")}`,
747
+ ),
748
+ ),
749
+ ...section(
750
+ `violations: ${count(reportable.length, "reportable")}` +
751
+ (carried > 0 ? `, ${String(carried)} carried by the baseline or a ledger` : "") +
752
+ (snapshot.stale.length > 0
753
+ ? `, ${count(snapshot.stale.length, "stale entry", "stale entries")}`
754
+ : "") +
755
+ (reportable.length > 0 ? " — nearest the ground first" : ""),
756
+ reportable.map(describe),
757
+ ),
758
+ ...(snapshot.unresolved.length === 0
759
+ ? []
760
+ : section(
761
+ `unresolved: ${count(snapshot.unresolved.length, "import")} no rule can police`,
762
+ snapshot.unresolved.map((one) => ` ${one.file} → ${one.specifier} (${one.detail})`),
763
+ )),
764
+ ...section(
765
+ `slack: ${count(snapshot.slack.length, "allowance")} nothing imports through` +
766
+ ceilingMark("slack"),
767
+ snapshot.slack.map(
768
+ (one) =>
769
+ ` ${one.node}: ${one.kind} ${JSON.stringify(one.entry)}` +
770
+ (one.of === undefined ? "" : ` (via use, at ${count(one.of, "node")})`),
771
+ ),
772
+ ),
773
+ ...(concentrated.length === 0 && snapshot.conformance.concentration.ceiling === undefined
774
+ ? []
775
+ : section(
776
+ `concentrated: ${count(concentrated.length, "allowance")} used at fewer than half the nodes granted` +
777
+ ceilingMark("concentration"),
778
+ concentrated.map(
779
+ (one) =>
780
+ ` ${one.fragment}: ${one.kind} ${JSON.stringify(one.entry)} used at ${String(one.usedAt)} of ${count(one.of, "node")}`,
781
+ ),
782
+ )),
783
+ ...(snapshot.campaigns.length === 0
784
+ ? []
785
+ : section(
786
+ `campaigns: ${count(snapshot.campaigns.length, "campaign")}` +
787
+ (snapshot.campaigns.some((one) => one.stalled)
788
+ ? `, ${count(snapshot.campaigns.filter((one) => one.stalled).length, "stalled", "stalled")}`
789
+ : "") +
790
+ (snapshot.campaigns.some((one) => one.complete && one.ledgered)
791
+ ? `, ${count(snapshot.campaigns.filter((one) => one.complete && one.ledgered).length, "complete", "complete")}`
792
+ : ""),
793
+ renderCampaignRows(snapshot.campaigns),
794
+ )),
795
+ "",
796
+ `cycles: ${String(snapshot.cycles)}`,
797
+ `baseline: ${count(snapshot.baseline.size, "entry", "entries")}`,
798
+ `adoption: ${count(snapshot.adoption.unrestricted.length, "unrestricted tier")}, ${count(snapshot.adoption.partial.length, "partial tier")}`,
799
+ ];
800
+ };
801
+
802
+ export type ConformanceOptions = CheckOptions;
803
+
804
+ // The report of the tree against the manifest. Unlike `check`, it never
805
+ // fails: it is a measurement, and the manifest it measures against may be one
806
+ // the tree was never expected to satisfy yet — `--against` names a target.
807
+ // `ok` in the document says what `check` would have done.
808
+ export const conformance = (
809
+ policy: LoadedPolicy,
810
+ roots: ReadonlyArray<string>,
811
+ options: ConformanceOptions,
812
+ ): Effect.Effect<void, CliFailure> =>
813
+ Effect.gen(function* () {
814
+ const snapshot = snapshotOf(policy, roots, options.manifestPath);
815
+ yield* report(
816
+ options.format === "json" ? [JSON.stringify(snapshot, null, 2)] : renderSnapshot(snapshot),
817
+ );
818
+ });
229
819
 
230
820
  // How much of the tree the policy reaches. A probe proves a rule can fire;
231
821
  // this is whether the files are there to fire on. Reported per family, with the
@@ -293,7 +883,11 @@ export const writeBaseline = (
293
883
 
294
884
  // The question a tree config makes harder to answer than a flat one: given a
295
885
  // file, what governs it? A flat config you grep; a tree you have to walk.
296
- export const explain = (policy: LoadedPolicy, file: string): Effect.Effect<void, CliFailure> =>
886
+ export const explain = (
887
+ policy: LoadedPolicy,
888
+ file: string,
889
+ roots: ReadonlyArray<string> = ["packages"],
890
+ ): Effect.Effect<void, CliFailure> =>
297
891
  Effect.gen(function* () {
298
892
  const relative = path.relative(policy.repoRoot, path.resolve(policy.repoRoot, file));
299
893
  const selected = rulesSelecting(policy.importRules, relative);
@@ -321,7 +915,8 @@ export const explain = (policy: LoadedPolicy, file: string): Effect.Effect<void,
321
915
  !rule.fileNot.some((pattern) => pattern.test(relative)),
322
916
  );
323
917
 
324
- const firstSentence = (message: string) => `${message.split(". ")[0] ?? message}.`;
918
+ const firstSentence = (message: string) =>
919
+ `${(message.split(". ")[0] ?? message).replace(/\.$/, "")}.`;
325
920
  const named = (rule: { readonly name: string; readonly message: string }): string =>
326
921
  ` ${rule.name} — ${firstSentence(rule.message)}`;
327
922
 
@@ -347,6 +942,47 @@ export const explain = (policy: LoadedPolicy, file: string): Effect.Effect<void,
347
942
  const section = (title: string, lines: ReadonlyArray<string>): ReadonlyArray<string> =>
348
943
  lines.length === 0 ? [] : ["", title, ...lines];
349
944
 
945
+ // Each campaign selecting the file: the sector the file is in, its
946
+ // phase and definedness, the objectives in window that fire on it and
947
+ // the nearest remaining holdouts — which needs the campaign evaluated
948
+ // over its files, since a sector's phase is derived from all of them —
949
+ // then each objective's truth table: one line per leaf term and what
950
+ // it answered here, so a detector that "should fire" and does not shows
951
+ // which term is not saying what its author thinks.
952
+ const selectedCampaigns = campaignsSelecting(campaignsOf(policy).campaignRules, relative);
953
+ const evaluations =
954
+ selectedCampaigns.length === 0 ? [] : collectFindings(policy, roots).campaigns;
955
+ const campaignLines = selectedCampaigns.flatMap((rule) => {
956
+ const at = path.join(policy.repoRoot, relative);
957
+ const text = existsSync(at) ? readFileSync(at, "utf8") : "";
958
+ const input = {
959
+ file: relative,
960
+ text,
961
+ facts: policy.extractor.factsOf(relative, text),
962
+ resolver: policy.resolver,
963
+ fileSystem: policy.fileSystem,
964
+ syntax: policy.syntax.parse(relative, text),
965
+ functions: campaignsOf(policy).functions,
966
+ reports: campaignsOf(policy).reports,
967
+ };
968
+ const evaluation = evaluations.find((one) => one.rule.id === rule.id);
969
+ return [
970
+ ...(evaluation === undefined ? [] : explainCampaignLines(policy, evaluation, relative)),
971
+ ...rule.objectives.flatMap((objective) => {
972
+ const table = explainObjective(objective, input);
973
+ if (table.length === 0) return [];
974
+ const fired = table.length > 0 && table.every((line) => line.answer);
975
+ return [
976
+ ` ${objective.name} — ${firstSentence(objective.why ?? objective.message)} (${objective.holdout}; ${fired ? "fires here" : "no hit"})`,
977
+ ...table.map(
978
+ (line) =>
979
+ ` ${line.answer ? "✓" : "✗"} ${line.term}${line.count === undefined ? "" : ` (${String(line.count)})`}`,
980
+ ),
981
+ ];
982
+ }),
983
+ ];
984
+ });
985
+
350
986
  yield* report([
351
987
  relative,
352
988
  "",
@@ -379,6 +1015,7 @@ export const explain = (policy: LoadedPolicy, file: string): Effect.Effect<void,
379
1015
  ...section(" vocabulary (members):", vocabulary.map(named)),
380
1016
  ...section(" may export (surface):", surface.map(named)),
381
1017
  ...section(" graph:", graph),
1018
+ ...section(" campaigns:", campaignLines),
382
1019
  ]);
383
1020
  });
384
1021
 
@@ -445,43 +1082,715 @@ export const facts = (
445
1082
  ]);
446
1083
  });
447
1084
 
1085
+ const SCHEMA_HEADER = `# yaml-language-server: $schema=${MANIFEST_SCHEMA_ID}\n`;
1086
+
1087
+ // A first manifest: one open root that reaches itself, the ceilings at zero,
1088
+ // and a comment per section naming the page that explains it. Tight enough to
1089
+ // fire on the first external import — which is the moment the author learns
1090
+ // where the allowlist is — and small enough to read in one sitting.
1091
+ const STARTER_MANIFEST = `${SCHEMA_HEADER}#
1092
+ # The architecture policy: one manifest of this repository.
1093
+ # https://dataquail.github.io/goodbones/architecture-rules/manifest/
1094
+ #
1095
+ # A key ending in \`/\` is a folder; anything else is a file. The default is
1096
+ # tight: a folder admits only the children it lists, and a file may import only
1097
+ # what it or an ancestor allows. Laxity is opted into, by name, at the node that
1098
+ # wants it. Quote every glob — \`*\` and \`@\` mean something else to YAML bare.
1099
+
1100
+ # How an import specifier becomes a file. Every pattern below is matched
1101
+ # against a resolved path, so this is what makes the rest mean anything.
1102
+ # https://dataquail.github.io/goodbones/architecture-rules/enforcement/resolution/
1103
+ resolve:
1104
+ scopes:
1105
+ - files: ""
1106
+ language: typescript
1107
+ options: { tsconfig: tsconfig.json }
1108
+ unresolved: error
1109
+
1110
+ # Violations this repository is carrying while it adopts the policy, keyed by
1111
+ # fingerprint. Written by \`architecture baseline\`; the floor only rises.
1112
+ # https://dataquail.github.io/goodbones/architecture-rules/enforcement/baseline/
1113
+ baseline: .architecture-baseline.json
1114
+
1115
+ # Ceilings on how many tiers may say "not tightened yet". At zero, raising one
1116
+ # is a line in this file a reviewer sees. The same block takes coverage floors
1117
+ # and ceilings on what \`architecture conformance\` measures, once there are
1118
+ # numbers to write.
1119
+ # https://dataquail.github.io/goodbones/architecture-rules/enforcement/adoption/
1120
+ limits:
1121
+ unrestricted: 0
1122
+ partial: 0
1123
+
1124
+ # Refactors the repository is running, each an object: objectives (a
1125
+ # detector with a ledger under \`.architecture-campaigns/\` of every place the
1126
+ # pattern still occurs), over sectors the code births through a perimeter,
1127
+ # through phases toward an end. Fill one in, then
1128
+ # \`architecture objectives clear <id>\` to write its ledgers.
1129
+ # https://dataquail.github.io/goodbones/architecture-rules/manifest/campaigns/
1130
+ # campaigns:
1131
+ # js-to-ts:
1132
+ # why: The strict tsconfig cannot land while any src file is JavaScript.
1133
+ # how: Rename to .ts, add types at the module boundary, leave the body alone.
1134
+ # scope: { path: "src/**", extensions: [.js, .jsx] }
1135
+ # perimeter: file
1136
+ # objectives:
1137
+ # is-ts:
1138
+ # holdout: file
1139
+ # match: { path: { file: "\\\\.(js|jsx)$" } }
1140
+ # probes: { fires: [{ path: src/legacy/util.js }], ignores: [{ path: src/util.ts }] }
1141
+ # staleAfter: 14d
1142
+ # onComplete: remove
1143
+
1144
+ # The repository. One open root, reaching itself and the runtime; run
1145
+ # \`architecture check\` to see what else it reaches, and write that down here.
1146
+ # https://dataquail.github.io/goodbones/architecture-rules/manifest/imports/
1147
+ tree:
1148
+ "src/":
1149
+ message: "src/ is the whole program. Nothing in it is layered yet."
1150
+ layout: open
1151
+ imports:
1152
+ message: "This import is not on the allowlist."
1153
+ allow: ["src/**", "node:**"]
1154
+ # npm packages this tier may reach, by name.
1155
+ external: []
1156
+ children: {}
1157
+ `;
1158
+
1159
+ // A starter manifest, for a repository that has none.
1160
+ export const init = (repoRoot: string): Effect.Effect<void, CliFailure> =>
1161
+ Effect.gen(function* () {
1162
+ const present = MANIFEST_FILENAMES.filter((name) => existsSync(path.resolve(repoRoot, name)));
1163
+ if (present.length > 0) {
1164
+ return yield* Effect.fail(
1165
+ fail(
1166
+ `${present.join(", ")} already exists. \`init\` writes a starter manifest for a ` +
1167
+ `repository that has none, and does not overwrite one.`,
1168
+ ),
1169
+ );
1170
+ }
1171
+ yield* Effect.sync(() => {
1172
+ writeFileSync(path.resolve(repoRoot, "architecture.yaml"), STARTER_MANIFEST);
1173
+ });
1174
+ yield* report([
1175
+ "wrote architecture.yaml.",
1176
+ "",
1177
+ " architecture check # what src/ reaches today; add it to the allowlist by name",
1178
+ " architecture coverage # how much of the tree the policy reaches",
1179
+ ]);
1180
+ });
1181
+
1182
+ // The same manifest as a data file. Nothing is hoisted into `defs` — which
1183
+ // subtrees are worth naming is the author's call — and comments do not
1184
+ // survive, since no tool carries them across; the report says so.
1185
+ export const migrate = (
1186
+ repoRoot: string,
1187
+ configFilename?: string,
1188
+ ): Effect.Effect<void, CliFailure> =>
1189
+ Effect.gen(function* () {
1190
+ const from = yield* Effect.try({
1191
+ try: () =>
1192
+ configFilename === undefined
1193
+ ? findManifestFile(repoRoot)
1194
+ : path.resolve(repoRoot, configFilename),
1195
+ catch: (cause) => fail(String(cause)),
1196
+ });
1197
+ if (![".mjs", ".js", ".cjs"].includes(path.extname(from))) {
1198
+ return yield* Effect.fail(
1199
+ fail(
1200
+ `${path.basename(from)} is already a data file. \`migrate\` reads a JavaScript ` +
1201
+ `manifest and writes the same policy as architecture.yaml.`,
1202
+ ),
1203
+ );
1204
+ }
1205
+ const to = path.resolve(repoRoot, "architecture.yaml");
1206
+ if (existsSync(to)) {
1207
+ return yield* Effect.fail(
1208
+ fail("architecture.yaml already exists; `migrate` does not overwrite it."),
1209
+ );
1210
+ }
1211
+
1212
+ const read = yield* Effect.tryPromise({
1213
+ try: () => readManifestFile(from),
1214
+ catch: (cause) => fail(String(cause)),
1215
+ });
1216
+ // Written only if it decodes: a manifest that does not load as a module
1217
+ // is not going to load as YAML either, and the error names why.
1218
+ const decoded = decodeManifest(from, read.manifest);
1219
+ if (Result.isFailure(decoded)) return yield* Effect.fail(fail(decoded.failure.message));
1220
+
1221
+ yield* Effect.sync(() => {
1222
+ writeFileSync(to, `${SCHEMA_HEADER}\n${formatManifestYaml(read.manifest)}`);
1223
+ });
1224
+ yield* report([
1225
+ `wrote architecture.yaml from ${path.basename(from)}.`,
1226
+ "",
1227
+ "Comments were not carried over; port the ones worth keeping by hand.",
1228
+ `Then delete ${path.basename(from)}: a repository with two manifests is refused.`,
1229
+ ]);
1230
+ });
1231
+
1232
+ // The campaign commands. `campaigns` alone is the status table; `status
1233
+ // --changed` is the nudge; `attest` and `note` write a sector's record;
1234
+ // `history` replays the ledgers' git history. The ledgers themselves are
1235
+ // written by `objectives clear` (the ledger reconciled with the code
1236
+ // wherever that is not a regression) and `objectives concede` (the one way
1237
+ // a holdout is added by hand, with a reason).
1238
+
1239
+ const flagOf = (argv: ReadonlyArray<string>, flag: string): string | undefined => {
1240
+ const at = argv.indexOf(flag);
1241
+ const value = at === -1 ? undefined : argv[at + 1];
1242
+ return value === undefined || value.startsWith("--") ? undefined : value;
1243
+ };
1244
+
1245
+ const CAMPAIGN_SUBCOMMANDS = ["status", "attest", "note", "history", "clear", "concede"] as const;
1246
+ const OBJECTIVE_SUBCOMMANDS = ["clear", "concede"] as const;
1247
+ const VALUE_FLAGS = [
1248
+ "--reason",
1249
+ "--by",
1250
+ "--holdouts",
1251
+ "--entries",
1252
+ "--sector",
1253
+ "--campaign",
1254
+ "--evidence",
1255
+ "--base",
1256
+ "--since",
1257
+ "--hotfix",
1258
+ ] as const;
1259
+
1260
+ // The verbs the family shipped with, refused by name: each has a new name
1261
+ // or no place.
1262
+ const RETIRED: Readonly<Record<string, string>> = {
1263
+ init: "`campaigns init` is gone: `objectives clear <campaign>` writes a first ledger, recording each sector's initial.",
1264
+ prune: "`campaigns prune` is now `objectives clear`.",
1265
+ allow: "`campaigns allow` is now `objectives concede`.",
1266
+ };
1267
+
1268
+ // The positionals after the subcommand, with the value flags and their
1269
+ // values stepped over: whatever is left names the roots to walk, as it
1270
+ // does for every other command.
1271
+ const positionalsOf = (argv: ReadonlyArray<string>): ReadonlyArray<string> => {
1272
+ const positional: Array<string> = [];
1273
+ for (let at = 0; at < argv.length; at += 1) {
1274
+ const one = argv[at] ?? "";
1275
+ if ((VALUE_FLAGS as ReadonlyArray<string>).includes(one)) {
1276
+ at += 1;
1277
+ continue;
1278
+ }
1279
+ if (!one.startsWith("--")) positional.push(one);
1280
+ }
1281
+ return positional;
1282
+ };
1283
+
1284
+ // `campaigns [status | attest <sector> <phase> | note <sector> "<text>" |
1285
+ // history [<campaign>]] [roots…]`: the subcommand and its arguments come
1286
+ // first.
1287
+ export const campaignArgsOf = (
1288
+ argv: ReadonlyArray<string>,
1289
+ ids: ReadonlyArray<string>,
1290
+ ): {
1291
+ readonly subcommand: string | undefined;
1292
+ readonly args: ReadonlyArray<string>;
1293
+ readonly roots: ReadonlyArray<string>;
1294
+ } => {
1295
+ const positional = positionalsOf(argv);
1296
+ const [first, ...rest] = positional;
1297
+ if (first === undefined) return { subcommand: undefined, args: [], roots: [] };
1298
+ if (first in RETIRED) return { subcommand: first, args: [], roots: rest };
1299
+ if (!(CAMPAIGN_SUBCOMMANDS as ReadonlyArray<string>).includes(first)) {
1300
+ return { subcommand: undefined, args: [], roots: positional };
1301
+ }
1302
+ switch (first) {
1303
+ case "attest":
1304
+ return { subcommand: first, args: rest.slice(0, 2), roots: rest.slice(2) };
1305
+ case "note":
1306
+ return { subcommand: first, args: rest.slice(0, 2), roots: rest.slice(2) };
1307
+ case "history": {
1308
+ const [second] = rest;
1309
+ return second !== undefined && ids.includes(second)
1310
+ ? { subcommand: first, args: [second], roots: rest.slice(1) }
1311
+ : { subcommand: first, args: [], roots: rest };
1312
+ }
1313
+ case "clear":
1314
+ case "concede": {
1315
+ const [second] = rest;
1316
+ return second !== undefined && ids.includes(second.split("/")[0] ?? "")
1317
+ ? { subcommand: first, args: [second], roots: rest.slice(1) }
1318
+ : { subcommand: first, args: [], roots: rest };
1319
+ }
1320
+ default:
1321
+ return { subcommand: first, args: [], roots: rest };
1322
+ }
1323
+ };
1324
+
1325
+ // `objectives [clear [<campaign>[/<objective>]] | concede <campaign>[/<objective>]
1326
+ // --reason <text>] [roots…]`.
1327
+ export const objectiveArgsOf = (
1328
+ argv: ReadonlyArray<string>,
1329
+ ids: ReadonlyArray<string>,
1330
+ ): {
1331
+ readonly subcommand: string | undefined;
1332
+ readonly target: { campaign: string; objective: string | null } | null;
1333
+ readonly roots: ReadonlyArray<string>;
1334
+ } => {
1335
+ const positional = positionalsOf(argv);
1336
+ const [first, second, ...rest] = positional;
1337
+ if (first === undefined || !(OBJECTIVE_SUBCOMMANDS as ReadonlyArray<string>).includes(first)) {
1338
+ return { subcommand: first, target: null, roots: positional.slice(1) };
1339
+ }
1340
+ const [campaign = "", objective] = (second ?? "").split("/");
1341
+ const named = second !== undefined && ids.includes(campaign);
1342
+ return {
1343
+ subcommand: first,
1344
+ target: named ? { campaign, objective: objective ?? null } : null,
1345
+ roots: named ? rest : second === undefined ? [] : [second, ...rest],
1346
+ };
1347
+ };
1348
+
1349
+ // The one campaign, when there is one, else the one `--campaign` names.
1350
+ const campaignFor = (
1351
+ policy: LoadedPolicy,
1352
+ argv: ReadonlyArray<string>,
1353
+ ): Result.Result<CampaignEvaluation["rule"], string> => {
1354
+ const named = flagOf(argv, "--campaign");
1355
+ if (named !== undefined) {
1356
+ const found = campaignsOf(policy).campaignRules.find((rule) => rule.id === named);
1357
+ return found === undefined
1358
+ ? Result.fail(`no campaign is named "${named}"`)
1359
+ : Result.succeed(found);
1360
+ }
1361
+ const [only] = campaignsOf(policy).campaignRules;
1362
+ if (campaignsOf(policy).campaignRules.length === 1 && only !== undefined) return Result.succeed(only);
1363
+ return Result.fail(
1364
+ `this policy declares ${String(campaignsOf(policy).campaignRules.length)} campaigns; say which with --campaign <id>.`,
1365
+ );
1366
+ };
1367
+
1368
+ const objectiveFor = (
1369
+ rule: CampaignEvaluation["rule"],
1370
+ objective: string | null,
1371
+ ): Result.Result<string, string> => {
1372
+ if (objective !== null) {
1373
+ return rule.objectives.some((one) => one.id === objective)
1374
+ ? Result.succeed(objective)
1375
+ : Result.fail(`no objective of ${rule.id} is named "${objective}"`);
1376
+ }
1377
+ const [only] = rule.objectives;
1378
+ if (rule.objectives.length === 1 && only !== undefined) return Result.succeed(only.id);
1379
+ return Result.fail(
1380
+ `campaign ${rule.id} declares ${String(rule.objectives.length)} objectives; say which as ${rule.id}/<objective>.`,
1381
+ );
1382
+ };
1383
+
1384
+ export const objectives = (
1385
+ policy: LoadedPolicy,
1386
+ defaultRoots: ReadonlyArray<string>,
1387
+ argv: ReadonlyArray<string>,
1388
+ ): Effect.Effect<void, CliFailure> =>
1389
+ Effect.gen(function* () {
1390
+ const parsed = objectiveArgsOf(
1391
+ argv,
1392
+ campaignsOf(policy).campaignRules.map((rule) => rule.id),
1393
+ );
1394
+ const roots = parsed.roots.length > 0 ? parsed.roots : defaultRoots;
1395
+ if (campaignsOf(policy).campaignRules.length === 0) {
1396
+ return yield* report(["this policy declares no campaigns."]);
1397
+ }
1398
+ const evaluations = (): ReadonlyArray<CampaignEvaluation> =>
1399
+ collectFindings(policy, roots).campaigns;
1400
+
1401
+ switch (parsed.subcommand) {
1402
+ case "clear": {
1403
+ const targets =
1404
+ parsed.target === null
1405
+ ? campaignsOf(policy).campaignRules
1406
+ : campaignsOf(policy).campaignRules.filter((rule) => rule.id === parsed.target?.campaign);
1407
+ const by = authorOf(flagOf(argv, "--by")) ?? "unknown";
1408
+ const all = evaluations();
1409
+ const lines: Array<string> = [];
1410
+ for (const rule of targets) {
1411
+ const evaluation = all.find((one) => one.rule.id === rule.id);
1412
+ if (evaluation === undefined) continue;
1413
+ const only = parsed.target?.objective ?? null;
1414
+ if (only !== null && !rule.objectives.some((one) => one.id === only)) {
1415
+ return yield* Effect.fail(fail(`no objective of ${rule.id} is named "${only}"`));
1416
+ }
1417
+ const outcomes = yield* Effect.try({
1418
+ try: () => clear(policy, evaluation, only, by),
1419
+ catch: (cause) => fail(String(cause)),
1420
+ });
1421
+ for (const outcome of outcomes) {
1422
+ const parts = [
1423
+ ...(outcome.entered.length > 0
1424
+ ? [
1425
+ `${count(outcome.entered.length, "sector")} entered (${outcome.entered.join(", ")})`,
1426
+ ]
1427
+ : []),
1428
+ ...(outcome.cleared > 0 ? [`${count(outcome.cleared, "holdout")} cleared`] : []),
1429
+ ...(outcome.rewritten > 0
1430
+ ? [`${count(outcome.rewritten, "holdout")} rewritten`]
1431
+ : []),
1432
+ ...(outcome.closed > 0 ? [`${count(outcome.closed, "holdout")} closed`] : []),
1433
+ ...(outcome.rebaselined.length > 0
1434
+ ? [
1435
+ `${count(outcome.rebaselined.length, "sector")} re-baselined (${outcome.rebaselined.join(", ")})`,
1436
+ ]
1437
+ : []),
1438
+ ];
1439
+ lines.push(
1440
+ `${outcome.campaign}/${outcome.objective}: ${parts.length === 0 ? "nothing to clear" : parts.join(", ")}; ${count(outcome.left, "holdout")} left.`,
1441
+ );
1442
+ }
1443
+ }
1444
+ return yield* report(lines);
1445
+ }
1446
+ case "concede": {
1447
+ if (parsed.target === null) {
1448
+ return yield* Effect.fail(
1449
+ fail(
1450
+ "objectives concede needs a campaign: `objectives concede <campaign>[/<objective>] --reason <text>`",
1451
+ ),
1452
+ );
1453
+ }
1454
+ const rule = campaignsOf(policy).campaignRules.find((one) => one.id === parsed.target?.campaign);
1455
+ if (rule === undefined)
1456
+ return yield* Effect.fail(fail(`no campaign is named "${parsed.target.campaign}"`));
1457
+ const objective = objectiveFor(rule, parsed.target.objective);
1458
+ if (Result.isFailure(objective)) return yield* Effect.fail(fail(objective.failure));
1459
+ const reason = flagOf(argv, "--reason");
1460
+ if (reason === undefined) {
1461
+ return yield* Effect.fail(
1462
+ fail(
1463
+ "objectives concede needs --reason <text>: growth is recorded with why, or not at all.",
1464
+ ),
1465
+ );
1466
+ }
1467
+ const by = authorOf(flagOf(argv, "--by"));
1468
+ if (by === null) {
1469
+ return yield* Effect.fail(
1470
+ fail("objectives concede needs an author: pass --by <email>, or set git's user.email."),
1471
+ );
1472
+ }
1473
+ const evaluation = evaluations().find((one) => one.rule.id === rule.id);
1474
+ if (evaluation === undefined)
1475
+ return yield* Effect.fail(fail(`campaign ${rule.id} was not evaluated`));
1476
+ // `--holdouts` concedes a subset and refuses the rest: a pull
1477
+ // request that legitimately adds one hit while another is an
1478
+ // accident.
1479
+ const chosen =
1480
+ (flagOf(argv, "--holdouts") ?? flagOf(argv, "--entries"))
1481
+ ?.split(",")
1482
+ .map((one) => one.trim()) ?? null;
1483
+ const outcome = concede(
1484
+ policy,
1485
+ evaluation,
1486
+ objective.success,
1487
+ chosen,
1488
+ flagOf(argv, "--sector") ?? null,
1489
+ {
1490
+ at: policy.now,
1491
+ by,
1492
+ reason,
1493
+ },
1494
+ );
1495
+ if (Result.isFailure(outcome)) return yield* Effect.fail(fail(outcome.failure));
1496
+ if (outcome.success.conceded.length === 0) {
1497
+ return yield* report([
1498
+ `${rule.id}/${objective.success}: nothing to concede; every hit is in the ledger.`,
1499
+ ]);
1500
+ }
1501
+ return yield* report([
1502
+ `${rule.id}/${objective.success}: ${count(outcome.success.conceded.length, "holdout")} conceded, recorded by ${by}.`,
1503
+ ...outcome.success.conceded.map((one) => ` ${one.sector} · ${one.entry}`),
1504
+ ...(outcome.success.left.length === 0
1505
+ ? []
1506
+ : [
1507
+ "",
1508
+ `${count(outcome.success.left.length, "hit")} left unrecorded; check still fails on them.`,
1509
+ ]),
1510
+ ]);
1511
+ }
1512
+ default:
1513
+ return yield* Effect.fail(
1514
+ fail(
1515
+ `unknown objectives subcommand "${parsed.subcommand ?? ""}". Try: objectives clear [<campaign>[/<objective>]] | objectives concede <campaign>[/<objective>] --reason <text> [--by <email>] [--sector <name>] [--holdouts a,b]`,
1516
+ ),
1517
+ );
1518
+ }
1519
+ });
1520
+
1521
+ export const campaigns = (
1522
+ policy: LoadedPolicy,
1523
+ defaultRoots: ReadonlyArray<string>,
1524
+ argv: ReadonlyArray<string>,
1525
+ configFilename?: string,
1526
+ ): Effect.Effect<void, CliFailure> =>
1527
+ Effect.gen(function* () {
1528
+ const parsed = campaignArgsOf(
1529
+ argv,
1530
+ campaignsOf(policy).campaignRules.map((rule) => rule.id),
1531
+ );
1532
+ const roots = parsed.roots.length > 0 ? parsed.roots : defaultRoots;
1533
+ if (campaignsOf(policy).campaignRules.length === 0) {
1534
+ return yield* report(["this policy declares no campaigns."]);
1535
+ }
1536
+ const json = argv.includes("--json");
1537
+ const retired = parsed.subcommand === undefined ? undefined : RETIRED[parsed.subcommand];
1538
+ if (retired !== undefined) return yield* Effect.fail(fail(retired));
1539
+
1540
+ switch (parsed.subcommand) {
1541
+ case undefined: {
1542
+ const snapshot = snapshotOf(policy, roots, manifestPathOf(policy.repoRoot, configFilename));
1543
+ return yield* report([
1544
+ `${count(snapshot.campaigns.length, "campaign")} under ${roots.join(", ")}`,
1545
+ "",
1546
+ ...renderCampaignRows(snapshot.campaigns),
1547
+ "",
1548
+ " architecture campaigns status --changed [--base <ref>] [--json] # what a diff touches, and what to do",
1549
+ " architecture objectives clear [<campaign>[/<objective>]] # reconcile the ledgers with the code",
1550
+ ' architecture objectives concede <campaign>[/<objective>] --reason "<why>" # record why a count may rise',
1551
+ ' architecture campaigns attest <sector> <phase> --reason "<why>" [--evidence <url>]',
1552
+ ' architecture campaigns note <sector> "<text>"',
1553
+ " architecture campaigns history [<campaign>] [--since <ref>]",
1554
+ ]);
1555
+ }
1556
+ case "status": {
1557
+ if (!argv.includes("--changed")) {
1558
+ return yield* Effect.fail(
1559
+ fail("campaigns status takes --changed: the nudge is scoped to a diff."),
1560
+ );
1561
+ }
1562
+ const base = flagOf(argv, "--base") ?? null;
1563
+ const diff = yield* Effect.try({
1564
+ try: () => readDiff(policy.repoRoot, base),
1565
+ catch: (cause) => fail(`could not read the diff: ${String(cause)}`),
1566
+ });
1567
+ const current = collectFindings(policy, roots).campaigns;
1568
+ const baseSide =
1569
+ base === null
1570
+ ? null
1571
+ : yield* Effect.tryPromise({
1572
+ try: () =>
1573
+ baseSideAt(policy, base, roots, (repoRoot) =>
1574
+ loadPolicyFromFile(repoRoot, configFilename),
1575
+ ),
1576
+ catch: (cause) =>
1577
+ fail(`could not evaluate the base tree at ${base}: ${String(cause)}`),
1578
+ });
1579
+ const hotfix = flagOf(argv, "--hotfix") ?? null;
1580
+ const nudge = nudgeOf(
1581
+ policy,
1582
+ current,
1583
+ diff,
1584
+ baseSide,
1585
+ hotfix,
1586
+ hotfix === null ? null : authorOf(flagOf(argv, "--by")),
1587
+ );
1588
+ yield* report(json ? [JSON.stringify(nudge, null, 2)] : renderNudge(nudge, policy.now));
1589
+ if (!nudge.ok)
1590
+ return yield* Effect.fail(fail("the diff sends a sector back under its onTouch"));
1591
+ return;
1592
+ }
1593
+ case "attest": {
1594
+ const [sector, phase] = parsed.args;
1595
+ if (sector === undefined || phase === undefined) {
1596
+ return yield* Effect.fail(
1597
+ fail(
1598
+ 'campaigns attest needs a sector and a phase: `campaigns attest <sector> <phase> --reason "<why>"`',
1599
+ ),
1600
+ );
1601
+ }
1602
+ const reason = flagOf(argv, "--reason");
1603
+ if (reason === undefined)
1604
+ return yield* Effect.fail(fail("campaigns attest needs --reason <text>."));
1605
+ const by = authorOf(flagOf(argv, "--by"));
1606
+ if (by === null)
1607
+ return yield* Effect.fail(
1608
+ fail("campaigns attest needs an author: pass --by <email>, or set git's user.email."),
1609
+ );
1610
+ const rule = campaignFor(policy, argv);
1611
+ if (Result.isFailure(rule)) return yield* Effect.fail(fail(rule.failure));
1612
+ const evaluation = collectFindings(policy, roots).campaigns.find(
1613
+ (one) => one.rule.id === rule.success.id,
1614
+ );
1615
+ if (evaluation === undefined)
1616
+ return yield* Effect.fail(fail(`campaign ${rule.success.id} was not evaluated`));
1617
+ const written = attest(policy, evaluation, sector, phase, {
1618
+ reason,
1619
+ evidence: flagOf(argv, "--evidence"),
1620
+ by,
1621
+ });
1622
+ if (Result.isFailure(written)) return yield* Effect.fail(fail(written.failure));
1623
+ return yield* report([
1624
+ `${rule.success.id}: sector ${sector} attested at ${phase} by ${by}, in ${written.success}.`,
1625
+ "Run `objectives clear` to move it on.",
1626
+ ]);
1627
+ }
1628
+ case "note": {
1629
+ const [sector, text] = parsed.args;
1630
+ if (sector === undefined || text === undefined) {
1631
+ return yield* Effect.fail(
1632
+ fail('campaigns note needs a sector and a text: `campaigns note <sector> "<text>"`'),
1633
+ );
1634
+ }
1635
+ const by = authorOf(flagOf(argv, "--by"));
1636
+ if (by === null)
1637
+ return yield* Effect.fail(
1638
+ fail("campaigns note needs an author: pass --by <email>, or set git's user.email."),
1639
+ );
1640
+ const rule = campaignFor(policy, argv);
1641
+ if (Result.isFailure(rule)) return yield* Effect.fail(fail(rule.failure));
1642
+ const evaluation = collectFindings(policy, roots).campaigns.find(
1643
+ (one) => one.rule.id === rule.success.id,
1644
+ );
1645
+ if (evaluation === undefined)
1646
+ return yield* Effect.fail(fail(`campaign ${rule.success.id} was not evaluated`));
1647
+ const written = note(policy, evaluation, sector, text, by);
1648
+ if (Result.isFailure(written)) return yield* Effect.fail(fail(written.failure));
1649
+ return yield* report([
1650
+ `${rule.success.id}: note left on ${sector}, in ${written.success}.`,
1651
+ ]);
1652
+ }
1653
+ case "history": {
1654
+ const [named] = parsed.args;
1655
+ const targets =
1656
+ named === undefined
1657
+ ? campaignsOf(policy).campaignRules
1658
+ : campaignsOf(policy).campaignRules.filter((rule) => rule.id === named);
1659
+ const manifestPath = path
1660
+ .relative(policy.repoRoot, manifestPathOf(policy.repoRoot, configFilename))
1661
+ .replaceAll(path.sep, "/");
1662
+ const lines: Array<string> = [];
1663
+ for (const rule of targets) {
1664
+ const rows = historyOf(policy, rule, flagOf(argv, "--since") ?? null, [manifestPath]);
1665
+ if (json) {
1666
+ lines.push(JSON.stringify({ campaign: rule.id, rows }, null, 2));
1667
+ continue;
1668
+ }
1669
+ if (lines.length > 0) lines.push("");
1670
+ for (const line of renderHistory(rule, rows)) lines.push(line);
1671
+ }
1672
+ return yield* report(lines);
1673
+ }
1674
+ case "clear":
1675
+ case "concede":
1676
+ // The ledger verbs answer under `objectives`; accepted here too.
1677
+ return yield* objectives(policy, defaultRoots, argv);
1678
+ default:
1679
+ return yield* Effect.fail(
1680
+ fail(
1681
+ `unknown campaigns subcommand "${parsed.subcommand}". Try: campaigns | campaigns status --changed | campaigns attest <sector> <phase> --reason <text> | campaigns note <sector> "<text>" | campaigns history [<campaign>]`,
1682
+ ),
1683
+ );
1684
+ }
1685
+ });
1686
+
1687
+ // The commands that judge a campaign, and so ask its report source.
1688
+ const READS_REPORTS: ReadonlySet<string> = new Set([
1689
+ "check",
1690
+ "conformance",
1691
+ "baseline",
1692
+ "campaigns",
1693
+ "objectives",
1694
+ "explain",
1695
+ ]);
1696
+
448
1697
  export const run = (
449
1698
  repoRoot: string,
450
1699
  argv: ReadonlyArray<string>,
1700
+ // From ARCHITECTURE_CONFIG. Absent, the manifest is discovered by name.
1701
+ configFilename?: string,
451
1702
  ): Effect.Effect<void, CliFailure> =>
452
1703
  Effect.gen(function* () {
453
1704
  const [command = "check", ...rest] = argv;
1705
+
1706
+ // The three commands that write a manifest rather than read one.
1707
+ if (command === "init") return yield* init(repoRoot);
1708
+ if (command === "migrate") return yield* migrate(repoRoot, configFilename);
1709
+ if (command === "infer") {
1710
+ yield* infer(repoRoot, rest, configFilename);
1711
+ return;
1712
+ }
1713
+
1714
+ // `--against <file>` measures the tree against a manifest other than the
1715
+ // repository's own — the target the team is moving toward. Only
1716
+ // `conformance` takes it: a `check` against a manifest nobody is held to
1717
+ // yet would fail for no one's benefit.
1718
+ const againstAt = rest.indexOf("--against");
1719
+ const against = againstAt === -1 ? undefined : rest[againstAt + 1];
1720
+ if (againstAt !== -1 && (against === undefined || against.startsWith("--"))) {
1721
+ return yield* Effect.fail(fail("--against needs a manifest path"));
1722
+ }
1723
+ if (against !== undefined && command !== "conformance") {
1724
+ return yield* Effect.fail(
1725
+ fail(`--against is a \`conformance\` flag; ${command} does not take it`),
1726
+ );
1727
+ }
1728
+ const manifestFilename = against ?? configFilename;
1729
+
454
1730
  const policy = yield* Effect.tryPromise({
455
- try: () => loadPolicyFromFile(repoRoot),
1731
+ try: () => loadPolicyFromFile(repoRoot, manifestFilename),
456
1732
  catch: (cause) => fail(String(cause)),
457
1733
  });
458
1734
  yield* Effect.sync(() => {
459
1735
  for (const notice of policy.notices) process.stderr.write(`deprecated: ${notice}\n`);
460
1736
  });
461
1737
 
462
- const roots = rest.length > 0 ? rest : ["packages"];
1738
+ // Every `report` a campaign names is read now, before any file asks:
1739
+ // a term's several commands run at once rather than one after another
1740
+ // the first time a campaign selects a file. What cannot be read is kept
1741
+ // for the campaign to report; a report that does not parse is refused.
1742
+ if (READS_REPORTS.has(command)) {
1743
+ yield* Effect.tryPromise({
1744
+ try: () =>
1745
+ Promise.all(
1746
+ reportSpecsOf(campaignsOf(policy).campaignRules).map((spec) => campaignsOf(policy).reports.read?.(spec)),
1747
+ ),
1748
+ catch: (cause) => fail(String(cause)),
1749
+ });
1750
+ }
1751
+
1752
+ const json = rest.includes("--json");
1753
+ const positional = rest.filter(
1754
+ (argument, index) =>
1755
+ argument !== "--json" &&
1756
+ argument !== "--against" &&
1757
+ (againstAt === -1 || index !== againstAt + 1),
1758
+ );
1759
+ const roots = positional.length > 0 ? positional : ["packages"];
463
1760
 
464
1761
  switch (command) {
1762
+ case "campaigns":
1763
+ return yield* campaigns(policy, ["packages"], rest, configFilename);
1764
+ case "objectives":
1765
+ return yield* objectives(policy, ["packages"], rest);
465
1766
  case "check":
466
- return yield* check(policy, roots);
1767
+ return yield* check(policy, roots, {
1768
+ format: json ? "json" : "text",
1769
+ manifestPath: manifestPathOf(repoRoot, configFilename),
1770
+ });
1771
+ case "conformance":
1772
+ return yield* conformance(policy, roots, {
1773
+ format: json ? "json" : "text",
1774
+ manifestPath: manifestPathOf(repoRoot, manifestFilename),
1775
+ });
467
1776
  case "baseline":
468
1777
  return yield* writeBaseline(policy, roots);
469
1778
  case "explain": {
470
- const [file] = rest;
1779
+ const [file, ...explainRoots] = positional;
471
1780
  if (file === undefined) return yield* Effect.fail(fail("explain needs a file path"));
472
- return yield* explain(policy, file);
1781
+ return yield* explain(policy, file, explainRoots.length > 0 ? explainRoots : ["packages"]);
473
1782
  }
474
1783
  case "coverage":
475
1784
  return yield* coverage(policy, roots);
476
1785
  case "facts": {
477
- const [file] = rest.filter((argument) => argument !== "--json");
1786
+ const [file] = positional;
478
1787
  if (file === undefined) return yield* Effect.fail(fail("facts needs a file path"));
479
- return yield* facts(policy, file, rest.includes("--json") ? "json" : "text");
1788
+ return yield* facts(policy, file, json ? "json" : "text");
480
1789
  }
481
1790
  default:
482
1791
  return yield* Effect.fail(
483
1792
  fail(
484
- `unknown command "${command}". Try: check | baseline | coverage | explain <file> | facts <file> [--json]`,
1793
+ `unknown command "${command}". Try: check [--json] | conformance [--json] [--against <manifest>] | baseline | campaigns [status --changed | attest | note | history] | objectives [clear | concede] | coverage | explain <file> | facts <file> [--json] | init | infer | migrate`,
485
1794
  ),
486
1795
  );
487
1796
  }