@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/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 +49 -4
- 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 +269 -61
- 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 +426 -80
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
|
-
|
|
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<
|
|
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
|
|
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<
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
policy.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
184
|
-
yield* report(findings.unresolved.map((one) => ` unresolved: ${one}`));
|
|
416
|
+
const percent = (fraction: number): string => `${String(Math.floor(fraction * 100))}%`;
|
|
185
417
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
230
|
-
return yield* Effect.fail(fail("architecture violations"));
|
|
231
|
-
}
|
|
232
|
-
});
|
|
529
|
+
export type ConformanceOptions = CheckOptions;
|
|
233
530
|
|
|
234
|
-
|
|
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
|
|
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,
|
|
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
|
|
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] =
|
|
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,
|
|
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
|
}
|