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

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,11 +1,12 @@
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,
9
10
  decodeBaseline,
10
11
  decodeManifest,
11
12
  EMPTY_BASELINE,
@@ -23,6 +24,7 @@ import {
23
24
  fractionsOf,
24
25
  hasGraphRules,
25
26
  listSourceFiles,
27
+ makeBaselineFilter,
26
28
  MANIFEST_FILENAMES,
27
29
  MANIFEST_SCHEMA_ID,
28
30
  memberRulesSelecting,
@@ -33,14 +35,14 @@ import {
33
35
  type SourceFacts,
34
36
  staleEntriesOf,
35
37
  surfaceRulesSelecting,
36
- unbaselined,
37
38
  type Violation,
38
39
  } from "@goodbones/core";
39
40
  import * as Effect from "effect/Effect";
40
41
  import * as Result from "effect/Result";
41
42
 
42
- import { type LoadedPolicy, loadPolicyFromFile } from "./config-loader.js";
43
+ import { type LoadedPolicy, loadPolicyFromFile, manifestPathOf } from "./config-loader.js";
43
44
  import { buildGraph } from "./graph.js";
45
+ import { infer } from "./infer.js";
44
46
  import { sourceFactsOf } from "./source-facts.js";
45
47
 
46
48
  // The policy, run with no linter in the loop.
@@ -59,16 +61,24 @@ export type CliFailure = { readonly _tag: "CliFailure"; readonly message: string
59
61
 
60
62
  const fail = (message: string): CliFailure => ({ _tag: "CliFailure", message });
61
63
 
64
+ // An edge the resolver could not turn into a file. It is reported on its own,
65
+ // since every import rule about it enforces nothing.
66
+ export type UnresolvedEdge = {
67
+ readonly file: string;
68
+ readonly specifier: string;
69
+ readonly detail: string;
70
+ };
71
+
62
72
  export type Findings = {
63
73
  readonly violations: ReadonlyArray<Violation>;
64
- readonly unresolved: ReadonlyArray<string>;
74
+ readonly unresolved: ReadonlyArray<UnresolvedEdge>;
65
75
  readonly files: number;
66
76
  };
67
77
 
68
78
  export const collectFindings = (policy: LoadedPolicy, roots: ReadonlyArray<string>): Findings => {
69
79
  const files = listSourceFiles(policy.repoRoot, roots, policy.languages);
70
80
  const violations: Array<Violation> = [];
71
- const unresolved: Array<string> = [];
81
+ const unresolved: Array<UnresolvedEdge> = [];
72
82
 
73
83
  // Each file is parsed at most once, whether the per-file families or the
74
84
  // graph pass asks first.
@@ -128,7 +138,7 @@ export const collectFindings = (policy: LoadedPolicy, roots: ReadonlyArray<strin
128
138
  if (Result.isFailure(imported)) {
129
139
  if (policy.config.resolve.unresolved === "off") continue;
130
140
  if (policy.ignoreUnresolved.some((pattern) => pattern.test(specifier))) continue;
131
- unresolved.push(`${file} → ${specifier} (${imported.failure.detail})`);
141
+ unresolved.push({ file, specifier, detail: imported.failure.detail });
132
142
  continue;
133
143
  }
134
144
  for (const violation of imported.success) violations.push(violation);
@@ -170,65 +180,200 @@ const report = (lines: ReadonlyArray<string>): Effect.Effect<void> =>
170
180
  const describe = (violation: Violation): string =>
171
181
  ` ${violation.file}\n ${formatMessage(violation)}`;
172
182
 
173
- export const check = (
183
+ // Everything `check` has to say, as one value: the two renderers below read
184
+ // it, and nothing else computes a finding. `version` is here so a document
185
+ // that grows this shape (a conformance snapshot) can say which one it grew.
186
+ export type ReportedViolation = Violation & {
187
+ readonly fingerprint: string;
188
+ readonly baselined: boolean;
189
+ };
190
+
191
+ export type CoverageReport = Readonly<
192
+ Record<
193
+ CoverageFamily,
194
+ { readonly covered: number; readonly total: number; readonly floor?: number }
195
+ >
196
+ >;
197
+
198
+ export type CheckReport = {
199
+ readonly version: 1;
200
+ readonly files: number;
201
+ readonly roots: ReadonlyArray<string>;
202
+ readonly ok: boolean;
203
+ // The file the policy was read from, repo-relative, and a hash of its
204
+ // bytes — the root file only, when the manifest is split with `include`.
205
+ readonly manifest: { readonly path: string; readonly sha256: string };
206
+ // Every finding, baselined ones included; `baselined` says which.
207
+ readonly violations: ReadonlyArray<ReportedViolation>;
208
+ readonly unresolved: ReadonlyArray<UnresolvedEdge>;
209
+ // Baseline entries the code no longer produces.
210
+ readonly stale: ReadonlyArray<string>;
211
+ readonly coverage: CoverageReport;
212
+ readonly adoption: {
213
+ readonly unrestricted: ReadonlyArray<string>;
214
+ readonly partial: ReadonlyArray<string>;
215
+ };
216
+ };
217
+
218
+ const COVERAGE_FAMILIES: ReadonlyArray<CoverageFamily> = [
219
+ "imports",
220
+ "structure",
221
+ "members",
222
+ "surface",
223
+ "graph",
224
+ ];
225
+
226
+ const sha256Of = (file: string): string => {
227
+ try {
228
+ return createHash("sha256").update(readFileSync(file)).digest("hex");
229
+ } catch {
230
+ return "";
231
+ }
232
+ };
233
+
234
+ export const checkReport = (
174
235
  policy: LoadedPolicy,
175
236
  roots: ReadonlyArray<string>,
176
- ): Effect.Effect<void, CliFailure> =>
177
- 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);
237
+ manifestPath: string,
238
+ ): CheckReport => {
239
+ const findings = collectFindings(policy, roots);
240
+ const baseline = readBaseline(policy);
241
+ const stale = staleEntriesOf(baseline, findings.violations);
242
+ const { isBaselined } = makeBaselineFilter(baseline);
243
+ const violations = findings.violations.map((violation) => ({
244
+ ...violation,
245
+ fingerprint: fingerprintOf(violation),
246
+ baselined: isBaselined(violation),
247
+ }));
248
+
249
+ // The floors. A policy states how much of the tree it reaches, per
250
+ // family; falling under is a policy that quietly stopped covering files.
251
+ const floors = policy.config.limits?.coverage ?? {};
252
+ const found = coverageOf(policy, listSourceFiles(policy.repoRoot, roots, policy.languages));
253
+ const covered = (family: CoverageFamily): number =>
254
+ family === "structure" ? found.structure.enumerated : found[family].covered;
255
+ const coverage = Object.fromEntries(
256
+ COVERAGE_FAMILIES.map((family) => {
257
+ const floor = floors[family];
258
+ return [
259
+ family,
260
+ {
261
+ covered: covered(family),
262
+ total: found.files,
263
+ ...(floor === undefined ? {} : { floor }),
264
+ },
265
+ ];
266
+ }),
267
+ ) as CoverageReport;
268
+ const shortfalls = shortfallsOf(coverage);
269
+
270
+ const reportable = violations.filter((one) => !one.baselined).length;
271
+ return {
272
+ version: 1,
273
+ files: findings.files,
274
+ roots,
275
+ ok:
276
+ reportable === 0 &&
277
+ findings.unresolved.length === 0 &&
278
+ stale.length === 0 &&
279
+ shortfalls.length === 0,
280
+ manifest: {
281
+ path: path.relative(policy.repoRoot, manifestPath).replaceAll(path.sep, "/"),
282
+ sha256: sha256Of(manifestPath),
283
+ },
284
+ violations,
285
+ unresolved: findings.unresolved,
286
+ stale,
287
+ coverage,
288
+ adoption: {
289
+ unrestricted: policy.adoption.unrestricted,
290
+ partial: policy.adoption.partial,
291
+ },
292
+ };
293
+ };
294
+
295
+ // Why a report is not `ok`, in the order the text renderer explains it: a
296
+ // stale baseline first, since nothing else is trustworthy until the file
297
+ // describes something real.
298
+ type Shortfall = {
299
+ readonly family: CoverageFamily;
300
+ readonly actual: number;
301
+ readonly floor: number;
302
+ };
182
303
 
183
- yield* report(reportable.map(describe));
184
- yield* report(findings.unresolved.map((one) => ` unresolved: ${one}`));
304
+ const shortfallsOf = (coverage: CoverageReport): ReadonlyArray<Shortfall> =>
305
+ COVERAGE_FAMILIES.flatMap((family) => {
306
+ const { covered, floor, total } = coverage[family];
307
+ const actual = total === 0 ? 1 : covered / total;
308
+ return floor === undefined || actual >= floor ? [] : [{ family, actual, floor }];
309
+ });
185
310
 
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
- ]);
311
+ const failureOf = (
312
+ report: CheckReport,
313
+ shortfalls: ReadonlyArray<Shortfall>,
314
+ ): CliFailure | null => {
315
+ if (report.stale.length > 0) return fail("stale baseline entries");
316
+ if (shortfalls.length > 0) return fail("coverage below floor");
317
+ if (report.ok) return null;
318
+ return fail("architecture violations");
319
+ };
192
320
 
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
- }
321
+ const renderText = (report: CheckReport): ReadonlyArray<string> => {
322
+ const reportable = report.violations.filter((one) => !one.baselined);
323
+ const carried = report.violations.length - reportable.length;
324
+ const shortfalls = shortfallsOf(report.coverage);
325
+ return [
326
+ ...reportable.map(describe),
327
+ ...report.unresolved.map(
328
+ (one) => ` unresolved: ${one.file} → ${one.specifier} (${one.detail})`,
329
+ ),
330
+ "",
331
+ `${String(report.files)} files, ${String(reportable.length)} violations` +
332
+ (carried > 0 ? `, ${String(carried)} carried by the baseline` : ""),
333
+ // The ratchet: a fixed violation must leave the baseline, or the floor
334
+ // never rises and the file stops describing anything real.
335
+ ...(report.stale.length === 0
336
+ ? []
337
+ : [
338
+ "",
339
+ `${String(report.stale.length)} baseline entries no longer fire. The code was fixed; prune them:`,
340
+ ...report.stale.map((entry) => ` ${entry}`),
341
+ "",
342
+ " architecture baseline # rewrites the file from what still fires",
343
+ ]),
344
+ ...(shortfalls.length === 0
345
+ ? []
346
+ : [
347
+ "",
348
+ "coverage is below the floor the policy states for itself:",
349
+ ...shortfalls.map(
350
+ (one) => ` ${one.family}: ${percent(one.actual)} covered, floor ${percent(one.floor)}`,
351
+ ),
352
+ "",
353
+ " architecture coverage # which files no rule reaches",
354
+ ]),
355
+ ];
356
+ };
205
357
 
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
- }
358
+ export type CheckOptions = {
359
+ readonly format: "text" | "json";
360
+ readonly manifestPath: string;
361
+ };
228
362
 
229
- if (reportable.length > 0 || findings.unresolved.length > 0) {
230
- return yield* Effect.fail(fail("architecture violations"));
231
- }
363
+ export const check = (
364
+ policy: LoadedPolicy,
365
+ roots: ReadonlyArray<string>,
366
+ options: CheckOptions,
367
+ ): Effect.Effect<void, CliFailure> =>
368
+ Effect.gen(function* () {
369
+ const report_ = checkReport(policy, roots, options.manifestPath);
370
+ // JSON is one object on stdout and nothing else there; the failure, when
371
+ // there is one, is a sentence on stderr and the exit code, as in text.
372
+ yield* report(
373
+ options.format === "json" ? [JSON.stringify(report_, null, 2)] : renderText(report_),
374
+ );
375
+ const failure = failureOf(report_, shortfallsOf(report_.coverage));
376
+ if (failure !== null) return yield* Effect.fail(failure);
232
377
  });
233
378
 
234
379
  const percent = (fraction: number): string => `${String(Math.floor(fraction * 100))}%`;
@@ -585,9 +730,13 @@ export const run = (
585
730
  Effect.gen(function* () {
586
731
  const [command = "check", ...rest] = argv;
587
732
 
588
- // The two commands that write a manifest rather than read one.
733
+ // The three commands that write a manifest rather than read one.
589
734
  if (command === "init") return yield* init(repoRoot);
590
735
  if (command === "migrate") return yield* migrate(repoRoot, configFilename);
736
+ if (command === "infer") {
737
+ yield* infer(repoRoot, rest, configFilename);
738
+ return;
739
+ }
591
740
 
592
741
  const policy = yield* Effect.tryPromise({
593
742
  try: () => loadPolicyFromFile(repoRoot, configFilename),
@@ -597,11 +746,16 @@ export const run = (
597
746
  for (const notice of policy.notices) process.stderr.write(`deprecated: ${notice}\n`);
598
747
  });
599
748
 
600
- const roots = rest.length > 0 ? rest : ["packages"];
749
+ const json = rest.includes("--json");
750
+ const positional = rest.filter((argument) => argument !== "--json");
751
+ const roots = positional.length > 0 ? positional : ["packages"];
601
752
 
602
753
  switch (command) {
603
754
  case "check":
604
- return yield* check(policy, roots);
755
+ return yield* check(policy, roots, {
756
+ format: json ? "json" : "text",
757
+ manifestPath: manifestPathOf(repoRoot, configFilename),
758
+ });
605
759
  case "baseline":
606
760
  return yield* writeBaseline(policy, roots);
607
761
  case "explain": {
@@ -612,14 +766,14 @@ export const run = (
612
766
  case "coverage":
613
767
  return yield* coverage(policy, roots);
614
768
  case "facts": {
615
- const [file] = rest.filter((argument) => argument !== "--json");
769
+ const [file] = positional;
616
770
  if (file === undefined) return yield* Effect.fail(fail("facts needs a file path"));
617
- return yield* facts(policy, file, rest.includes("--json") ? "json" : "text");
771
+ return yield* facts(policy, file, json ? "json" : "text");
618
772
  }
619
773
  default:
620
774
  return yield* Effect.fail(
621
775
  fail(
622
- `unknown command "${command}". Try: check | baseline | coverage | explain <file> | facts <file> [--json] | init | migrate`,
776
+ `unknown command "${command}". Try: check [--json] | baseline | coverage | explain <file> | facts <file> [--json] | init | infer | migrate`,
623
777
  ),
624
778
  );
625
779
  }