@goodbones/cli 0.1.0-beta.2 → 0.1.0-beta.20
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/campaigns/host.d.ts +51 -0
- package/build/dts/campaigns/host.d.ts.map +1 -0
- package/build/dts/campaigns/live.d.ts +3 -0
- package/build/dts/campaigns/live.d.ts.map +1 -0
- package/build/dts/campaigns/none.d.ts +7 -0
- package/build/dts/campaigns/none.d.ts.map +1 -0
- package/build/dts/campaigns/verbs.d.ts +6 -0
- package/build/dts/campaigns/verbs.d.ts.map +1 -0
- package/build/dts/config-loader.d.ts +10 -3
- 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/output.d.ts +9 -0
- package/build/dts/output.d.ts.map +1 -0
- package/build/dts/run.d.ts +67 -8
- package/build/dts/run.d.ts.map +1 -1
- package/build/esm/campaigns/host.js +2 -0
- package/build/esm/campaigns/host.js.map +1 -0
- package/build/esm/campaigns/live.js +106 -0
- package/build/esm/campaigns/live.js.map +1 -0
- package/build/esm/campaigns/none.js +34 -0
- package/build/esm/campaigns/none.js.map +1 -0
- package/build/esm/campaigns/verbs.js +489 -0
- package/build/esm/campaigns/verbs.js.map +1 -0
- package/build/esm/config-loader.js +66 -10
- 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/output.js +8 -0
- package/build/esm/output.js.map +1 -0
- package/build/esm/run.js +555 -78
- package/build/esm/run.js.map +1 -1
- package/package.json +19 -5
- package/src/campaigns/host.ts +120 -0
- package/src/campaigns/live.ts +136 -0
- package/src/campaigns/none.ts +47 -0
- package/src/campaigns/verbs.ts +620 -0
- package/src/config-loader.ts +81 -11
- package/src/infer.ts +511 -0
- package/src/output.ts +18 -0
- package/src/run.ts +870 -103
package/src/run.ts
CHANGED
|
@@ -1,46 +1,66 @@
|
|
|
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
|
+
breaches,
|
|
9
|
+
CONFORMANCE_MEASURES,
|
|
10
|
+
type ConformanceMeasure,
|
|
11
|
+
countedFiles,
|
|
12
|
+
type CoverageFamily,
|
|
7
13
|
coverageOf,
|
|
8
|
-
|
|
14
|
+
cyclesIn,
|
|
9
15
|
decodeBaseline,
|
|
10
16
|
decodeManifest,
|
|
11
17
|
EMPTY_BASELINE,
|
|
12
18
|
evaluateGraph,
|
|
13
19
|
evaluateMemberSite,
|
|
20
|
+
evaluateResolvedEdge,
|
|
14
21
|
evaluateSelectedBindings,
|
|
15
|
-
evaluateSelectedEdge,
|
|
16
22
|
evaluateStructure,
|
|
17
23
|
evaluateSurface,
|
|
24
|
+
expandAliases,
|
|
18
25
|
exportRulesSelecting,
|
|
19
26
|
findManifestFile,
|
|
20
27
|
fingerprintOf,
|
|
21
28
|
formatManifestYaml,
|
|
22
29
|
formatMessage,
|
|
23
30
|
fractionsOf,
|
|
31
|
+
globsOf,
|
|
32
|
+
globToRegExp,
|
|
33
|
+
type Graph,
|
|
24
34
|
hasGraphRules,
|
|
35
|
+
heightOf,
|
|
25
36
|
listSourceFiles,
|
|
37
|
+
makeBaselineFilter,
|
|
26
38
|
MANIFEST_FILENAMES,
|
|
27
39
|
MANIFEST_SCHEMA_ID,
|
|
28
40
|
memberRulesSelecting,
|
|
41
|
+
type ObservedEdge,
|
|
29
42
|
readManifestFile,
|
|
30
43
|
requiredSiblingsOf,
|
|
44
|
+
residueOf,
|
|
31
45
|
rulesSelecting,
|
|
32
46
|
serializeBaseline,
|
|
47
|
+
slackOf,
|
|
48
|
+
type Snapshot,
|
|
49
|
+
SNAPSHOT_VERSION,
|
|
33
50
|
type SourceFacts,
|
|
34
51
|
staleEntriesOf,
|
|
35
52
|
surfaceRulesSelecting,
|
|
36
|
-
|
|
53
|
+
vacancyOf,
|
|
37
54
|
type Violation,
|
|
38
55
|
} from "@goodbones/core";
|
|
39
56
|
import * as Effect from "effect/Effect";
|
|
40
57
|
import * as Result from "effect/Result";
|
|
41
58
|
|
|
42
|
-
import {
|
|
59
|
+
import type { CampaignEvaluation, CampaignReport, VerbContext } from "./campaigns/host.js";
|
|
60
|
+
import { type LoadedPolicy, loadPolicyFromFile, manifestPathOf } from "./config-loader.js";
|
|
43
61
|
import { buildGraph } from "./graph.js";
|
|
62
|
+
import { infer } from "./infer.js";
|
|
63
|
+
import { type CliFailure, count, fail, report } from "./output.js";
|
|
44
64
|
import { sourceFactsOf } from "./source-facts.js";
|
|
45
65
|
|
|
46
66
|
// The policy, run with no linter in the loop.
|
|
@@ -50,48 +70,95 @@ import { sourceFactsOf } from "./source-facts.js";
|
|
|
50
70
|
// second way to ask the same question — and the only way to write a baseline,
|
|
51
71
|
// since that needs every finding at once rather than one file at a time.
|
|
52
72
|
//
|
|
53
|
-
// It covers all four families. The
|
|
54
|
-
// rather than oxlint's; both adapters meet at the
|
|
55
|
-
// a binding, a member site — so they answer to
|
|
56
|
-
// other.
|
|
73
|
+
// It covers all four families. The ones that need a syntax tree read it through
|
|
74
|
+
// the language pack's own parse rather than oxlint's; both adapters meet at the
|
|
75
|
+
// same vocabulary — a specifier, a binding, a member site — so they answer to
|
|
76
|
+
// the same core rather than to each other.
|
|
57
77
|
|
|
58
|
-
export type CliFailure
|
|
78
|
+
export type { CliFailure } from "./output.js";
|
|
59
79
|
|
|
60
|
-
|
|
80
|
+
// An edge the resolver could not turn into a file. It is reported on its own,
|
|
81
|
+
// since every import rule about it enforces nothing.
|
|
82
|
+
export type UnresolvedEdge = {
|
|
83
|
+
readonly file: string;
|
|
84
|
+
readonly specifier: string;
|
|
85
|
+
readonly detail: string;
|
|
86
|
+
};
|
|
61
87
|
|
|
62
88
|
export type Findings = {
|
|
63
89
|
readonly violations: ReadonlyArray<Violation>;
|
|
64
|
-
|
|
90
|
+
// Every campaign, evaluated over the files it sees: its sectors, every
|
|
91
|
+
// hit placed in one, each sector's phase. Kept apart from the violations:
|
|
92
|
+
// a hit is debt a campaign is paying down, judged against its ledger
|
|
93
|
+
// rather than the baseline.
|
|
94
|
+
readonly campaigns: ReadonlyArray<CampaignEvaluation>;
|
|
95
|
+
readonly unresolved: ReadonlyArray<UnresolvedEdge>;
|
|
65
96
|
readonly files: number;
|
|
97
|
+
// Every edge resolved from a file under an import rule — what the slack
|
|
98
|
+
// report reads. An edge from a file no import rule selects is not here,
|
|
99
|
+
// since no allowlist could have admitted it.
|
|
100
|
+
readonly edges: ReadonlyArray<ObservedEdge>;
|
|
101
|
+
// The import graph, when a graph rule needed it or the caller asked.
|
|
102
|
+
readonly graph: Graph | null;
|
|
66
103
|
};
|
|
67
104
|
|
|
68
|
-
export
|
|
69
|
-
|
|
105
|
+
export type CollectOptions = {
|
|
106
|
+
// Build the graph even when no rule needs it — the snapshot counts cycles
|
|
107
|
+
// and orders violations by it.
|
|
108
|
+
readonly graph?: boolean;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const collectFindings = (
|
|
112
|
+
policy: LoadedPolicy,
|
|
113
|
+
roots: ReadonlyArray<string>,
|
|
114
|
+
options: CollectOptions = {},
|
|
115
|
+
): Findings => {
|
|
116
|
+
// A campaign may widen the walk past the packs' extensions; a file only a
|
|
117
|
+
// campaign asked for is seen by the campaigns and by no other family.
|
|
118
|
+
const walked = listSourceFiles(
|
|
119
|
+
policy.repoRoot,
|
|
120
|
+
roots,
|
|
121
|
+
policy.languages,
|
|
122
|
+
policy.campaigns.widenedExtensions(policy),
|
|
123
|
+
);
|
|
124
|
+
const known = new Set(policy.languages.flatMap((one) => one.extensions));
|
|
125
|
+
const files = walked.filter((file) => known.has(path.extname(file)));
|
|
70
126
|
const violations: Array<Violation> = [];
|
|
71
|
-
const unresolved: Array<
|
|
127
|
+
const unresolved: Array<UnresolvedEdge> = [];
|
|
128
|
+
const edges: Array<ObservedEdge> = [];
|
|
72
129
|
|
|
73
|
-
// Each file is parsed at most once, whether the per-file
|
|
74
|
-
// graph pass asks first.
|
|
130
|
+
// Each file is read and parsed at most once, whether the per-file
|
|
131
|
+
// families, the graph pass or a campaign asks first.
|
|
132
|
+
const texts = new Map<string, string>();
|
|
133
|
+
const textOf = (file: string): string => {
|
|
134
|
+
const cached = texts.get(file);
|
|
135
|
+
if (cached !== undefined) return cached;
|
|
136
|
+
const text = readFileSync(path.join(policy.repoRoot, file), "utf8");
|
|
137
|
+
texts.set(file, text);
|
|
138
|
+
return text;
|
|
139
|
+
};
|
|
75
140
|
const parsed = new Map<string, SourceFacts>();
|
|
76
141
|
const factsOf = (file: string): SourceFacts => {
|
|
77
142
|
const cached = parsed.get(file);
|
|
78
143
|
if (cached !== undefined) return cached;
|
|
79
|
-
const facts =
|
|
144
|
+
const facts = policy.extractor.factsOf(file, textOf(file));
|
|
80
145
|
parsed.set(file, facts);
|
|
81
146
|
return facts;
|
|
82
147
|
};
|
|
83
148
|
|
|
84
149
|
// The graph is the whole repository resolved at once — the one question no
|
|
85
150
|
// per-file adapter can ask — and is built only when a rule needs it.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
policy.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
151
|
+
const graph =
|
|
152
|
+
options.graph === true || hasGraphRules(policy.graph)
|
|
153
|
+
? buildGraph(files, policy.resolver, factsOf)
|
|
154
|
+
: null;
|
|
155
|
+
if (graph !== null && hasGraphRules(policy.graph)) {
|
|
156
|
+
for (const violation of evaluateGraph(policy.graph, graph)) violations.push(violation);
|
|
93
157
|
}
|
|
94
158
|
|
|
159
|
+
// The campaigns, over every walked file, through the same caches.
|
|
160
|
+
const campaigns = policy.campaigns.evaluate(policy, roots, walked, { textOf, factsOf });
|
|
161
|
+
|
|
95
162
|
for (const file of files) {
|
|
96
163
|
for (const violation of evaluateStructure(policy.structure, policy.fileSystem, file)) {
|
|
97
164
|
violations.push(violation);
|
|
@@ -124,14 +191,21 @@ export const collectFindings = (policy: LoadedPolicy, roots: ReadonlyArray<strin
|
|
|
124
191
|
for (const specifier of facts.specifiers) {
|
|
125
192
|
const edge = { importer: file, specifier };
|
|
126
193
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
194
|
+
// A file no import rule selects never needs its imports resolved, which
|
|
195
|
+
// is what keeps resolution off the hot path for the bulk of the repo.
|
|
196
|
+
if (selectedImports.length > 0) {
|
|
197
|
+
const resolved = policy.resolver.resolve(file, specifier);
|
|
198
|
+
if (Result.isFailure(resolved)) {
|
|
199
|
+
if (policy.config.resolve.unresolved === "off") continue;
|
|
200
|
+
if (policy.ignoreUnresolved.some((pattern) => pattern.test(specifier))) continue;
|
|
201
|
+
unresolved.push({ file, specifier, detail: resolved.failure.detail });
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
edges.push({ importer: file, target: resolved.success });
|
|
205
|
+
for (const violation of evaluateResolvedEdge(selectedImports, file, resolved.success)) {
|
|
206
|
+
violations.push(violation);
|
|
207
|
+
}
|
|
133
208
|
}
|
|
134
|
-
for (const violation of imported.success) violations.push(violation);
|
|
135
209
|
|
|
136
210
|
const bound = facts.bindings.get(specifier) ?? [];
|
|
137
211
|
const exported = evaluateSelectedBindings(selectedExports, policy.resolver, {
|
|
@@ -144,7 +218,7 @@ export const collectFindings = (policy: LoadedPolicy, roots: ReadonlyArray<strin
|
|
|
144
218
|
}
|
|
145
219
|
}
|
|
146
220
|
|
|
147
|
-
return { violations, unresolved, files: files.length };
|
|
221
|
+
return { violations, campaigns, unresolved, files: files.length, edges, graph };
|
|
148
222
|
};
|
|
149
223
|
|
|
150
224
|
const baselinePathOf = (policy: LoadedPolicy): string | null =>
|
|
@@ -162,76 +236,634 @@ const readBaseline = (policy: LoadedPolicy): Baseline => {
|
|
|
162
236
|
}
|
|
163
237
|
};
|
|
164
238
|
|
|
165
|
-
const report = (lines: ReadonlyArray<string>): Effect.Effect<void> =>
|
|
166
|
-
Effect.sync(() => {
|
|
167
|
-
for (const line of lines) process.stdout.write(`${line}\n`);
|
|
168
|
-
});
|
|
169
|
-
|
|
170
239
|
const describe = (violation: Violation): string =>
|
|
171
240
|
` ${violation.file}\n ${formatMessage(violation)}`;
|
|
172
241
|
|
|
242
|
+
// Everything `check` has to say, as one value: the two renderers below read
|
|
243
|
+
// it, and nothing else computes a finding. `version` is here so a document
|
|
244
|
+
// that grows this shape (a conformance snapshot) can say which one it grew.
|
|
245
|
+
export type ReportedViolation = Violation & {
|
|
246
|
+
readonly fingerprint: string;
|
|
247
|
+
readonly baselined: boolean;
|
|
248
|
+
// For a campaign hit: carried by the objective's ledger, so `check` does
|
|
249
|
+
// not fail on it. The campaign analogue of `baselined`.
|
|
250
|
+
readonly ledgered: boolean;
|
|
251
|
+
// For a campaign hit: which objective, in which sector, and the ledger
|
|
252
|
+
// entry it is keyed by there.
|
|
253
|
+
readonly objective?: string;
|
|
254
|
+
readonly sector?: string;
|
|
255
|
+
readonly entry?: string;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// Per family: the files it reaches of those the limits count, the floor the
|
|
259
|
+
// manifest's `limits.coverage` states as a fraction, and the ceiling its
|
|
260
|
+
// `limits.unreached` states on the files left over.
|
|
261
|
+
export type CoverageReport = Readonly<
|
|
262
|
+
Record<
|
|
263
|
+
CoverageFamily,
|
|
264
|
+
{
|
|
265
|
+
readonly covered: number;
|
|
266
|
+
readonly total: number;
|
|
267
|
+
readonly floor?: number;
|
|
268
|
+
readonly ceiling?: number;
|
|
269
|
+
}
|
|
270
|
+
>
|
|
271
|
+
>;
|
|
272
|
+
|
|
273
|
+
// The conformance measures as counts, each beside the ceiling the manifest's
|
|
274
|
+
// `limits.conformance` states for it. `conformance` names what each counts;
|
|
275
|
+
// `check` holds the counts to the ceilings.
|
|
276
|
+
export type ConformanceReport = Readonly<
|
|
277
|
+
Record<ConformanceMeasure, { readonly count: number; readonly ceiling?: number }>
|
|
278
|
+
>;
|
|
279
|
+
|
|
280
|
+
export type CheckReport = {
|
|
281
|
+
readonly version: 1;
|
|
282
|
+
readonly files: number;
|
|
283
|
+
// Walked files under the manifest's `limits.outside`: judged by every
|
|
284
|
+
// rule, and left out of `coverage` and of the residue.
|
|
285
|
+
readonly outside: number;
|
|
286
|
+
readonly roots: ReadonlyArray<string>;
|
|
287
|
+
readonly ok: boolean;
|
|
288
|
+
// The file the policy was read from, repo-relative, and a hash of its
|
|
289
|
+
// bytes — the root file only, when the manifest is split with `include`.
|
|
290
|
+
readonly manifest: { readonly path: string; readonly sha256: string };
|
|
291
|
+
// Every finding, baselined ones included; `baselined` says which.
|
|
292
|
+
readonly violations: ReadonlyArray<ReportedViolation>;
|
|
293
|
+
readonly unresolved: ReadonlyArray<UnresolvedEdge>;
|
|
294
|
+
// Baseline entries the code no longer produces.
|
|
295
|
+
readonly stale: ReadonlyArray<string>;
|
|
296
|
+
readonly coverage: CoverageReport;
|
|
297
|
+
readonly conformance: ConformanceReport;
|
|
298
|
+
readonly adoption: {
|
|
299
|
+
readonly unrestricted: ReadonlyArray<string>;
|
|
300
|
+
readonly partial: ReadonlyArray<string>;
|
|
301
|
+
};
|
|
302
|
+
readonly campaigns: ReadonlyArray<CampaignReport>;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const COVERAGE_FAMILIES: ReadonlyArray<CoverageFamily> = [
|
|
306
|
+
"imports",
|
|
307
|
+
"structure",
|
|
308
|
+
"members",
|
|
309
|
+
"surface",
|
|
310
|
+
"graph",
|
|
311
|
+
];
|
|
312
|
+
|
|
313
|
+
const sha256Of = (file: string): string => {
|
|
314
|
+
try {
|
|
315
|
+
return createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
316
|
+
} catch {
|
|
317
|
+
return "";
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
export const checkReport = (
|
|
322
|
+
policy: LoadedPolicy,
|
|
323
|
+
roots: ReadonlyArray<string>,
|
|
324
|
+
manifestPath: string,
|
|
325
|
+
): CheckReport => reportOf(policy, roots, manifestPath, collectFindings(policy, roots)).report;
|
|
326
|
+
|
|
327
|
+
// The four conformance measures, as `conformance` names them: what no
|
|
328
|
+
// family reaches, what no file is under, what nothing imports through, and
|
|
329
|
+
// the fragment entries concentrated at fewer than half the nodes granted.
|
|
330
|
+
type Measures = {
|
|
331
|
+
readonly residue: ReturnType<typeof residueOf>;
|
|
332
|
+
readonly vacant: ReturnType<typeof vacancyOf>;
|
|
333
|
+
readonly slack: ReturnType<typeof slackOf>["slack"];
|
|
334
|
+
readonly concentration: ReturnType<typeof slackOf>["concentration"];
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
// The patterns the policy's `limits.outside` names, alias-expanded, each a
|
|
338
|
+
// subtree.
|
|
339
|
+
const outsideOf = (policy: LoadedPolicy): ReadonlyArray<RegExp> =>
|
|
340
|
+
globsOf(policy.config.limits?.outside ?? []).map((glob) =>
|
|
341
|
+
globToRegExp(expandAliases(glob, policy.config.aliases ?? {})),
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const measuresOf = (
|
|
345
|
+
policy: LoadedPolicy,
|
|
346
|
+
files: ReadonlyArray<string>,
|
|
347
|
+
edges: ReadonlyArray<ObservedEdge>,
|
|
348
|
+
): Measures => {
|
|
349
|
+
// Slack is measured over the walked files as well as the edges: an
|
|
350
|
+
// allowlist that selects no file is vacant, and its entries are reported as
|
|
351
|
+
// that rather than as lines nobody needs.
|
|
352
|
+
const { concentration, slack } = slackOf(policy.importRules, edges, files);
|
|
353
|
+
return {
|
|
354
|
+
// Residue is a count of files, and counts the ones the limits count. A
|
|
355
|
+
// node is live or vacant by every file under it, counted or not.
|
|
356
|
+
residue: residueOf(policy, countedFiles(files, outsideOf(policy))),
|
|
357
|
+
vacant: vacancyOf(policy.importRules, files),
|
|
358
|
+
slack,
|
|
359
|
+
concentration,
|
|
360
|
+
};
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
// A fragment entry is concentrated when it is used at fewer than half the
|
|
364
|
+
// nodes granted it — the text report's threshold, and the ceiling's.
|
|
365
|
+
const isConcentrated = (one: { readonly usedAt: number; readonly of: number }): boolean =>
|
|
366
|
+
one.usedAt * 2 < one.of;
|
|
367
|
+
|
|
368
|
+
const countsOf = (measures: Measures): Readonly<Record<ConformanceMeasure, number>> => ({
|
|
369
|
+
residue: measures.residue.files.length,
|
|
370
|
+
vacant: measures.vacant.length,
|
|
371
|
+
slack: measures.slack.length,
|
|
372
|
+
concentration: measures.concentration.filter(isConcentrated).length,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const reportOf = (
|
|
376
|
+
policy: LoadedPolicy,
|
|
377
|
+
roots: ReadonlyArray<string>,
|
|
378
|
+
manifestPath: string,
|
|
379
|
+
findings: Findings,
|
|
380
|
+
): {
|
|
381
|
+
readonly report: CheckReport;
|
|
382
|
+
readonly measures: Measures;
|
|
383
|
+
readonly files: ReadonlyArray<string>;
|
|
384
|
+
} => {
|
|
385
|
+
const baseline = readBaseline(policy);
|
|
386
|
+
const stale = staleEntriesOf(baseline, findings.violations);
|
|
387
|
+
const { isBaselined } = makeBaselineFilter(baseline);
|
|
388
|
+
const violations: Array<ReportedViolation> = [
|
|
389
|
+
...findings.violations.map((violation) => ({
|
|
390
|
+
...violation,
|
|
391
|
+
fingerprint: fingerprintOf(violation),
|
|
392
|
+
baselined: isBaselined(violation),
|
|
393
|
+
ledgered: false,
|
|
394
|
+
})),
|
|
395
|
+
// The hits that count: those whose objective is in window for the
|
|
396
|
+
// sector they fall in.
|
|
397
|
+
...policy.campaigns.hitsOf(policy, findings.campaigns).map((hit) => ({
|
|
398
|
+
...hit.violation,
|
|
399
|
+
fingerprint: fingerprintOf(hit.violation),
|
|
400
|
+
baselined: false,
|
|
401
|
+
ledgered: hit.ledgered,
|
|
402
|
+
objective: hit.objective,
|
|
403
|
+
sector: hit.sector,
|
|
404
|
+
entry: hit.entry,
|
|
405
|
+
})),
|
|
406
|
+
];
|
|
407
|
+
const campaigns = policy.campaigns.reportsOf(policy, findings.campaigns);
|
|
408
|
+
|
|
409
|
+
// The floors. A policy states how much of the tree it reaches, per
|
|
410
|
+
// family; falling under is a policy that quietly stopped covering files.
|
|
411
|
+
// Both are held over the files the limits count: what `limits.outside`
|
|
412
|
+
// names is judged by every rule and left out of these numbers.
|
|
413
|
+
const floors = policy.config.limits?.coverage ?? {};
|
|
414
|
+
const unreached = policy.config.limits?.unreached ?? {};
|
|
415
|
+
const files = listSourceFiles(policy.repoRoot, roots, policy.languages);
|
|
416
|
+
const counted = countedFiles(files, outsideOf(policy));
|
|
417
|
+
const found = coverageOf(policy, counted);
|
|
418
|
+
const covered = (family: CoverageFamily): number =>
|
|
419
|
+
family === "structure" ? found.structure.enumerated : found[family].covered;
|
|
420
|
+
const coverage = Object.fromEntries(
|
|
421
|
+
COVERAGE_FAMILIES.map((family) => {
|
|
422
|
+
const floor = floors[family];
|
|
423
|
+
const ceiling = unreached[family];
|
|
424
|
+
return [
|
|
425
|
+
family,
|
|
426
|
+
{
|
|
427
|
+
covered: covered(family),
|
|
428
|
+
total: found.files,
|
|
429
|
+
...(floor === undefined ? {} : { floor }),
|
|
430
|
+
...(ceiling === undefined ? {} : { ceiling }),
|
|
431
|
+
},
|
|
432
|
+
];
|
|
433
|
+
}),
|
|
434
|
+
) as CoverageReport;
|
|
435
|
+
const shortfalls = shortfallsOf(coverage);
|
|
436
|
+
const overruns = overrunsOf(coverage);
|
|
437
|
+
|
|
438
|
+
// The ceilings. What no family reaches, what no file is under and what
|
|
439
|
+
// nothing imports through are each a count the policy may hold itself
|
|
440
|
+
// to; rising over one is a manifest that quietly widened.
|
|
441
|
+
const ceilings = policy.config.limits?.conformance ?? {};
|
|
442
|
+
const measures = measuresOf(policy, files, findings.edges);
|
|
443
|
+
const counts = countsOf(measures);
|
|
444
|
+
const conformance = Object.fromEntries(
|
|
445
|
+
CONFORMANCE_MEASURES.map((measure) => {
|
|
446
|
+
const ceiling = ceilings[measure];
|
|
447
|
+
return [measure, { count: counts[measure], ...(ceiling === undefined ? {} : { ceiling }) }];
|
|
448
|
+
}),
|
|
449
|
+
) as ConformanceReport;
|
|
450
|
+
const excesses = excessesOf(conformance);
|
|
451
|
+
|
|
452
|
+
const reportable = violations.filter((one) => !one.baselined && !one.ledgered).length;
|
|
453
|
+
const report: CheckReport = {
|
|
454
|
+
version: 1,
|
|
455
|
+
files: findings.files,
|
|
456
|
+
outside: files.length - counted.length,
|
|
457
|
+
roots,
|
|
458
|
+
ok:
|
|
459
|
+
reportable === 0 &&
|
|
460
|
+
findings.unresolved.length === 0 &&
|
|
461
|
+
stale.length === 0 &&
|
|
462
|
+
shortfalls.length === 0 &&
|
|
463
|
+
overruns.length === 0 &&
|
|
464
|
+
excesses.length === 0 &&
|
|
465
|
+
policy.campaigns.failuresOf(campaigns).length === 0,
|
|
466
|
+
manifest: {
|
|
467
|
+
path: path.relative(policy.repoRoot, manifestPath).replaceAll(path.sep, "/"),
|
|
468
|
+
sha256: sha256Of(manifestPath),
|
|
469
|
+
},
|
|
470
|
+
violations,
|
|
471
|
+
unresolved: findings.unresolved,
|
|
472
|
+
stale,
|
|
473
|
+
coverage,
|
|
474
|
+
conformance,
|
|
475
|
+
adoption: {
|
|
476
|
+
unrestricted: policy.adoption.unrestricted,
|
|
477
|
+
partial: policy.adoption.partial,
|
|
478
|
+
},
|
|
479
|
+
campaigns,
|
|
480
|
+
};
|
|
481
|
+
return { report, measures, files };
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
// Why a report is not `ok`, in the order the text renderer explains it: a
|
|
485
|
+
// stale baseline first, since nothing else is trustworthy until the file
|
|
486
|
+
// describes something real.
|
|
487
|
+
type Shortfall = {
|
|
488
|
+
readonly family: CoverageFamily;
|
|
489
|
+
readonly actual: number;
|
|
490
|
+
readonly floor: number;
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const shortfallsOf = (coverage: CoverageReport): ReadonlyArray<Shortfall> =>
|
|
494
|
+
COVERAGE_FAMILIES.flatMap((family) => {
|
|
495
|
+
const { covered, floor, total } = coverage[family];
|
|
496
|
+
const actual = total === 0 ? 1 : covered / total;
|
|
497
|
+
return floor === undefined || !breaches({ direction: "up", limit: floor, tolerance: 0 }, actual)
|
|
498
|
+
? []
|
|
499
|
+
: [{ family, actual, floor }];
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// A family leaving more files unreached than the ceiling the policy states
|
|
503
|
+
// for it. A count, so deleting files a rule reached changes nothing here,
|
|
504
|
+
// where it lowers the fraction a floor holds.
|
|
505
|
+
type Overrun = {
|
|
506
|
+
readonly family: CoverageFamily;
|
|
507
|
+
readonly unreached: number;
|
|
508
|
+
readonly ceiling: number;
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const overrunsOf = (coverage: CoverageReport): ReadonlyArray<Overrun> =>
|
|
512
|
+
COVERAGE_FAMILIES.flatMap((family) => {
|
|
513
|
+
const { ceiling, covered, total } = coverage[family];
|
|
514
|
+
const unreached = total - covered;
|
|
515
|
+
return ceiling === undefined ||
|
|
516
|
+
!breaches({ direction: "down", limit: ceiling, tolerance: 0 }, unreached)
|
|
517
|
+
? []
|
|
518
|
+
: [{ family, unreached, ceiling }];
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// A conformance measure over the ceiling the policy states for it.
|
|
522
|
+
type Excess = {
|
|
523
|
+
readonly measure: ConformanceMeasure;
|
|
524
|
+
readonly count: number;
|
|
525
|
+
readonly ceiling: number;
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
const excessesOf = (conformance: ConformanceReport): ReadonlyArray<Excess> =>
|
|
529
|
+
CONFORMANCE_MEASURES.flatMap((measure) => {
|
|
530
|
+
const { ceiling, count } = conformance[measure];
|
|
531
|
+
return ceiling === undefined ||
|
|
532
|
+
!breaches({ direction: "down", limit: ceiling, tolerance: 0 }, count)
|
|
533
|
+
? []
|
|
534
|
+
: [{ measure, count, ceiling }];
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
const failureOf = (
|
|
538
|
+
policy: LoadedPolicy,
|
|
539
|
+
report: CheckReport,
|
|
540
|
+
shortfalls: ReadonlyArray<Shortfall>,
|
|
541
|
+
): CliFailure | null => {
|
|
542
|
+
if (report.stale.length > 0) return fail("stale baseline entries");
|
|
543
|
+
const [campaignFailure] = policy.campaigns.failuresOf(report.campaigns);
|
|
544
|
+
if (campaignFailure !== undefined) return fail(campaignFailure);
|
|
545
|
+
if (shortfalls.length > 0) return fail("coverage below floor");
|
|
546
|
+
if (overrunsOf(report.coverage).length > 0) return fail("unreached above ceiling");
|
|
547
|
+
if (excessesOf(report.conformance).length > 0) return fail("conformance above ceiling");
|
|
548
|
+
if (report.ok) return null;
|
|
549
|
+
return fail("architecture violations");
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
// What each measure counts, as the failure names it.
|
|
553
|
+
const MEASURE_NOUNS: Readonly<Record<ConformanceMeasure, readonly [string, string]>> = {
|
|
554
|
+
residue: ["file no family reaches", "files no family reaches"],
|
|
555
|
+
vacant: ["node no file is under", "nodes no file is under"],
|
|
556
|
+
slack: ["allowance nothing imports through", "allowances nothing imports through"],
|
|
557
|
+
concentration: [
|
|
558
|
+
"allowance used at fewer than half the nodes granted",
|
|
559
|
+
"allowances used at fewer than half the nodes granted",
|
|
560
|
+
],
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
const describeExcess = (one: Excess): string => {
|
|
564
|
+
const [singular, plural] = MEASURE_NOUNS[one.measure];
|
|
565
|
+
return ` ${one.measure}: ${count(one.count, singular, plural)}, ceiling ${String(one.ceiling)}`;
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const renderCampaigns = (policy: LoadedPolicy, report: CheckReport): ReadonlyArray<string> =>
|
|
569
|
+
policy.campaigns.renderReports(
|
|
570
|
+
report.campaigns,
|
|
571
|
+
report.violations.flatMap((one) =>
|
|
572
|
+
one.kind === "campaign" &&
|
|
573
|
+
one.objective !== undefined &&
|
|
574
|
+
one.sector !== undefined &&
|
|
575
|
+
one.entry !== undefined
|
|
576
|
+
? [
|
|
577
|
+
{
|
|
578
|
+
violation: one,
|
|
579
|
+
objective: one.objective,
|
|
580
|
+
sector: one.sector,
|
|
581
|
+
entry: one.entry,
|
|
582
|
+
ledgered: one.ledgered,
|
|
583
|
+
},
|
|
584
|
+
]
|
|
585
|
+
: [],
|
|
586
|
+
),
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
const renderText = (policy: LoadedPolicy, report: CheckReport): ReadonlyArray<string> => {
|
|
590
|
+
const reportable = report.violations.filter((one) => one.kind !== "campaign" && !one.baselined);
|
|
591
|
+
const carried =
|
|
592
|
+
report.violations.filter((one) => one.kind !== "campaign").length - reportable.length;
|
|
593
|
+
const shortfalls = shortfallsOf(report.coverage);
|
|
594
|
+
const overruns = overrunsOf(report.coverage);
|
|
595
|
+
const excesses = excessesOf(report.conformance);
|
|
596
|
+
return [
|
|
597
|
+
...reportable.map(describe),
|
|
598
|
+
...report.unresolved.map(
|
|
599
|
+
(one) => ` unresolved: ${one.file} → ${one.specifier} (${one.detail})`,
|
|
600
|
+
),
|
|
601
|
+
"",
|
|
602
|
+
`${String(report.files)} files, ${String(reportable.length)} violations` +
|
|
603
|
+
(carried > 0 ? `, ${String(carried)} carried by the baseline` : ""),
|
|
604
|
+
// The ratchet: a fixed violation must leave the baseline, or the floor
|
|
605
|
+
// never rises and the file stops describing anything real.
|
|
606
|
+
...(report.stale.length === 0
|
|
607
|
+
? []
|
|
608
|
+
: [
|
|
609
|
+
"",
|
|
610
|
+
`${String(report.stale.length)} baseline entries no longer fire. The code was fixed; prune them:`,
|
|
611
|
+
...report.stale.map((entry) => ` ${entry}`),
|
|
612
|
+
"",
|
|
613
|
+
" architecture baseline # rewrites the file from what still fires",
|
|
614
|
+
]),
|
|
615
|
+
...(shortfalls.length === 0
|
|
616
|
+
? []
|
|
617
|
+
: [
|
|
618
|
+
"",
|
|
619
|
+
"coverage is below the floor the policy states for itself:",
|
|
620
|
+
...shortfalls.map(
|
|
621
|
+
(one) => ` ${one.family}: ${percent(one.actual)} covered, floor ${percent(one.floor)}`,
|
|
622
|
+
),
|
|
623
|
+
"",
|
|
624
|
+
" architecture coverage # which files no rule reaches",
|
|
625
|
+
]),
|
|
626
|
+
...(overruns.length === 0
|
|
627
|
+
? []
|
|
628
|
+
: [
|
|
629
|
+
"",
|
|
630
|
+
"more files are unreached than the ceiling the policy states for itself:",
|
|
631
|
+
...overruns.map(
|
|
632
|
+
(one) =>
|
|
633
|
+
` ${one.family}: ${count(one.unreached, "file")} unreached, ceiling ${String(one.ceiling)}`,
|
|
634
|
+
),
|
|
635
|
+
"",
|
|
636
|
+
" architecture coverage # which files no rule reaches",
|
|
637
|
+
]),
|
|
638
|
+
...(excesses.length === 0
|
|
639
|
+
? []
|
|
640
|
+
: [
|
|
641
|
+
"",
|
|
642
|
+
"conformance is above the ceiling the policy states for itself:",
|
|
643
|
+
...excesses.map(describeExcess),
|
|
644
|
+
"",
|
|
645
|
+
" architecture conformance # which files, nodes and allowances",
|
|
646
|
+
]),
|
|
647
|
+
...renderCampaigns(policy, report),
|
|
648
|
+
];
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
export type CheckOptions = {
|
|
652
|
+
readonly format: "text" | "json";
|
|
653
|
+
readonly manifestPath: string;
|
|
654
|
+
};
|
|
655
|
+
|
|
173
656
|
export const check = (
|
|
174
657
|
policy: LoadedPolicy,
|
|
175
658
|
roots: ReadonlyArray<string>,
|
|
659
|
+
options: CheckOptions,
|
|
176
660
|
): Effect.Effect<void, CliFailure> =>
|
|
177
661
|
Effect.gen(function* () {
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
662
|
+
const report_ = checkReport(policy, roots, options.manifestPath);
|
|
663
|
+
// JSON is one object on stdout and nothing else there; the failure, when
|
|
664
|
+
// there is one, is a sentence on stderr and the exit code, as in text.
|
|
665
|
+
yield* report(
|
|
666
|
+
options.format === "json" ? [JSON.stringify(report_, null, 2)] : renderText(policy, report_),
|
|
667
|
+
);
|
|
668
|
+
const failure = failureOf(policy, report_, shortfallsOf(report_.coverage));
|
|
669
|
+
if (failure !== null) return yield* Effect.fail(failure);
|
|
670
|
+
});
|
|
182
671
|
|
|
183
|
-
|
|
184
|
-
yield* report(findings.unresolved.map((one) => ` unresolved: ${one}`));
|
|
672
|
+
const percent = (fraction: number): string => `${String(Math.floor(fraction * 100))}%`;
|
|
185
673
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
674
|
+
// The conformance snapshot: `check`'s report grown with what no family
|
|
675
|
+
// reaches, what the allowlists permit and nothing uses, the cycle count and
|
|
676
|
+
// the size of the debt — the whole distance between the tree and the
|
|
677
|
+
// manifest, as one document another run can be compared against. Its shape
|
|
678
|
+
// is the core's `Snapshot`, and the schema published beside the manifest's.
|
|
679
|
+
export const snapshotOf = (
|
|
680
|
+
policy: LoadedPolicy,
|
|
681
|
+
roots: ReadonlyArray<string>,
|
|
682
|
+
manifestPath: string,
|
|
683
|
+
): Snapshot => {
|
|
684
|
+
const findings = collectFindings(policy, roots, { graph: true });
|
|
685
|
+
const { files, measures, report: report_ } = reportOf(policy, roots, manifestPath, findings);
|
|
686
|
+
const graph = findings.graph ?? { files, edges: new Map() };
|
|
687
|
+
const heights = heightOf(graph);
|
|
192
688
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
689
|
+
// Leaf edges first. A violation names a target when it is about an edge;
|
|
690
|
+
// the cost of fixing it is roughly how much of the graph stands beneath
|
|
691
|
+
// that target, so the ones nearest the ground come first and a reader
|
|
692
|
+
// starting at the top of the list is starting where a fix stays local.
|
|
693
|
+
// Ties keep the fingerprint order, so the list is the same on every run.
|
|
694
|
+
const heightOfViolation = (one: ReportedViolation): number =>
|
|
695
|
+
heights.get(one.subject ?? "") ?? heights.get(one.file) ?? 0;
|
|
696
|
+
const violations = [...report_.violations].sort((left, right) => {
|
|
697
|
+
const byHeight = heightOfViolation(left) - heightOfViolation(right);
|
|
698
|
+
return byHeight !== 0 ? byHeight : left.fingerprint.localeCompare(right.fingerprint);
|
|
699
|
+
});
|
|
205
700
|
|
|
206
|
-
|
|
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
|
-
}
|
|
701
|
+
const campaigns = policy.campaigns.snapshotOf(policy, findings.campaigns);
|
|
228
702
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
703
|
+
return {
|
|
704
|
+
version: SNAPSHOT_VERSION,
|
|
705
|
+
manifest: report_.manifest,
|
|
706
|
+
roots: report_.roots,
|
|
707
|
+
files: report_.files,
|
|
708
|
+
outside: report_.outside,
|
|
709
|
+
ok: report_.ok,
|
|
710
|
+
coverage: report_.coverage,
|
|
711
|
+
conformance: report_.conformance,
|
|
712
|
+
residue: measures.residue,
|
|
713
|
+
vacant: measures.vacant,
|
|
714
|
+
violations,
|
|
715
|
+
unresolved: report_.unresolved,
|
|
716
|
+
stale: report_.stale,
|
|
717
|
+
baseline: { size: readBaseline(policy).entries.length },
|
|
718
|
+
cycles: cyclesIn(graph).length,
|
|
719
|
+
slack: measures.slack,
|
|
720
|
+
concentration: measures.concentration,
|
|
721
|
+
adoption: report_.adoption,
|
|
722
|
+
campaigns,
|
|
723
|
+
};
|
|
724
|
+
};
|
|
233
725
|
|
|
234
|
-
|
|
726
|
+
// The unreached ceiling beside a family's row, and the ratchet's nudge when
|
|
727
|
+
// the count has fallen under it: a ceiling is lowered by hand.
|
|
728
|
+
const unreachedMark = (covered: number, total: number, ceiling: number | undefined): string => {
|
|
729
|
+
if (ceiling === undefined) return "";
|
|
730
|
+
const left = total - covered;
|
|
731
|
+
if (left > ceiling) return ` ${String(left)} unreached > ${String(ceiling)} ✗`;
|
|
732
|
+
return ` ${String(left)} unreached ≤ ${String(ceiling)} ✓${left < ceiling ? `, lower it to ${String(left)}` : ""}`;
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
const outsideNote = (outside: number): string =>
|
|
736
|
+
outside === 0 ? "" : `, ${count(outside, "file")} outside the limits`;
|
|
737
|
+
|
|
738
|
+
const renderSnapshot = (policy: LoadedPolicy, snapshot: Snapshot): ReadonlyArray<string> => {
|
|
739
|
+
const reportable = snapshot.violations.filter((one) => !one.baselined && !one.ledgered);
|
|
740
|
+
const carried = snapshot.violations.length - reportable.length;
|
|
741
|
+
const row = (family: CoverageFamily): string => {
|
|
742
|
+
const { ceiling, covered, floor, total } = snapshot.coverage[family];
|
|
743
|
+
const fraction = total === 0 ? 1 : covered / total;
|
|
744
|
+
const mark =
|
|
745
|
+
floor === undefined
|
|
746
|
+
? ""
|
|
747
|
+
: fraction >= floor
|
|
748
|
+
? ` ≥ ${percent(floor)} ✓`
|
|
749
|
+
: ` < ${percent(floor)} ✗`;
|
|
750
|
+
return ` ${family.padEnd(10)} ${String(covered).padStart(5)}/${String(total)} ${percent(fraction).padStart(4)}${mark}${unreachedMark(covered, total, ceiling)}`;
|
|
751
|
+
};
|
|
752
|
+
const section = (title: string, lines: ReadonlyArray<string>): ReadonlyArray<string> => [
|
|
753
|
+
"",
|
|
754
|
+
title,
|
|
755
|
+
...lines,
|
|
756
|
+
];
|
|
757
|
+
const vacantWidth = Math.max(0, ...snapshot.vacant.map((one) => one.node.length));
|
|
758
|
+
// The document carries every partly-used fragment entry; the text shows
|
|
759
|
+
// the ones concentrated enough to read as a per-file rule written wide.
|
|
760
|
+
const concentrated = snapshot.concentration.filter(isConcentrated);
|
|
761
|
+
// The ceiling beside each measure that has one, and the ratchet's nudge
|
|
762
|
+
// when the count has fallen under it: a ceiling is lowered by hand.
|
|
763
|
+
const ceilingMark = (measure: ConformanceMeasure): string => {
|
|
764
|
+
const { ceiling, count: actual } = snapshot.conformance[measure];
|
|
765
|
+
if (ceiling === undefined) return "";
|
|
766
|
+
if (actual > ceiling) return ` > ${String(ceiling)} ✗`;
|
|
767
|
+
return ` ≤ ${String(ceiling)} ✓${actual < ceiling ? `, lower it to ${String(actual)}` : ""}`;
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
return [
|
|
771
|
+
`${String(snapshot.files)} files under ${snapshot.roots.join(", ")}, against ${snapshot.manifest.path}${outsideNote(snapshot.outside)}`,
|
|
772
|
+
...section("coverage", COVERAGE_FAMILIES.map(row)),
|
|
773
|
+
...section(
|
|
774
|
+
`residue: ${count(snapshot.residue.files.length, "file")} no family reaches` +
|
|
775
|
+
(snapshot.residue.folders.length === 0
|
|
776
|
+
? ""
|
|
777
|
+
: `, ${count(snapshot.residue.folders.length, "folder")} wholly`) +
|
|
778
|
+
ceilingMark("residue"),
|
|
779
|
+
[
|
|
780
|
+
...snapshot.residue.folders.map((folder) => ` ${folder}/`),
|
|
781
|
+
...snapshot.residue.files
|
|
782
|
+
.filter(
|
|
783
|
+
(file) => !snapshot.residue.folders.some((folder) => file.startsWith(`${folder}/`)),
|
|
784
|
+
)
|
|
785
|
+
.map((file) => ` ${file}`),
|
|
786
|
+
],
|
|
787
|
+
),
|
|
788
|
+
...section(
|
|
789
|
+
`vacant: ${count(snapshot.vacant.length, "node")} ${snapshot.vacant.length === 1 ? "selects" : "select"} no file` +
|
|
790
|
+
ceilingMark("vacant"),
|
|
791
|
+
snapshot.vacant.map(
|
|
792
|
+
(one) => ` ${one.node.padEnd(vacantWidth)} ${count(one.allowances, "allowance")}`,
|
|
793
|
+
),
|
|
794
|
+
),
|
|
795
|
+
...section(
|
|
796
|
+
`violations: ${count(reportable.length, "reportable")}` +
|
|
797
|
+
(carried > 0 ? `, ${String(carried)} carried by the baseline or a ledger` : "") +
|
|
798
|
+
(snapshot.stale.length > 0
|
|
799
|
+
? `, ${count(snapshot.stale.length, "stale entry", "stale entries")}`
|
|
800
|
+
: "") +
|
|
801
|
+
(reportable.length > 0 ? " — nearest the ground first" : ""),
|
|
802
|
+
reportable.map(describe),
|
|
803
|
+
),
|
|
804
|
+
...(snapshot.unresolved.length === 0
|
|
805
|
+
? []
|
|
806
|
+
: section(
|
|
807
|
+
`unresolved: ${count(snapshot.unresolved.length, "import")} no rule can police`,
|
|
808
|
+
snapshot.unresolved.map((one) => ` ${one.file} → ${one.specifier} (${one.detail})`),
|
|
809
|
+
)),
|
|
810
|
+
...section(
|
|
811
|
+
`slack: ${count(snapshot.slack.length, "allowance")} nothing imports through` +
|
|
812
|
+
ceilingMark("slack"),
|
|
813
|
+
snapshot.slack.map(
|
|
814
|
+
(one) =>
|
|
815
|
+
` ${one.node}: ${one.kind} ${JSON.stringify(one.entry)}` +
|
|
816
|
+
(one.of === undefined ? "" : ` (via use, at ${count(one.of, "node")})`),
|
|
817
|
+
),
|
|
818
|
+
),
|
|
819
|
+
...(concentrated.length === 0 && snapshot.conformance.concentration.ceiling === undefined
|
|
820
|
+
? []
|
|
821
|
+
: section(
|
|
822
|
+
`concentrated: ${count(concentrated.length, "allowance")} used at fewer than half the nodes granted` +
|
|
823
|
+
ceilingMark("concentration"),
|
|
824
|
+
concentrated.map(
|
|
825
|
+
(one) =>
|
|
826
|
+
` ${one.fragment}: ${one.kind} ${JSON.stringify(one.entry)} used at ${String(one.usedAt)} of ${count(one.of, "node")}`,
|
|
827
|
+
),
|
|
828
|
+
)),
|
|
829
|
+
...(snapshot.campaigns.length === 0
|
|
830
|
+
? []
|
|
831
|
+
: section(
|
|
832
|
+
`campaigns: ${count(snapshot.campaigns.length, "campaign")}` +
|
|
833
|
+
(snapshot.campaigns.some((one) => one.stalled)
|
|
834
|
+
? `, ${count(snapshot.campaigns.filter((one) => one.stalled).length, "stalled", "stalled")}`
|
|
835
|
+
: "") +
|
|
836
|
+
(snapshot.campaigns.some((one) => one.complete && one.ledgered)
|
|
837
|
+
? `, ${count(snapshot.campaigns.filter((one) => one.complete && one.ledgered).length, "complete", "complete")}`
|
|
838
|
+
: ""),
|
|
839
|
+
policy.campaigns.renderRows(snapshot.campaigns),
|
|
840
|
+
)),
|
|
841
|
+
"",
|
|
842
|
+
`cycles: ${String(snapshot.cycles)}`,
|
|
843
|
+
`baseline: ${count(snapshot.baseline.size, "entry", "entries")}`,
|
|
844
|
+
`adoption: ${count(snapshot.adoption.unrestricted.length, "unrestricted tier")}, ${count(snapshot.adoption.partial.length, "partial tier")}`,
|
|
845
|
+
];
|
|
846
|
+
};
|
|
847
|
+
|
|
848
|
+
export type ConformanceOptions = CheckOptions;
|
|
849
|
+
|
|
850
|
+
// The report of the tree against the manifest. Unlike `check`, it never
|
|
851
|
+
// fails: it is a measurement, and the manifest it measures against may be one
|
|
852
|
+
// the tree was never expected to satisfy yet — `--against` names a target.
|
|
853
|
+
// `ok` in the document says what `check` would have done.
|
|
854
|
+
export const conformance = (
|
|
855
|
+
policy: LoadedPolicy,
|
|
856
|
+
roots: ReadonlyArray<string>,
|
|
857
|
+
options: ConformanceOptions,
|
|
858
|
+
): Effect.Effect<void, CliFailure> =>
|
|
859
|
+
Effect.gen(function* () {
|
|
860
|
+
const snapshot = snapshotOf(policy, roots, options.manifestPath);
|
|
861
|
+
yield* report(
|
|
862
|
+
options.format === "json"
|
|
863
|
+
? [JSON.stringify(snapshot, null, 2)]
|
|
864
|
+
: renderSnapshot(policy, snapshot),
|
|
865
|
+
);
|
|
866
|
+
});
|
|
235
867
|
|
|
236
868
|
// How much of the tree the policy reaches. A probe proves a rule can fire;
|
|
237
869
|
// this is whether the files are there to fire on. Reported per family, with the
|
|
@@ -241,10 +873,14 @@ export const coverage = (
|
|
|
241
873
|
roots: ReadonlyArray<string>,
|
|
242
874
|
): Effect.Effect<void, CliFailure> =>
|
|
243
875
|
Effect.gen(function* () {
|
|
244
|
-
|
|
876
|
+
// The limits are held over the files they count, so that is what the
|
|
877
|
+
// rows are over: what `limits.outside` names is said, and left out.
|
|
878
|
+
const walked = listSourceFiles(policy.repoRoot, roots, policy.languages);
|
|
879
|
+
const files = countedFiles(walked, outsideOf(policy));
|
|
245
880
|
const found = coverageOf(policy, files);
|
|
246
881
|
const fractions = fractionsOf(found);
|
|
247
882
|
const floors = policy.config.limits?.coverage ?? {};
|
|
883
|
+
const ceilings = policy.config.limits?.unreached ?? {};
|
|
248
884
|
const row = (family: keyof typeof fractions, covered: number, note: string): string => {
|
|
249
885
|
const floor = floors[family];
|
|
250
886
|
const mark =
|
|
@@ -253,11 +889,11 @@ export const coverage = (
|
|
|
253
889
|
: fractions[family] >= floor
|
|
254
890
|
? ` ≥ ${percent(floor)} ✓`
|
|
255
891
|
: ` < ${percent(floor)} ✗`;
|
|
256
|
-
return ` ${family.padEnd(10)} ${String(covered).padStart(5)}/${String(found.files)} ${percent(fractions[family]).padStart(4)} ${note}${mark}`;
|
|
892
|
+
return ` ${family.padEnd(10)} ${String(covered).padStart(5)}/${String(found.files)} ${percent(fractions[family]).padStart(4)} ${note}${mark}${unreachedMark(covered, found.files, ceilings[family])}`;
|
|
257
893
|
};
|
|
258
894
|
|
|
259
895
|
yield* report([
|
|
260
|
-
`${String(
|
|
896
|
+
`${String(walked.length)} files under ${roots.join(", ")}${outsideNote(walked.length - files.length)}`,
|
|
261
897
|
"",
|
|
262
898
|
row("imports", found.imports.covered, "under an import allowlist"),
|
|
263
899
|
row(
|
|
@@ -299,7 +935,11 @@ export const writeBaseline = (
|
|
|
299
935
|
|
|
300
936
|
// The question a tree config makes harder to answer than a flat one: given a
|
|
301
937
|
// file, what governs it? A flat config you grep; a tree you have to walk.
|
|
302
|
-
export const explain = (
|
|
938
|
+
export const explain = (
|
|
939
|
+
policy: LoadedPolicy,
|
|
940
|
+
file: string,
|
|
941
|
+
roots: ReadonlyArray<string> = ["packages"],
|
|
942
|
+
): Effect.Effect<void, CliFailure> =>
|
|
303
943
|
Effect.gen(function* () {
|
|
304
944
|
const relative = path.relative(policy.repoRoot, path.resolve(policy.repoRoot, file));
|
|
305
945
|
const selected = rulesSelecting(policy.importRules, relative);
|
|
@@ -327,7 +967,8 @@ export const explain = (policy: LoadedPolicy, file: string): Effect.Effect<void,
|
|
|
327
967
|
!rule.fileNot.some((pattern) => pattern.test(relative)),
|
|
328
968
|
);
|
|
329
969
|
|
|
330
|
-
const firstSentence = (message: string) =>
|
|
970
|
+
const firstSentence = (message: string) =>
|
|
971
|
+
`${(message.split(". ")[0] ?? message).replace(/\.$/, "")}.`;
|
|
331
972
|
const named = (rule: { readonly name: string; readonly message: string }): string =>
|
|
332
973
|
` ${rule.name} — ${firstSentence(rule.message)}`;
|
|
333
974
|
|
|
@@ -353,6 +994,14 @@ export const explain = (policy: LoadedPolicy, file: string): Effect.Effect<void,
|
|
|
353
994
|
const section = (title: string, lines: ReadonlyArray<string>): ReadonlyArray<string> =>
|
|
354
995
|
lines.length === 0 ? [] : ["", title, ...lines];
|
|
355
996
|
|
|
997
|
+
// The campaigns selecting the file, evaluated over their files only
|
|
998
|
+
// when one does: a sector's phase is derived from all of them.
|
|
999
|
+
const campaignLines = policy.campaigns.explainLines(
|
|
1000
|
+
policy,
|
|
1001
|
+
relative,
|
|
1002
|
+
() => collectFindings(policy, roots).campaigns,
|
|
1003
|
+
);
|
|
1004
|
+
|
|
356
1005
|
yield* report([
|
|
357
1006
|
relative,
|
|
358
1007
|
"",
|
|
@@ -385,6 +1034,7 @@ export const explain = (policy: LoadedPolicy, file: string): Effect.Effect<void,
|
|
|
385
1034
|
...section(" vocabulary (members):", vocabulary.map(named)),
|
|
386
1035
|
...section(" may export (surface):", surface.map(named)),
|
|
387
1036
|
...section(" graph:", graph),
|
|
1037
|
+
...section(" campaigns:", campaignLines),
|
|
388
1038
|
]);
|
|
389
1039
|
});
|
|
390
1040
|
|
|
@@ -482,12 +1132,35 @@ resolve:
|
|
|
482
1132
|
baseline: .architecture-baseline.json
|
|
483
1133
|
|
|
484
1134
|
# Ceilings on how many tiers may say "not tightened yet". At zero, raising one
|
|
485
|
-
# is a line in this file a reviewer sees.
|
|
1135
|
+
# is a line in this file a reviewer sees. The same block takes coverage floors
|
|
1136
|
+
# and ceilings on what \`architecture conformance\` measures, once there are
|
|
1137
|
+
# numbers to write.
|
|
486
1138
|
# https://dataquail.github.io/goodbones/architecture-rules/enforcement/adoption/
|
|
487
1139
|
limits:
|
|
488
1140
|
unrestricted: 0
|
|
489
1141
|
partial: 0
|
|
490
1142
|
|
|
1143
|
+
# Refactors the repository is running, each an object: objectives (a
|
|
1144
|
+
# detector with a ledger under \`.architecture-campaigns/\` of every place the
|
|
1145
|
+
# pattern still occurs), over sectors the code births through a perimeter,
|
|
1146
|
+
# through phases toward an end. Fill one in, then
|
|
1147
|
+
# \`architecture objectives clear <id>\` to write its ledgers.
|
|
1148
|
+
# https://dataquail.github.io/goodbones/architecture-rules/manifest/campaigns/
|
|
1149
|
+
# campaigns:
|
|
1150
|
+
# js-to-ts:
|
|
1151
|
+
# why: The strict tsconfig cannot land while any src file is JavaScript.
|
|
1152
|
+
# how: Rename to .ts, add types at the module boundary, leave the body alone.
|
|
1153
|
+
# scope: { path: "src/**", extensions: [.js, .jsx] }
|
|
1154
|
+
# perimeter: file
|
|
1155
|
+
# objectives:
|
|
1156
|
+
# is-ts:
|
|
1157
|
+
# intent: A JavaScript file is one the strict compiler never checks.
|
|
1158
|
+
# holdout: file
|
|
1159
|
+
# match: { path: { file: "\\\\.(js|jsx)$" } }
|
|
1160
|
+
# probes: { fires: [{ path: src/legacy/util.js }], ignores: [{ path: src/util.ts }] }
|
|
1161
|
+
# staleAfter: 14d
|
|
1162
|
+
# onComplete: remove
|
|
1163
|
+
|
|
491
1164
|
# The repository. One open root, reaching itself and the runtime; run
|
|
492
1165
|
# \`architecture check\` to see what else it reaches, and write that down here.
|
|
493
1166
|
# https://dataquail.github.io/goodbones/architecture-rules/manifest/imports/
|
|
@@ -576,6 +1249,50 @@ export const migrate = (
|
|
|
576
1249
|
]);
|
|
577
1250
|
});
|
|
578
1251
|
|
|
1252
|
+
// The campaign commands, answered by the family the host was composed with:
|
|
1253
|
+
// `campaigns` alone is the status table, `status --changed` the nudge,
|
|
1254
|
+
// `attest` and `note` write a sector's record, `history` replays the ledgers;
|
|
1255
|
+
// `objectives clear` and `objectives concede` write the ledgers. Without
|
|
1256
|
+
// `@goodbones/campaigns` installed, each says what to install.
|
|
1257
|
+
const verbContext = (
|
|
1258
|
+
policy: LoadedPolicy,
|
|
1259
|
+
defaultRoots: ReadonlyArray<string>,
|
|
1260
|
+
argv: ReadonlyArray<string>,
|
|
1261
|
+
configFilename?: string,
|
|
1262
|
+
): VerbContext => ({
|
|
1263
|
+
policy,
|
|
1264
|
+
defaultRoots,
|
|
1265
|
+
argv,
|
|
1266
|
+
evaluate: (roots) => collectFindings(policy, roots).campaigns,
|
|
1267
|
+
reload: (repoRoot) => loadPolicyFromFile(repoRoot, configFilename),
|
|
1268
|
+
manifestPath: manifestPathOf(policy.repoRoot, configFilename),
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
export const objectives = (
|
|
1272
|
+
policy: LoadedPolicy,
|
|
1273
|
+
defaultRoots: ReadonlyArray<string>,
|
|
1274
|
+
argv: ReadonlyArray<string>,
|
|
1275
|
+
): Effect.Effect<void, CliFailure> =>
|
|
1276
|
+
policy.campaigns.objectives(verbContext(policy, defaultRoots, argv));
|
|
1277
|
+
|
|
1278
|
+
export const campaigns = (
|
|
1279
|
+
policy: LoadedPolicy,
|
|
1280
|
+
defaultRoots: ReadonlyArray<string>,
|
|
1281
|
+
argv: ReadonlyArray<string>,
|
|
1282
|
+
configFilename?: string,
|
|
1283
|
+
): Effect.Effect<void, CliFailure> =>
|
|
1284
|
+
policy.campaigns.campaigns(verbContext(policy, defaultRoots, argv, configFilename));
|
|
1285
|
+
|
|
1286
|
+
// The commands that judge a campaign, and so ask its report source.
|
|
1287
|
+
const READS_REPORTS: ReadonlySet<string> = new Set([
|
|
1288
|
+
"check",
|
|
1289
|
+
"conformance",
|
|
1290
|
+
"baseline",
|
|
1291
|
+
"campaigns",
|
|
1292
|
+
"objectives",
|
|
1293
|
+
"explain",
|
|
1294
|
+
]);
|
|
1295
|
+
|
|
579
1296
|
export const run = (
|
|
580
1297
|
repoRoot: string,
|
|
581
1298
|
argv: ReadonlyArray<string>,
|
|
@@ -585,41 +1302,91 @@ export const run = (
|
|
|
585
1302
|
Effect.gen(function* () {
|
|
586
1303
|
const [command = "check", ...rest] = argv;
|
|
587
1304
|
|
|
588
|
-
// The
|
|
1305
|
+
// The three commands that write a manifest rather than read one.
|
|
589
1306
|
if (command === "init") return yield* init(repoRoot);
|
|
590
1307
|
if (command === "migrate") return yield* migrate(repoRoot, configFilename);
|
|
1308
|
+
if (command === "infer") {
|
|
1309
|
+
yield* infer(repoRoot, rest, configFilename);
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// `--against <file>` measures the tree against a manifest other than the
|
|
1314
|
+
// repository's own — the target the team is moving toward. Only
|
|
1315
|
+
// `conformance` takes it: a `check` against a manifest nobody is held to
|
|
1316
|
+
// yet would fail for no one's benefit.
|
|
1317
|
+
const againstAt = rest.indexOf("--against");
|
|
1318
|
+
const against = againstAt === -1 ? undefined : rest[againstAt + 1];
|
|
1319
|
+
if (againstAt !== -1 && (against === undefined || against.startsWith("--"))) {
|
|
1320
|
+
return yield* Effect.fail(fail("--against needs a manifest path"));
|
|
1321
|
+
}
|
|
1322
|
+
if (against !== undefined && command !== "conformance") {
|
|
1323
|
+
return yield* Effect.fail(
|
|
1324
|
+
fail(`--against is a \`conformance\` flag; ${command} does not take it`),
|
|
1325
|
+
);
|
|
1326
|
+
}
|
|
1327
|
+
const manifestFilename = against ?? configFilename;
|
|
591
1328
|
|
|
592
1329
|
const policy = yield* Effect.tryPromise({
|
|
593
|
-
try: () => loadPolicyFromFile(repoRoot,
|
|
1330
|
+
try: () => loadPolicyFromFile(repoRoot, manifestFilename),
|
|
594
1331
|
catch: (cause) => fail(String(cause)),
|
|
595
1332
|
});
|
|
596
1333
|
yield* Effect.sync(() => {
|
|
597
1334
|
for (const notice of policy.notices) process.stderr.write(`deprecated: ${notice}\n`);
|
|
598
1335
|
});
|
|
599
1336
|
|
|
600
|
-
|
|
1337
|
+
// Every `report` a campaign names is read now, before any file asks:
|
|
1338
|
+
// a term's several commands run at once rather than one after another
|
|
1339
|
+
// the first time a campaign selects a file. What cannot be read is kept
|
|
1340
|
+
// for the campaign to report; a report that does not parse is refused.
|
|
1341
|
+
if (READS_REPORTS.has(command)) {
|
|
1342
|
+
yield* Effect.tryPromise({
|
|
1343
|
+
try: () => policy.campaigns.readReports(policy),
|
|
1344
|
+
catch: (cause) => fail(String(cause)),
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
const json = rest.includes("--json");
|
|
1349
|
+
const positional = rest.filter(
|
|
1350
|
+
(argument, index) =>
|
|
1351
|
+
argument !== "--json" &&
|
|
1352
|
+
argument !== "--against" &&
|
|
1353
|
+
(againstAt === -1 || index !== againstAt + 1),
|
|
1354
|
+
);
|
|
1355
|
+
const roots = positional.length > 0 ? positional : ["packages"];
|
|
601
1356
|
|
|
602
1357
|
switch (command) {
|
|
1358
|
+
case "campaigns":
|
|
1359
|
+
return yield* campaigns(policy, ["packages"], rest, configFilename);
|
|
1360
|
+
case "objectives":
|
|
1361
|
+
return yield* objectives(policy, ["packages"], rest);
|
|
603
1362
|
case "check":
|
|
604
|
-
return yield* check(policy, roots
|
|
1363
|
+
return yield* check(policy, roots, {
|
|
1364
|
+
format: json ? "json" : "text",
|
|
1365
|
+
manifestPath: manifestPathOf(repoRoot, configFilename),
|
|
1366
|
+
});
|
|
1367
|
+
case "conformance":
|
|
1368
|
+
return yield* conformance(policy, roots, {
|
|
1369
|
+
format: json ? "json" : "text",
|
|
1370
|
+
manifestPath: manifestPathOf(repoRoot, manifestFilename),
|
|
1371
|
+
});
|
|
605
1372
|
case "baseline":
|
|
606
1373
|
return yield* writeBaseline(policy, roots);
|
|
607
1374
|
case "explain": {
|
|
608
|
-
const [file] =
|
|
1375
|
+
const [file, ...explainRoots] = positional;
|
|
609
1376
|
if (file === undefined) return yield* Effect.fail(fail("explain needs a file path"));
|
|
610
|
-
return yield* explain(policy, file);
|
|
1377
|
+
return yield* explain(policy, file, explainRoots.length > 0 ? explainRoots : ["packages"]);
|
|
611
1378
|
}
|
|
612
1379
|
case "coverage":
|
|
613
1380
|
return yield* coverage(policy, roots);
|
|
614
1381
|
case "facts": {
|
|
615
|
-
const [file] =
|
|
1382
|
+
const [file] = positional;
|
|
616
1383
|
if (file === undefined) return yield* Effect.fail(fail("facts needs a file path"));
|
|
617
|
-
return yield* facts(policy, file,
|
|
1384
|
+
return yield* facts(policy, file, json ? "json" : "text");
|
|
618
1385
|
}
|
|
619
1386
|
default:
|
|
620
1387
|
return yield* Effect.fail(
|
|
621
1388
|
fail(
|
|
622
|
-
`unknown command "${command}". Try: check | baseline | coverage | explain <file> | facts <file> [--json] | init | migrate`,
|
|
1389
|
+
`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`,
|
|
623
1390
|
),
|
|
624
1391
|
);
|
|
625
1392
|
}
|