@goodbones/cli 0.1.0-beta.3 → 0.1.0-beta.5

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,18 +1,20 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
3
  import * as path from "node:path";
3
4
 
4
5
  import {
5
6
  type Baseline,
6
7
  baselineOf,
8
+ type CoverageFamily,
7
9
  coverageOf,
8
- coverageShortfalls,
10
+ cyclesIn,
9
11
  decodeBaseline,
10
12
  decodeManifest,
11
13
  EMPTY_BASELINE,
12
14
  evaluateGraph,
13
15
  evaluateMemberSite,
16
+ evaluateResolvedEdge,
14
17
  evaluateSelectedBindings,
15
- evaluateSelectedEdge,
16
18
  evaluateStructure,
17
19
  evaluateSurface,
18
20
  exportRulesSelecting,
@@ -21,26 +23,34 @@ import {
21
23
  formatManifestYaml,
22
24
  formatMessage,
23
25
  fractionsOf,
26
+ type Graph,
24
27
  hasGraphRules,
28
+ heightOf,
25
29
  listSourceFiles,
30
+ makeBaselineFilter,
26
31
  MANIFEST_FILENAMES,
27
32
  MANIFEST_SCHEMA_ID,
28
33
  memberRulesSelecting,
34
+ type ObservedEdge,
29
35
  readManifestFile,
30
36
  requiredSiblingsOf,
37
+ residueOf,
31
38
  rulesSelecting,
32
39
  serializeBaseline,
40
+ slackOf,
41
+ type Snapshot,
42
+ SNAPSHOT_VERSION,
33
43
  type SourceFacts,
34
44
  staleEntriesOf,
35
45
  surfaceRulesSelecting,
36
- unbaselined,
37
46
  type Violation,
38
47
  } from "@goodbones/core";
39
48
  import * as Effect from "effect/Effect";
40
49
  import * as Result from "effect/Result";
41
50
 
42
- import { type LoadedPolicy, loadPolicyFromFile } from "./config-loader.js";
51
+ import { type LoadedPolicy, loadPolicyFromFile, manifestPathOf } from "./config-loader.js";
43
52
  import { buildGraph } from "./graph.js";
53
+ import { infer } from "./infer.js";
44
54
  import { sourceFactsOf } from "./source-facts.js";
45
55
 
46
56
  // The policy, run with no linter in the loop.
@@ -59,16 +69,41 @@ export type CliFailure = { readonly _tag: "CliFailure"; readonly message: string
59
69
 
60
70
  const fail = (message: string): CliFailure => ({ _tag: "CliFailure", message });
61
71
 
72
+ // An edge the resolver could not turn into a file. It is reported on its own,
73
+ // since every import rule about it enforces nothing.
74
+ export type UnresolvedEdge = {
75
+ readonly file: string;
76
+ readonly specifier: string;
77
+ readonly detail: string;
78
+ };
79
+
62
80
  export type Findings = {
63
81
  readonly violations: ReadonlyArray<Violation>;
64
- readonly unresolved: ReadonlyArray<string>;
82
+ readonly unresolved: ReadonlyArray<UnresolvedEdge>;
65
83
  readonly files: number;
84
+ // Every edge resolved from a file under an import rule — what the slack
85
+ // report reads. An edge from a file no import rule selects is not here,
86
+ // since no allowlist could have admitted it.
87
+ readonly edges: ReadonlyArray<ObservedEdge>;
88
+ // The import graph, when a graph rule needed it or the caller asked.
89
+ readonly graph: Graph | null;
66
90
  };
67
91
 
68
- export const collectFindings = (policy: LoadedPolicy, roots: ReadonlyArray<string>): Findings => {
92
+ export type CollectOptions = {
93
+ // Build the graph even when no rule needs it — the snapshot counts cycles
94
+ // and orders violations by it.
95
+ readonly graph?: boolean;
96
+ };
97
+
98
+ export const collectFindings = (
99
+ policy: LoadedPolicy,
100
+ roots: ReadonlyArray<string>,
101
+ options: CollectOptions = {},
102
+ ): Findings => {
69
103
  const files = listSourceFiles(policy.repoRoot, roots, policy.languages);
70
104
  const violations: Array<Violation> = [];
71
- const unresolved: Array<string> = [];
105
+ const unresolved: Array<UnresolvedEdge> = [];
106
+ const edges: Array<ObservedEdge> = [];
72
107
 
73
108
  // Each file is parsed at most once, whether the per-file families or the
74
109
  // graph pass asks first.
@@ -83,13 +118,12 @@ export const collectFindings = (policy: LoadedPolicy, roots: ReadonlyArray<strin
83
118
 
84
119
  // The graph is the whole repository resolved at once — the one question no
85
120
  // per-file adapter can ask — and is built only when a rule needs it.
86
- if (hasGraphRules(policy.graph)) {
87
- for (const violation of evaluateGraph(
88
- policy.graph,
89
- buildGraph(files, policy.resolver, factsOf),
90
- )) {
91
- violations.push(violation);
92
- }
121
+ const graph =
122
+ options.graph === true || hasGraphRules(policy.graph)
123
+ ? buildGraph(files, policy.resolver, factsOf)
124
+ : null;
125
+ if (graph !== null && hasGraphRules(policy.graph)) {
126
+ for (const violation of evaluateGraph(policy.graph, graph)) violations.push(violation);
93
127
  }
94
128
 
95
129
  for (const file of files) {
@@ -124,14 +158,21 @@ export const collectFindings = (policy: LoadedPolicy, roots: ReadonlyArray<strin
124
158
  for (const specifier of facts.specifiers) {
125
159
  const edge = { importer: file, specifier };
126
160
 
127
- const imported = evaluateSelectedEdge(selectedImports, policy.resolver, edge);
128
- if (Result.isFailure(imported)) {
129
- if (policy.config.resolve.unresolved === "off") continue;
130
- if (policy.ignoreUnresolved.some((pattern) => pattern.test(specifier))) continue;
131
- unresolved.push(`${file} → ${specifier} (${imported.failure.detail})`);
132
- continue;
161
+ // A file no import rule selects never needs its imports resolved, which
162
+ // is what keeps resolution off the hot path for the bulk of the repo.
163
+ if (selectedImports.length > 0) {
164
+ const resolved = policy.resolver.resolve(file, specifier);
165
+ if (Result.isFailure(resolved)) {
166
+ if (policy.config.resolve.unresolved === "off") continue;
167
+ if (policy.ignoreUnresolved.some((pattern) => pattern.test(specifier))) continue;
168
+ unresolved.push({ file, specifier, detail: resolved.failure.detail });
169
+ continue;
170
+ }
171
+ edges.push({ importer: file, target: resolved.success });
172
+ for (const violation of evaluateResolvedEdge(selectedImports, file, resolved.success)) {
173
+ violations.push(violation);
174
+ }
133
175
  }
134
- for (const violation of imported.success) violations.push(violation);
135
176
 
136
177
  const bound = facts.bindings.get(specifier) ?? [];
137
178
  const exported = evaluateSelectedBindings(selectedExports, policy.resolver, {
@@ -144,7 +185,7 @@ export const collectFindings = (policy: LoadedPolicy, roots: ReadonlyArray<strin
144
185
  }
145
186
  }
146
187
 
147
- return { violations, unresolved, files: files.length };
188
+ return { violations, unresolved, files: files.length, edges, graph };
148
189
  };
149
190
 
150
191
  const baselinePathOf = (policy: LoadedPolicy): string | null =>
@@ -170,68 +211,338 @@ const report = (lines: ReadonlyArray<string>): Effect.Effect<void> =>
170
211
  const describe = (violation: Violation): string =>
171
212
  ` ${violation.file}\n ${formatMessage(violation)}`;
172
213
 
214
+ // Everything `check` has to say, as one value: the two renderers below read
215
+ // it, and nothing else computes a finding. `version` is here so a document
216
+ // that grows this shape (a conformance snapshot) can say which one it grew.
217
+ export type ReportedViolation = Violation & {
218
+ readonly fingerprint: string;
219
+ readonly baselined: boolean;
220
+ };
221
+
222
+ export type CoverageReport = Readonly<
223
+ Record<
224
+ CoverageFamily,
225
+ { readonly covered: number; readonly total: number; readonly floor?: number }
226
+ >
227
+ >;
228
+
229
+ export type CheckReport = {
230
+ readonly version: 1;
231
+ readonly files: number;
232
+ readonly roots: ReadonlyArray<string>;
233
+ readonly ok: boolean;
234
+ // The file the policy was read from, repo-relative, and a hash of its
235
+ // bytes — the root file only, when the manifest is split with `include`.
236
+ readonly manifest: { readonly path: string; readonly sha256: string };
237
+ // Every finding, baselined ones included; `baselined` says which.
238
+ readonly violations: ReadonlyArray<ReportedViolation>;
239
+ readonly unresolved: ReadonlyArray<UnresolvedEdge>;
240
+ // Baseline entries the code no longer produces.
241
+ readonly stale: ReadonlyArray<string>;
242
+ readonly coverage: CoverageReport;
243
+ readonly adoption: {
244
+ readonly unrestricted: ReadonlyArray<string>;
245
+ readonly partial: ReadonlyArray<string>;
246
+ };
247
+ };
248
+
249
+ const COVERAGE_FAMILIES: ReadonlyArray<CoverageFamily> = [
250
+ "imports",
251
+ "structure",
252
+ "members",
253
+ "surface",
254
+ "graph",
255
+ ];
256
+
257
+ const sha256Of = (file: string): string => {
258
+ try {
259
+ return createHash("sha256").update(readFileSync(file)).digest("hex");
260
+ } catch {
261
+ return "";
262
+ }
263
+ };
264
+
265
+ export const checkReport = (
266
+ policy: LoadedPolicy,
267
+ roots: ReadonlyArray<string>,
268
+ manifestPath: string,
269
+ ): CheckReport => reportOf(policy, roots, manifestPath, collectFindings(policy, roots));
270
+
271
+ const reportOf = (
272
+ policy: LoadedPolicy,
273
+ roots: ReadonlyArray<string>,
274
+ manifestPath: string,
275
+ findings: Findings,
276
+ ): CheckReport => {
277
+ const baseline = readBaseline(policy);
278
+ const stale = staleEntriesOf(baseline, findings.violations);
279
+ const { isBaselined } = makeBaselineFilter(baseline);
280
+ const violations = findings.violations.map((violation) => ({
281
+ ...violation,
282
+ fingerprint: fingerprintOf(violation),
283
+ baselined: isBaselined(violation),
284
+ }));
285
+
286
+ // The floors. A policy states how much of the tree it reaches, per
287
+ // family; falling under is a policy that quietly stopped covering files.
288
+ const floors = policy.config.limits?.coverage ?? {};
289
+ const found = coverageOf(policy, listSourceFiles(policy.repoRoot, roots, policy.languages));
290
+ const covered = (family: CoverageFamily): number =>
291
+ family === "structure" ? found.structure.enumerated : found[family].covered;
292
+ const coverage = Object.fromEntries(
293
+ COVERAGE_FAMILIES.map((family) => {
294
+ const floor = floors[family];
295
+ return [
296
+ family,
297
+ {
298
+ covered: covered(family),
299
+ total: found.files,
300
+ ...(floor === undefined ? {} : { floor }),
301
+ },
302
+ ];
303
+ }),
304
+ ) as CoverageReport;
305
+ const shortfalls = shortfallsOf(coverage);
306
+
307
+ const reportable = violations.filter((one) => !one.baselined).length;
308
+ return {
309
+ version: 1,
310
+ files: findings.files,
311
+ roots,
312
+ ok:
313
+ reportable === 0 &&
314
+ findings.unresolved.length === 0 &&
315
+ stale.length === 0 &&
316
+ shortfalls.length === 0,
317
+ manifest: {
318
+ path: path.relative(policy.repoRoot, manifestPath).replaceAll(path.sep, "/"),
319
+ sha256: sha256Of(manifestPath),
320
+ },
321
+ violations,
322
+ unresolved: findings.unresolved,
323
+ stale,
324
+ coverage,
325
+ adoption: {
326
+ unrestricted: policy.adoption.unrestricted,
327
+ partial: policy.adoption.partial,
328
+ },
329
+ };
330
+ };
331
+
332
+ // Why a report is not `ok`, in the order the text renderer explains it: a
333
+ // stale baseline first, since nothing else is trustworthy until the file
334
+ // describes something real.
335
+ type Shortfall = {
336
+ readonly family: CoverageFamily;
337
+ readonly actual: number;
338
+ readonly floor: number;
339
+ };
340
+
341
+ const shortfallsOf = (coverage: CoverageReport): ReadonlyArray<Shortfall> =>
342
+ COVERAGE_FAMILIES.flatMap((family) => {
343
+ const { covered, floor, total } = coverage[family];
344
+ const actual = total === 0 ? 1 : covered / total;
345
+ return floor === undefined || actual >= floor ? [] : [{ family, actual, floor }];
346
+ });
347
+
348
+ const failureOf = (
349
+ report: CheckReport,
350
+ shortfalls: ReadonlyArray<Shortfall>,
351
+ ): CliFailure | null => {
352
+ if (report.stale.length > 0) return fail("stale baseline entries");
353
+ if (shortfalls.length > 0) return fail("coverage below floor");
354
+ if (report.ok) return null;
355
+ return fail("architecture violations");
356
+ };
357
+
358
+ const renderText = (report: CheckReport): ReadonlyArray<string> => {
359
+ const reportable = report.violations.filter((one) => !one.baselined);
360
+ const carried = report.violations.length - reportable.length;
361
+ const shortfalls = shortfallsOf(report.coverage);
362
+ return [
363
+ ...reportable.map(describe),
364
+ ...report.unresolved.map(
365
+ (one) => ` unresolved: ${one.file} → ${one.specifier} (${one.detail})`,
366
+ ),
367
+ "",
368
+ `${String(report.files)} files, ${String(reportable.length)} violations` +
369
+ (carried > 0 ? `, ${String(carried)} carried by the baseline` : ""),
370
+ // The ratchet: a fixed violation must leave the baseline, or the floor
371
+ // never rises and the file stops describing anything real.
372
+ ...(report.stale.length === 0
373
+ ? []
374
+ : [
375
+ "",
376
+ `${String(report.stale.length)} baseline entries no longer fire. The code was fixed; prune them:`,
377
+ ...report.stale.map((entry) => ` ${entry}`),
378
+ "",
379
+ " architecture baseline # rewrites the file from what still fires",
380
+ ]),
381
+ ...(shortfalls.length === 0
382
+ ? []
383
+ : [
384
+ "",
385
+ "coverage is below the floor the policy states for itself:",
386
+ ...shortfalls.map(
387
+ (one) => ` ${one.family}: ${percent(one.actual)} covered, floor ${percent(one.floor)}`,
388
+ ),
389
+ "",
390
+ " architecture coverage # which files no rule reaches",
391
+ ]),
392
+ ];
393
+ };
394
+
395
+ export type CheckOptions = {
396
+ readonly format: "text" | "json";
397
+ readonly manifestPath: string;
398
+ };
399
+
173
400
  export const check = (
174
401
  policy: LoadedPolicy,
175
402
  roots: ReadonlyArray<string>,
403
+ options: CheckOptions,
176
404
  ): Effect.Effect<void, CliFailure> =>
177
405
  Effect.gen(function* () {
178
- const findings = collectFindings(policy, roots);
179
- const baseline = readBaseline(policy);
180
- const reportable = unbaselined(baseline, findings.violations);
181
- const stale = staleEntriesOf(baseline, findings.violations);
406
+ const report_ = checkReport(policy, roots, options.manifestPath);
407
+ // JSON is one object on stdout and nothing else there; the failure, when
408
+ // there is one, is a sentence on stderr and the exit code, as in text.
409
+ yield* report(
410
+ options.format === "json" ? [JSON.stringify(report_, null, 2)] : renderText(report_),
411
+ );
412
+ const failure = failureOf(report_, shortfallsOf(report_.coverage));
413
+ if (failure !== null) return yield* Effect.fail(failure);
414
+ });
182
415
 
183
- yield* report(reportable.map(describe));
184
- yield* report(findings.unresolved.map((one) => ` unresolved: ${one}`));
416
+ const percent = (fraction: number): string => `${String(Math.floor(fraction * 100))}%`;
185
417
 
186
- const carried = findings.violations.length - reportable.length;
187
- yield* report([
188
- "",
189
- `${String(findings.files)} files, ${String(reportable.length)} violations` +
190
- (carried > 0 ? `, ${String(carried)} carried by the baseline` : ""),
191
- ]);
418
+ // The conformance snapshot: `check`'s report grown with what no family
419
+ // reaches, what the allowlists permit and nothing uses, the cycle count and
420
+ // the size of the debt — the whole distance between the tree and the
421
+ // manifest, as one document another run can be compared against. Its shape
422
+ // is the core's `Snapshot`, and the schema published beside the manifest's.
423
+ export const snapshotOf = (
424
+ policy: LoadedPolicy,
425
+ roots: ReadonlyArray<string>,
426
+ manifestPath: string,
427
+ ): Snapshot => {
428
+ const findings = collectFindings(policy, roots, { graph: true });
429
+ const report_ = reportOf(policy, roots, manifestPath, findings);
430
+ const files = listSourceFiles(policy.repoRoot, roots, policy.languages);
431
+ const graph = findings.graph ?? { files, edges: new Map() };
432
+ const heights = heightOf(graph);
433
+
434
+ // Leaf edges first. A violation names a target when it is about an edge;
435
+ // the cost of fixing it is roughly how much of the graph stands beneath
436
+ // that target, so the ones nearest the ground come first and a reader
437
+ // starting at the top of the list is starting where a fix stays local.
438
+ // Ties keep the fingerprint order, so the list is the same on every run.
439
+ const heightOfViolation = (one: ReportedViolation): number =>
440
+ heights.get(one.subject ?? "") ?? heights.get(one.file) ?? 0;
441
+ const violations = [...report_.violations].sort((left, right) => {
442
+ const byHeight = heightOfViolation(left) - heightOfViolation(right);
443
+ return byHeight !== 0 ? byHeight : left.fingerprint.localeCompare(right.fingerprint);
444
+ });
192
445
 
193
- if (stale.length > 0) {
194
- // The ratchet: a fixed violation must leave the baseline, or the floor
195
- // never rises and the file stops describing anything real.
196
- yield* report([
197
- "",
198
- `${String(stale.length)} baseline entries no longer fire. The code was fixed; prune them:`,
199
- ...stale.map((entry) => ` ${entry}`),
200
- "",
201
- " architecture baseline # rewrites the file from what still fires",
202
- ]);
203
- return yield* Effect.fail(fail("stale baseline entries"));
204
- }
446
+ return {
447
+ version: SNAPSHOT_VERSION,
448
+ manifest: report_.manifest,
449
+ roots: report_.roots,
450
+ files: report_.files,
451
+ ok: report_.ok,
452
+ coverage: report_.coverage,
453
+ residue: residueOf(policy, files),
454
+ violations,
455
+ unresolved: report_.unresolved,
456
+ stale: report_.stale,
457
+ baseline: { size: readBaseline(policy).entries.length },
458
+ cycles: cyclesIn(graph).length,
459
+ slack: slackOf(policy.importRules, findings.edges),
460
+ adoption: report_.adoption,
461
+ };
462
+ };
205
463
 
206
- // The floors. A policy states how much of the tree it reaches, per
207
- // family; falling under is a policy that quietly stopped covering files.
208
- const floors = policy.config.limits?.coverage;
209
- const shortfalls =
210
- floors === undefined
211
- ? []
212
- : coverageShortfalls(
213
- coverageOf(policy, listSourceFiles(policy.repoRoot, roots, policy.languages)),
214
- floors,
215
- );
216
- if (shortfalls.length > 0) {
217
- yield* report([
218
- "",
219
- "coverage is below the floor the policy states for itself:",
220
- ...shortfalls.map(
221
- (one) => ` ${one.family}: ${percent(one.actual)} covered, floor ${percent(one.floor)}`,
222
- ),
223
- "",
224
- " architecture coverage # which files no rule reaches",
225
- ]);
226
- return yield* Effect.fail(fail("coverage below floor"));
227
- }
464
+ const renderSnapshot = (snapshot: Snapshot): ReadonlyArray<string> => {
465
+ const reportable = snapshot.violations.filter((one) => !one.baselined);
466
+ const carried = snapshot.violations.length - reportable.length;
467
+ const count = (n: number, noun: string, plural = `${noun}s`): string =>
468
+ `${String(n)} ${n === 1 ? noun : plural}`;
469
+ const row = (family: CoverageFamily): string => {
470
+ const { covered, floor, total } = snapshot.coverage[family];
471
+ const fraction = total === 0 ? 1 : covered / total;
472
+ const mark =
473
+ floor === undefined
474
+ ? ""
475
+ : fraction >= floor
476
+ ? ` ≥ ${percent(floor)} ✓`
477
+ : ` < ${percent(floor)} ✗`;
478
+ return ` ${family.padEnd(10)} ${String(covered).padStart(5)}/${String(total)} ${percent(fraction).padStart(4)}${mark}`;
479
+ };
480
+ const section = (title: string, lines: ReadonlyArray<string>): ReadonlyArray<string> => [
481
+ "",
482
+ title,
483
+ ...lines,
484
+ ];
485
+
486
+ return [
487
+ `${String(snapshot.files)} files under ${snapshot.roots.join(", ")}, against ${snapshot.manifest.path}`,
488
+ ...section("coverage", COVERAGE_FAMILIES.map(row)),
489
+ ...section(
490
+ `residue: ${count(snapshot.residue.files.length, "file")} no family reaches` +
491
+ (snapshot.residue.folders.length === 0
492
+ ? ""
493
+ : `, ${count(snapshot.residue.folders.length, "folder")} wholly`),
494
+ [
495
+ ...snapshot.residue.folders.map((folder) => ` ${folder}/`),
496
+ ...snapshot.residue.files
497
+ .filter(
498
+ (file) => !snapshot.residue.folders.some((folder) => file.startsWith(`${folder}/`)),
499
+ )
500
+ .map((file) => ` ${file}`),
501
+ ],
502
+ ),
503
+ ...section(
504
+ `violations: ${count(reportable.length, "reportable")}` +
505
+ (carried > 0 ? `, ${String(carried)} carried by the baseline` : "") +
506
+ (snapshot.stale.length > 0
507
+ ? `, ${count(snapshot.stale.length, "stale entry", "stale entries")}`
508
+ : "") +
509
+ (reportable.length > 0 ? " — nearest the ground first" : ""),
510
+ reportable.map(describe),
511
+ ),
512
+ ...(snapshot.unresolved.length === 0
513
+ ? []
514
+ : section(
515
+ `unresolved: ${count(snapshot.unresolved.length, "import")} no rule can police`,
516
+ snapshot.unresolved.map((one) => ` ${one.file} → ${one.specifier} (${one.detail})`),
517
+ )),
518
+ ...section(
519
+ `slack: ${count(snapshot.slack.length, "allowance")} nothing imports through`,
520
+ snapshot.slack.map((one) => ` ${one.node}: ${one.kind} ${JSON.stringify(one.entry)}`),
521
+ ),
522
+ "",
523
+ `cycles: ${String(snapshot.cycles)}`,
524
+ `baseline: ${count(snapshot.baseline.size, "entry", "entries")}`,
525
+ `adoption: ${count(snapshot.adoption.unrestricted.length, "unrestricted tier")}, ${count(snapshot.adoption.partial.length, "partial tier")}`,
526
+ ];
527
+ };
228
528
 
229
- if (reportable.length > 0 || findings.unresolved.length > 0) {
230
- return yield* Effect.fail(fail("architecture violations"));
231
- }
232
- });
529
+ export type ConformanceOptions = CheckOptions;
233
530
 
234
- const percent = (fraction: number): string => `${String(Math.floor(fraction * 100))}%`;
531
+ // The report of the tree against the manifest. Unlike `check`, it never
532
+ // fails: it is a measurement, and the manifest it measures against may be one
533
+ // the tree was never expected to satisfy yet — `--against` names a target.
534
+ // `ok` in the document says what `check` would have done.
535
+ export const conformance = (
536
+ policy: LoadedPolicy,
537
+ roots: ReadonlyArray<string>,
538
+ options: ConformanceOptions,
539
+ ): Effect.Effect<void, CliFailure> =>
540
+ Effect.gen(function* () {
541
+ const snapshot = snapshotOf(policy, roots, options.manifestPath);
542
+ yield* report(
543
+ options.format === "json" ? [JSON.stringify(snapshot, null, 2)] : renderSnapshot(snapshot),
544
+ );
545
+ });
235
546
 
236
547
  // How much of the tree the policy reaches. A probe proves a rule can fire;
237
548
  // this is whether the files are there to fire on. Reported per family, with the
@@ -585,23 +896,58 @@ export const run = (
585
896
  Effect.gen(function* () {
586
897
  const [command = "check", ...rest] = argv;
587
898
 
588
- // The two commands that write a manifest rather than read one.
899
+ // The three commands that write a manifest rather than read one.
589
900
  if (command === "init") return yield* init(repoRoot);
590
901
  if (command === "migrate") return yield* migrate(repoRoot, configFilename);
902
+ if (command === "infer") {
903
+ yield* infer(repoRoot, rest, configFilename);
904
+ return;
905
+ }
906
+
907
+ // `--against <file>` measures the tree against a manifest other than the
908
+ // repository's own — the target the team is moving toward. Only
909
+ // `conformance` takes it: a `check` against a manifest nobody is held to
910
+ // yet would fail for no one's benefit.
911
+ const againstAt = rest.indexOf("--against");
912
+ const against = againstAt === -1 ? undefined : rest[againstAt + 1];
913
+ if (againstAt !== -1 && (against === undefined || against.startsWith("--"))) {
914
+ return yield* Effect.fail(fail("--against needs a manifest path"));
915
+ }
916
+ if (against !== undefined && command !== "conformance") {
917
+ return yield* Effect.fail(
918
+ fail(`--against is a \`conformance\` flag; ${command} does not take it`),
919
+ );
920
+ }
921
+ const manifestFilename = against ?? configFilename;
591
922
 
592
923
  const policy = yield* Effect.tryPromise({
593
- try: () => loadPolicyFromFile(repoRoot, configFilename),
924
+ try: () => loadPolicyFromFile(repoRoot, manifestFilename),
594
925
  catch: (cause) => fail(String(cause)),
595
926
  });
596
927
  yield* Effect.sync(() => {
597
928
  for (const notice of policy.notices) process.stderr.write(`deprecated: ${notice}\n`);
598
929
  });
599
930
 
600
- const roots = rest.length > 0 ? rest : ["packages"];
931
+ const json = rest.includes("--json");
932
+ const positional = rest.filter(
933
+ (argument, index) =>
934
+ argument !== "--json" &&
935
+ argument !== "--against" &&
936
+ (againstAt === -1 || index !== againstAt + 1),
937
+ );
938
+ const roots = positional.length > 0 ? positional : ["packages"];
601
939
 
602
940
  switch (command) {
603
941
  case "check":
604
- return yield* check(policy, roots);
942
+ return yield* check(policy, roots, {
943
+ format: json ? "json" : "text",
944
+ manifestPath: manifestPathOf(repoRoot, configFilename),
945
+ });
946
+ case "conformance":
947
+ return yield* conformance(policy, roots, {
948
+ format: json ? "json" : "text",
949
+ manifestPath: manifestPathOf(repoRoot, manifestFilename),
950
+ });
605
951
  case "baseline":
606
952
  return yield* writeBaseline(policy, roots);
607
953
  case "explain": {
@@ -612,14 +958,14 @@ export const run = (
612
958
  case "coverage":
613
959
  return yield* coverage(policy, roots);
614
960
  case "facts": {
615
- const [file] = rest.filter((argument) => argument !== "--json");
961
+ const [file] = positional;
616
962
  if (file === undefined) return yield* Effect.fail(fail("facts needs a file path"));
617
- return yield* facts(policy, file, rest.includes("--json") ? "json" : "text");
963
+ return yield* facts(policy, file, json ? "json" : "text");
618
964
  }
619
965
  default:
620
966
  return yield* Effect.fail(
621
967
  fail(
622
- `unknown command "${command}". Try: check | baseline | coverage | explain <file> | facts <file> [--json] | init | migrate`,
968
+ `unknown command "${command}". Try: check [--json] | conformance [--json] [--against <manifest>] | baseline | coverage | explain <file> | facts <file> [--json] | init | infer | migrate`,
623
969
  ),
624
970
  );
625
971
  }