@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/build/dts/config-loader.d.ts +5 -1
- package/build/dts/config-loader.d.ts.map +1 -1
- package/build/dts/infer.d.ts +34 -0
- package/build/dts/infer.d.ts.map +1 -0
- package/build/dts/run.d.ts +40 -3
- package/build/dts/run.d.ts.map +1 -1
- package/build/esm/config-loader.js +21 -6
- package/build/esm/config-loader.js.map +1 -1
- package/build/esm/infer.js +378 -0
- package/build/esm/infer.js.map +1 -0
- package/build/esm/run.js +132 -46
- package/build/esm/run.js.map +1 -1
- package/package.json +3 -3
- package/src/config-loader.ts +32 -7
- package/src/infer.ts +511 -0
- package/src/run.ts +218 -64
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<
|
|
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<
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
|
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
|
|
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] =
|
|
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,
|
|
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
|
}
|