@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/build/esm/run.js
CHANGED
|
@@ -1,34 +1,54 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
3
|
import * as path from "node:path";
|
|
3
|
-
import { baselineOf, coverageOf,
|
|
4
|
+
import { baselineOf, breaches, CONFORMANCE_MEASURES, countedFiles, coverageOf, cyclesIn, decodeBaseline, decodeManifest, EMPTY_BASELINE, evaluateGraph, evaluateMemberSite, evaluateResolvedEdge, evaluateSelectedBindings, evaluateStructure, evaluateSurface, expandAliases, exportRulesSelecting, findManifestFile, fingerprintOf, formatManifestYaml, formatMessage, fractionsOf, globsOf, globToRegExp, hasGraphRules, heightOf, listSourceFiles, makeBaselineFilter, MANIFEST_FILENAMES, MANIFEST_SCHEMA_ID, memberRulesSelecting, readManifestFile, requiredSiblingsOf, residueOf, rulesSelecting, serializeBaseline, slackOf, SNAPSHOT_VERSION, staleEntriesOf, surfaceRulesSelecting, vacancyOf, } from "@goodbones/core";
|
|
4
5
|
import * as Effect from "effect/Effect";
|
|
5
6
|
import * as Result from "effect/Result";
|
|
6
|
-
import { loadPolicyFromFile } from "./config-loader.js";
|
|
7
|
+
import { loadPolicyFromFile, manifestPathOf } from "./config-loader.js";
|
|
7
8
|
import { buildGraph } from "./graph.js";
|
|
9
|
+
import { infer } from "./infer.js";
|
|
10
|
+
import { count, fail, report } from "./output.js";
|
|
8
11
|
import { sourceFactsOf } from "./source-facts.js";
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
export const collectFindings = (policy, roots, options = {}) => {
|
|
13
|
+
// A campaign may widen the walk past the packs' extensions; a file only a
|
|
14
|
+
// campaign asked for is seen by the campaigns and by no other family.
|
|
15
|
+
const walked = listSourceFiles(policy.repoRoot, roots, policy.languages, policy.campaigns.widenedExtensions(policy));
|
|
16
|
+
const known = new Set(policy.languages.flatMap((one) => one.extensions));
|
|
17
|
+
const files = walked.filter((file) => known.has(path.extname(file)));
|
|
12
18
|
const violations = [];
|
|
13
19
|
const unresolved = [];
|
|
14
|
-
|
|
15
|
-
//
|
|
20
|
+
const edges = [];
|
|
21
|
+
// Each file is read and parsed at most once, whether the per-file
|
|
22
|
+
// families, the graph pass or a campaign asks first.
|
|
23
|
+
const texts = new Map();
|
|
24
|
+
const textOf = (file) => {
|
|
25
|
+
const cached = texts.get(file);
|
|
26
|
+
if (cached !== undefined)
|
|
27
|
+
return cached;
|
|
28
|
+
const text = readFileSync(path.join(policy.repoRoot, file), "utf8");
|
|
29
|
+
texts.set(file, text);
|
|
30
|
+
return text;
|
|
31
|
+
};
|
|
16
32
|
const parsed = new Map();
|
|
17
33
|
const factsOf = (file) => {
|
|
18
34
|
const cached = parsed.get(file);
|
|
19
35
|
if (cached !== undefined)
|
|
20
36
|
return cached;
|
|
21
|
-
const facts =
|
|
37
|
+
const facts = policy.extractor.factsOf(file, textOf(file));
|
|
22
38
|
parsed.set(file, facts);
|
|
23
39
|
return facts;
|
|
24
40
|
};
|
|
25
41
|
// The graph is the whole repository resolved at once — the one question no
|
|
26
42
|
// per-file adapter can ask — and is built only when a rule needs it.
|
|
27
|
-
|
|
28
|
-
|
|
43
|
+
const graph = options.graph === true || hasGraphRules(policy.graph)
|
|
44
|
+
? buildGraph(files, policy.resolver, factsOf)
|
|
45
|
+
: null;
|
|
46
|
+
if (graph !== null && hasGraphRules(policy.graph)) {
|
|
47
|
+
for (const violation of evaluateGraph(policy.graph, graph))
|
|
29
48
|
violations.push(violation);
|
|
30
|
-
}
|
|
31
49
|
}
|
|
50
|
+
// The campaigns, over every walked file, through the same caches.
|
|
51
|
+
const campaigns = policy.campaigns.evaluate(policy, roots, walked, { textOf, factsOf });
|
|
32
52
|
for (const file of files) {
|
|
33
53
|
for (const violation of evaluateStructure(policy.structure, policy.fileSystem, file)) {
|
|
34
54
|
violations.push(violation);
|
|
@@ -54,17 +74,23 @@ export const collectFindings = (policy, roots) => {
|
|
|
54
74
|
}
|
|
55
75
|
for (const specifier of facts.specifiers) {
|
|
56
76
|
const edge = { importer: file, specifier };
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (
|
|
77
|
+
// A file no import rule selects never needs its imports resolved, which
|
|
78
|
+
// is what keeps resolution off the hot path for the bulk of the repo.
|
|
79
|
+
if (selectedImports.length > 0) {
|
|
80
|
+
const resolved = policy.resolver.resolve(file, specifier);
|
|
81
|
+
if (Result.isFailure(resolved)) {
|
|
82
|
+
if (policy.config.resolve.unresolved === "off")
|
|
83
|
+
continue;
|
|
84
|
+
if (policy.ignoreUnresolved.some((pattern) => pattern.test(specifier)))
|
|
85
|
+
continue;
|
|
86
|
+
unresolved.push({ file, specifier, detail: resolved.failure.detail });
|
|
62
87
|
continue;
|
|
63
|
-
|
|
64
|
-
|
|
88
|
+
}
|
|
89
|
+
edges.push({ importer: file, target: resolved.success });
|
|
90
|
+
for (const violation of evaluateResolvedEdge(selectedImports, file, resolved.success)) {
|
|
91
|
+
violations.push(violation);
|
|
92
|
+
}
|
|
65
93
|
}
|
|
66
|
-
for (const violation of imported.success)
|
|
67
|
-
violations.push(violation);
|
|
68
94
|
const bound = facts.bindings.get(specifier) ?? [];
|
|
69
95
|
const exported = evaluateSelectedBindings(selectedExports, policy.resolver, {
|
|
70
96
|
...edge,
|
|
@@ -76,7 +102,7 @@ export const collectFindings = (policy, roots) => {
|
|
|
76
102
|
}
|
|
77
103
|
}
|
|
78
104
|
}
|
|
79
|
-
return { violations, unresolved, files: files.length };
|
|
105
|
+
return { violations, campaigns, unresolved, files: files.length, edges, graph };
|
|
80
106
|
};
|
|
81
107
|
const baselinePathOf = (policy) => policy.config.baseline === undefined
|
|
82
108
|
? null
|
|
@@ -92,65 +118,422 @@ const readBaseline = (policy) => {
|
|
|
92
118
|
return EMPTY_BASELINE;
|
|
93
119
|
}
|
|
94
120
|
};
|
|
95
|
-
const report = (lines) => Effect.sync(() => {
|
|
96
|
-
for (const line of lines)
|
|
97
|
-
process.stdout.write(`${line}\n`);
|
|
98
|
-
});
|
|
99
121
|
const describe = (violation) => ` ${violation.file}\n ${formatMessage(violation)}`;
|
|
100
|
-
|
|
101
|
-
|
|
122
|
+
const COVERAGE_FAMILIES = [
|
|
123
|
+
"imports",
|
|
124
|
+
"structure",
|
|
125
|
+
"members",
|
|
126
|
+
"surface",
|
|
127
|
+
"graph",
|
|
128
|
+
];
|
|
129
|
+
const sha256Of = (file) => {
|
|
130
|
+
try {
|
|
131
|
+
return createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return "";
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
export const checkReport = (policy, roots, manifestPath) => reportOf(policy, roots, manifestPath, collectFindings(policy, roots)).report;
|
|
138
|
+
// The patterns the policy's `limits.outside` names, alias-expanded, each a
|
|
139
|
+
// subtree.
|
|
140
|
+
const outsideOf = (policy) => globsOf(policy.config.limits?.outside ?? []).map((glob) => globToRegExp(expandAliases(glob, policy.config.aliases ?? {})));
|
|
141
|
+
const measuresOf = (policy, files, edges) => {
|
|
142
|
+
// Slack is measured over the walked files as well as the edges: an
|
|
143
|
+
// allowlist that selects no file is vacant, and its entries are reported as
|
|
144
|
+
// that rather than as lines nobody needs.
|
|
145
|
+
const { concentration, slack } = slackOf(policy.importRules, edges, files);
|
|
146
|
+
return {
|
|
147
|
+
// Residue is a count of files, and counts the ones the limits count. A
|
|
148
|
+
// node is live or vacant by every file under it, counted or not.
|
|
149
|
+
residue: residueOf(policy, countedFiles(files, outsideOf(policy))),
|
|
150
|
+
vacant: vacancyOf(policy.importRules, files),
|
|
151
|
+
slack,
|
|
152
|
+
concentration,
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
// A fragment entry is concentrated when it is used at fewer than half the
|
|
156
|
+
// nodes granted it — the text report's threshold, and the ceiling's.
|
|
157
|
+
const isConcentrated = (one) => one.usedAt * 2 < one.of;
|
|
158
|
+
const countsOf = (measures) => ({
|
|
159
|
+
residue: measures.residue.files.length,
|
|
160
|
+
vacant: measures.vacant.length,
|
|
161
|
+
slack: measures.slack.length,
|
|
162
|
+
concentration: measures.concentration.filter(isConcentrated).length,
|
|
163
|
+
});
|
|
164
|
+
const reportOf = (policy, roots, manifestPath, findings) => {
|
|
102
165
|
const baseline = readBaseline(policy);
|
|
103
|
-
const reportable = unbaselined(baseline, findings.violations);
|
|
104
166
|
const stale = staleEntriesOf(baseline, findings.violations);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
167
|
+
const { isBaselined } = makeBaselineFilter(baseline);
|
|
168
|
+
const violations = [
|
|
169
|
+
...findings.violations.map((violation) => ({
|
|
170
|
+
...violation,
|
|
171
|
+
fingerprint: fingerprintOf(violation),
|
|
172
|
+
baselined: isBaselined(violation),
|
|
173
|
+
ledgered: false,
|
|
174
|
+
})),
|
|
175
|
+
// The hits that count: those whose objective is in window for the
|
|
176
|
+
// sector they fall in.
|
|
177
|
+
...policy.campaigns.hitsOf(policy, findings.campaigns).map((hit) => ({
|
|
178
|
+
...hit.violation,
|
|
179
|
+
fingerprint: fingerprintOf(hit.violation),
|
|
180
|
+
baselined: false,
|
|
181
|
+
ledgered: hit.ledgered,
|
|
182
|
+
objective: hit.objective,
|
|
183
|
+
sector: hit.sector,
|
|
184
|
+
entry: hit.entry,
|
|
185
|
+
})),
|
|
186
|
+
];
|
|
187
|
+
const campaigns = policy.campaigns.reportsOf(policy, findings.campaigns);
|
|
188
|
+
// The floors. A policy states how much of the tree it reaches, per
|
|
189
|
+
// family; falling under is a policy that quietly stopped covering files.
|
|
190
|
+
// Both are held over the files the limits count: what `limits.outside`
|
|
191
|
+
// names is judged by every rule and left out of these numbers.
|
|
192
|
+
const floors = policy.config.limits?.coverage ?? {};
|
|
193
|
+
const unreached = policy.config.limits?.unreached ?? {};
|
|
194
|
+
const files = listSourceFiles(policy.repoRoot, roots, policy.languages);
|
|
195
|
+
const counted = countedFiles(files, outsideOf(policy));
|
|
196
|
+
const found = coverageOf(policy, counted);
|
|
197
|
+
const covered = (family) => family === "structure" ? found.structure.enumerated : found[family].covered;
|
|
198
|
+
const coverage = Object.fromEntries(COVERAGE_FAMILIES.map((family) => {
|
|
199
|
+
const floor = floors[family];
|
|
200
|
+
const ceiling = unreached[family];
|
|
201
|
+
return [
|
|
202
|
+
family,
|
|
203
|
+
{
|
|
204
|
+
covered: covered(family),
|
|
205
|
+
total: found.files,
|
|
206
|
+
...(floor === undefined ? {} : { floor }),
|
|
207
|
+
...(ceiling === undefined ? {} : { ceiling }),
|
|
208
|
+
},
|
|
209
|
+
];
|
|
210
|
+
}));
|
|
211
|
+
const shortfalls = shortfallsOf(coverage);
|
|
212
|
+
const overruns = overrunsOf(coverage);
|
|
213
|
+
// The ceilings. What no family reaches, what no file is under and what
|
|
214
|
+
// nothing imports through are each a count the policy may hold itself
|
|
215
|
+
// to; rising over one is a manifest that quietly widened.
|
|
216
|
+
const ceilings = policy.config.limits?.conformance ?? {};
|
|
217
|
+
const measures = measuresOf(policy, files, findings.edges);
|
|
218
|
+
const counts = countsOf(measures);
|
|
219
|
+
const conformance = Object.fromEntries(CONFORMANCE_MEASURES.map((measure) => {
|
|
220
|
+
const ceiling = ceilings[measure];
|
|
221
|
+
return [measure, { count: counts[measure], ...(ceiling === undefined ? {} : { ceiling }) }];
|
|
222
|
+
}));
|
|
223
|
+
const excesses = excessesOf(conformance);
|
|
224
|
+
const reportable = violations.filter((one) => !one.baselined && !one.ledgered).length;
|
|
225
|
+
const report = {
|
|
226
|
+
version: 1,
|
|
227
|
+
files: findings.files,
|
|
228
|
+
outside: files.length - counted.length,
|
|
229
|
+
roots,
|
|
230
|
+
ok: reportable === 0 &&
|
|
231
|
+
findings.unresolved.length === 0 &&
|
|
232
|
+
stale.length === 0 &&
|
|
233
|
+
shortfalls.length === 0 &&
|
|
234
|
+
overruns.length === 0 &&
|
|
235
|
+
excesses.length === 0 &&
|
|
236
|
+
policy.campaigns.failuresOf(campaigns).length === 0,
|
|
237
|
+
manifest: {
|
|
238
|
+
path: path.relative(policy.repoRoot, manifestPath).replaceAll(path.sep, "/"),
|
|
239
|
+
sha256: sha256Of(manifestPath),
|
|
240
|
+
},
|
|
241
|
+
violations,
|
|
242
|
+
unresolved: findings.unresolved,
|
|
243
|
+
stale,
|
|
244
|
+
coverage,
|
|
245
|
+
conformance,
|
|
246
|
+
adoption: {
|
|
247
|
+
unrestricted: policy.adoption.unrestricted,
|
|
248
|
+
partial: policy.adoption.partial,
|
|
249
|
+
},
|
|
250
|
+
campaigns,
|
|
251
|
+
};
|
|
252
|
+
return { report, measures, files };
|
|
253
|
+
};
|
|
254
|
+
const shortfallsOf = (coverage) => COVERAGE_FAMILIES.flatMap((family) => {
|
|
255
|
+
const { covered, floor, total } = coverage[family];
|
|
256
|
+
const actual = total === 0 ? 1 : covered / total;
|
|
257
|
+
return floor === undefined || !breaches({ direction: "up", limit: floor, tolerance: 0 }, actual)
|
|
258
|
+
? []
|
|
259
|
+
: [{ family, actual, floor }];
|
|
260
|
+
});
|
|
261
|
+
const overrunsOf = (coverage) => COVERAGE_FAMILIES.flatMap((family) => {
|
|
262
|
+
const { ceiling, covered, total } = coverage[family];
|
|
263
|
+
const unreached = total - covered;
|
|
264
|
+
return ceiling === undefined ||
|
|
265
|
+
!breaches({ direction: "down", limit: ceiling, tolerance: 0 }, unreached)
|
|
266
|
+
? []
|
|
267
|
+
: [{ family, unreached, ceiling }];
|
|
268
|
+
});
|
|
269
|
+
const excessesOf = (conformance) => CONFORMANCE_MEASURES.flatMap((measure) => {
|
|
270
|
+
const { ceiling, count } = conformance[measure];
|
|
271
|
+
return ceiling === undefined ||
|
|
272
|
+
!breaches({ direction: "down", limit: ceiling, tolerance: 0 }, count)
|
|
273
|
+
? []
|
|
274
|
+
: [{ measure, count, ceiling }];
|
|
275
|
+
});
|
|
276
|
+
const failureOf = (policy, report, shortfalls) => {
|
|
277
|
+
if (report.stale.length > 0)
|
|
278
|
+
return fail("stale baseline entries");
|
|
279
|
+
const [campaignFailure] = policy.campaigns.failuresOf(report.campaigns);
|
|
280
|
+
if (campaignFailure !== undefined)
|
|
281
|
+
return fail(campaignFailure);
|
|
282
|
+
if (shortfalls.length > 0)
|
|
283
|
+
return fail("coverage below floor");
|
|
284
|
+
if (overrunsOf(report.coverage).length > 0)
|
|
285
|
+
return fail("unreached above ceiling");
|
|
286
|
+
if (excessesOf(report.conformance).length > 0)
|
|
287
|
+
return fail("conformance above ceiling");
|
|
288
|
+
if (report.ok)
|
|
289
|
+
return null;
|
|
290
|
+
return fail("architecture violations");
|
|
291
|
+
};
|
|
292
|
+
// What each measure counts, as the failure names it.
|
|
293
|
+
const MEASURE_NOUNS = {
|
|
294
|
+
residue: ["file no family reaches", "files no family reaches"],
|
|
295
|
+
vacant: ["node no file is under", "nodes no file is under"],
|
|
296
|
+
slack: ["allowance nothing imports through", "allowances nothing imports through"],
|
|
297
|
+
concentration: [
|
|
298
|
+
"allowance used at fewer than half the nodes granted",
|
|
299
|
+
"allowances used at fewer than half the nodes granted",
|
|
300
|
+
],
|
|
301
|
+
};
|
|
302
|
+
const describeExcess = (one) => {
|
|
303
|
+
const [singular, plural] = MEASURE_NOUNS[one.measure];
|
|
304
|
+
return ` ${one.measure}: ${count(one.count, singular, plural)}, ceiling ${String(one.ceiling)}`;
|
|
305
|
+
};
|
|
306
|
+
const renderCampaigns = (policy, report) => policy.campaigns.renderReports(report.campaigns, report.violations.flatMap((one) => one.kind === "campaign" &&
|
|
307
|
+
one.objective !== undefined &&
|
|
308
|
+
one.sector !== undefined &&
|
|
309
|
+
one.entry !== undefined
|
|
310
|
+
? [
|
|
311
|
+
{
|
|
312
|
+
violation: one,
|
|
313
|
+
objective: one.objective,
|
|
314
|
+
sector: one.sector,
|
|
315
|
+
entry: one.entry,
|
|
316
|
+
ledgered: one.ledgered,
|
|
317
|
+
},
|
|
318
|
+
]
|
|
319
|
+
: []));
|
|
320
|
+
const renderText = (policy, report) => {
|
|
321
|
+
const reportable = report.violations.filter((one) => one.kind !== "campaign" && !one.baselined);
|
|
322
|
+
const carried = report.violations.filter((one) => one.kind !== "campaign").length - reportable.length;
|
|
323
|
+
const shortfalls = shortfallsOf(report.coverage);
|
|
324
|
+
const overruns = overrunsOf(report.coverage);
|
|
325
|
+
const excesses = excessesOf(report.conformance);
|
|
326
|
+
return [
|
|
327
|
+
...reportable.map(describe),
|
|
328
|
+
...report.unresolved.map((one) => ` unresolved: ${one.file} → ${one.specifier} (${one.detail})`),
|
|
109
329
|
"",
|
|
110
|
-
`${String(
|
|
330
|
+
`${String(report.files)} files, ${String(reportable.length)} violations` +
|
|
111
331
|
(carried > 0 ? `, ${String(carried)} carried by the baseline` : ""),
|
|
112
|
-
]);
|
|
113
|
-
if (stale.length > 0) {
|
|
114
332
|
// The ratchet: a fixed violation must leave the baseline, or the floor
|
|
115
333
|
// never rises and the file stops describing anything real.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
334
|
+
...(report.stale.length === 0
|
|
335
|
+
? []
|
|
336
|
+
: [
|
|
337
|
+
"",
|
|
338
|
+
`${String(report.stale.length)} baseline entries no longer fire. The code was fixed; prune them:`,
|
|
339
|
+
...report.stale.map((entry) => ` ${entry}`),
|
|
340
|
+
"",
|
|
341
|
+
" architecture baseline # rewrites the file from what still fires",
|
|
342
|
+
]),
|
|
343
|
+
...(shortfalls.length === 0
|
|
344
|
+
? []
|
|
345
|
+
: [
|
|
346
|
+
"",
|
|
347
|
+
"coverage is below the floor the policy states for itself:",
|
|
348
|
+
...shortfalls.map((one) => ` ${one.family}: ${percent(one.actual)} covered, floor ${percent(one.floor)}`),
|
|
349
|
+
"",
|
|
350
|
+
" architecture coverage # which files no rule reaches",
|
|
351
|
+
]),
|
|
352
|
+
...(overruns.length === 0
|
|
353
|
+
? []
|
|
354
|
+
: [
|
|
355
|
+
"",
|
|
356
|
+
"more files are unreached than the ceiling the policy states for itself:",
|
|
357
|
+
...overruns.map((one) => ` ${one.family}: ${count(one.unreached, "file")} unreached, ceiling ${String(one.ceiling)}`),
|
|
358
|
+
"",
|
|
359
|
+
" architecture coverage # which files no rule reaches",
|
|
360
|
+
]),
|
|
361
|
+
...(excesses.length === 0
|
|
362
|
+
? []
|
|
363
|
+
: [
|
|
364
|
+
"",
|
|
365
|
+
"conformance is above the ceiling the policy states for itself:",
|
|
366
|
+
...excesses.map(describeExcess),
|
|
367
|
+
"",
|
|
368
|
+
" architecture conformance # which files, nodes and allowances",
|
|
369
|
+
]),
|
|
370
|
+
...renderCampaigns(policy, report),
|
|
371
|
+
];
|
|
372
|
+
};
|
|
373
|
+
export const check = (policy, roots, options) => Effect.gen(function* () {
|
|
374
|
+
const report_ = checkReport(policy, roots, options.manifestPath);
|
|
375
|
+
// JSON is one object on stdout and nothing else there; the failure, when
|
|
376
|
+
// there is one, is a sentence on stderr and the exit code, as in text.
|
|
377
|
+
yield* report(options.format === "json" ? [JSON.stringify(report_, null, 2)] : renderText(policy, report_));
|
|
378
|
+
const failure = failureOf(policy, report_, shortfallsOf(report_.coverage));
|
|
379
|
+
if (failure !== null)
|
|
380
|
+
return yield* Effect.fail(failure);
|
|
144
381
|
});
|
|
145
382
|
const percent = (fraction) => `${String(Math.floor(fraction * 100))}%`;
|
|
383
|
+
// The conformance snapshot: `check`'s report grown with what no family
|
|
384
|
+
// reaches, what the allowlists permit and nothing uses, the cycle count and
|
|
385
|
+
// the size of the debt — the whole distance between the tree and the
|
|
386
|
+
// manifest, as one document another run can be compared against. Its shape
|
|
387
|
+
// is the core's `Snapshot`, and the schema published beside the manifest's.
|
|
388
|
+
export const snapshotOf = (policy, roots, manifestPath) => {
|
|
389
|
+
const findings = collectFindings(policy, roots, { graph: true });
|
|
390
|
+
const { files, measures, report: report_ } = reportOf(policy, roots, manifestPath, findings);
|
|
391
|
+
const graph = findings.graph ?? { files, edges: new Map() };
|
|
392
|
+
const heights = heightOf(graph);
|
|
393
|
+
// Leaf edges first. A violation names a target when it is about an edge;
|
|
394
|
+
// the cost of fixing it is roughly how much of the graph stands beneath
|
|
395
|
+
// that target, so the ones nearest the ground come first and a reader
|
|
396
|
+
// starting at the top of the list is starting where a fix stays local.
|
|
397
|
+
// Ties keep the fingerprint order, so the list is the same on every run.
|
|
398
|
+
const heightOfViolation = (one) => heights.get(one.subject ?? "") ?? heights.get(one.file) ?? 0;
|
|
399
|
+
const violations = [...report_.violations].sort((left, right) => {
|
|
400
|
+
const byHeight = heightOfViolation(left) - heightOfViolation(right);
|
|
401
|
+
return byHeight !== 0 ? byHeight : left.fingerprint.localeCompare(right.fingerprint);
|
|
402
|
+
});
|
|
403
|
+
const campaigns = policy.campaigns.snapshotOf(policy, findings.campaigns);
|
|
404
|
+
return {
|
|
405
|
+
version: SNAPSHOT_VERSION,
|
|
406
|
+
manifest: report_.manifest,
|
|
407
|
+
roots: report_.roots,
|
|
408
|
+
files: report_.files,
|
|
409
|
+
outside: report_.outside,
|
|
410
|
+
ok: report_.ok,
|
|
411
|
+
coverage: report_.coverage,
|
|
412
|
+
conformance: report_.conformance,
|
|
413
|
+
residue: measures.residue,
|
|
414
|
+
vacant: measures.vacant,
|
|
415
|
+
violations,
|
|
416
|
+
unresolved: report_.unresolved,
|
|
417
|
+
stale: report_.stale,
|
|
418
|
+
baseline: { size: readBaseline(policy).entries.length },
|
|
419
|
+
cycles: cyclesIn(graph).length,
|
|
420
|
+
slack: measures.slack,
|
|
421
|
+
concentration: measures.concentration,
|
|
422
|
+
adoption: report_.adoption,
|
|
423
|
+
campaigns,
|
|
424
|
+
};
|
|
425
|
+
};
|
|
426
|
+
// The unreached ceiling beside a family's row, and the ratchet's nudge when
|
|
427
|
+
// the count has fallen under it: a ceiling is lowered by hand.
|
|
428
|
+
const unreachedMark = (covered, total, ceiling) => {
|
|
429
|
+
if (ceiling === undefined)
|
|
430
|
+
return "";
|
|
431
|
+
const left = total - covered;
|
|
432
|
+
if (left > ceiling)
|
|
433
|
+
return ` ${String(left)} unreached > ${String(ceiling)} ✗`;
|
|
434
|
+
return ` ${String(left)} unreached ≤ ${String(ceiling)} ✓${left < ceiling ? `, lower it to ${String(left)}` : ""}`;
|
|
435
|
+
};
|
|
436
|
+
const outsideNote = (outside) => outside === 0 ? "" : `, ${count(outside, "file")} outside the limits`;
|
|
437
|
+
const renderSnapshot = (policy, snapshot) => {
|
|
438
|
+
const reportable = snapshot.violations.filter((one) => !one.baselined && !one.ledgered);
|
|
439
|
+
const carried = snapshot.violations.length - reportable.length;
|
|
440
|
+
const row = (family) => {
|
|
441
|
+
const { ceiling, covered, floor, total } = snapshot.coverage[family];
|
|
442
|
+
const fraction = total === 0 ? 1 : covered / total;
|
|
443
|
+
const mark = floor === undefined
|
|
444
|
+
? ""
|
|
445
|
+
: fraction >= floor
|
|
446
|
+
? ` ≥ ${percent(floor)} ✓`
|
|
447
|
+
: ` < ${percent(floor)} ✗`;
|
|
448
|
+
return ` ${family.padEnd(10)} ${String(covered).padStart(5)}/${String(total)} ${percent(fraction).padStart(4)}${mark}${unreachedMark(covered, total, ceiling)}`;
|
|
449
|
+
};
|
|
450
|
+
const section = (title, lines) => [
|
|
451
|
+
"",
|
|
452
|
+
title,
|
|
453
|
+
...lines,
|
|
454
|
+
];
|
|
455
|
+
const vacantWidth = Math.max(0, ...snapshot.vacant.map((one) => one.node.length));
|
|
456
|
+
// The document carries every partly-used fragment entry; the text shows
|
|
457
|
+
// the ones concentrated enough to read as a per-file rule written wide.
|
|
458
|
+
const concentrated = snapshot.concentration.filter(isConcentrated);
|
|
459
|
+
// The ceiling beside each measure that has one, and the ratchet's nudge
|
|
460
|
+
// when the count has fallen under it: a ceiling is lowered by hand.
|
|
461
|
+
const ceilingMark = (measure) => {
|
|
462
|
+
const { ceiling, count: actual } = snapshot.conformance[measure];
|
|
463
|
+
if (ceiling === undefined)
|
|
464
|
+
return "";
|
|
465
|
+
if (actual > ceiling)
|
|
466
|
+
return ` > ${String(ceiling)} ✗`;
|
|
467
|
+
return ` ≤ ${String(ceiling)} ✓${actual < ceiling ? `, lower it to ${String(actual)}` : ""}`;
|
|
468
|
+
};
|
|
469
|
+
return [
|
|
470
|
+
`${String(snapshot.files)} files under ${snapshot.roots.join(", ")}, against ${snapshot.manifest.path}${outsideNote(snapshot.outside)}`,
|
|
471
|
+
...section("coverage", COVERAGE_FAMILIES.map(row)),
|
|
472
|
+
...section(`residue: ${count(snapshot.residue.files.length, "file")} no family reaches` +
|
|
473
|
+
(snapshot.residue.folders.length === 0
|
|
474
|
+
? ""
|
|
475
|
+
: `, ${count(snapshot.residue.folders.length, "folder")} wholly`) +
|
|
476
|
+
ceilingMark("residue"), [
|
|
477
|
+
...snapshot.residue.folders.map((folder) => ` ${folder}/`),
|
|
478
|
+
...snapshot.residue.files
|
|
479
|
+
.filter((file) => !snapshot.residue.folders.some((folder) => file.startsWith(`${folder}/`)))
|
|
480
|
+
.map((file) => ` ${file}`),
|
|
481
|
+
]),
|
|
482
|
+
...section(`vacant: ${count(snapshot.vacant.length, "node")} ${snapshot.vacant.length === 1 ? "selects" : "select"} no file` +
|
|
483
|
+
ceilingMark("vacant"), snapshot.vacant.map((one) => ` ${one.node.padEnd(vacantWidth)} ${count(one.allowances, "allowance")}`)),
|
|
484
|
+
...section(`violations: ${count(reportable.length, "reportable")}` +
|
|
485
|
+
(carried > 0 ? `, ${String(carried)} carried by the baseline or a ledger` : "") +
|
|
486
|
+
(snapshot.stale.length > 0
|
|
487
|
+
? `, ${count(snapshot.stale.length, "stale entry", "stale entries")}`
|
|
488
|
+
: "") +
|
|
489
|
+
(reportable.length > 0 ? " — nearest the ground first" : ""), reportable.map(describe)),
|
|
490
|
+
...(snapshot.unresolved.length === 0
|
|
491
|
+
? []
|
|
492
|
+
: section(`unresolved: ${count(snapshot.unresolved.length, "import")} no rule can police`, snapshot.unresolved.map((one) => ` ${one.file} → ${one.specifier} (${one.detail})`))),
|
|
493
|
+
...section(`slack: ${count(snapshot.slack.length, "allowance")} nothing imports through` +
|
|
494
|
+
ceilingMark("slack"), snapshot.slack.map((one) => ` ${one.node}: ${one.kind} ${JSON.stringify(one.entry)}` +
|
|
495
|
+
(one.of === undefined ? "" : ` (via use, at ${count(one.of, "node")})`))),
|
|
496
|
+
...(concentrated.length === 0 && snapshot.conformance.concentration.ceiling === undefined
|
|
497
|
+
? []
|
|
498
|
+
: section(`concentrated: ${count(concentrated.length, "allowance")} used at fewer than half the nodes granted` +
|
|
499
|
+
ceilingMark("concentration"), concentrated.map((one) => ` ${one.fragment}: ${one.kind} ${JSON.stringify(one.entry)} used at ${String(one.usedAt)} of ${count(one.of, "node")}`))),
|
|
500
|
+
...(snapshot.campaigns.length === 0
|
|
501
|
+
? []
|
|
502
|
+
: section(`campaigns: ${count(snapshot.campaigns.length, "campaign")}` +
|
|
503
|
+
(snapshot.campaigns.some((one) => one.stalled)
|
|
504
|
+
? `, ${count(snapshot.campaigns.filter((one) => one.stalled).length, "stalled", "stalled")}`
|
|
505
|
+
: "") +
|
|
506
|
+
(snapshot.campaigns.some((one) => one.complete && one.ledgered)
|
|
507
|
+
? `, ${count(snapshot.campaigns.filter((one) => one.complete && one.ledgered).length, "complete", "complete")}`
|
|
508
|
+
: ""), policy.campaigns.renderRows(snapshot.campaigns))),
|
|
509
|
+
"",
|
|
510
|
+
`cycles: ${String(snapshot.cycles)}`,
|
|
511
|
+
`baseline: ${count(snapshot.baseline.size, "entry", "entries")}`,
|
|
512
|
+
`adoption: ${count(snapshot.adoption.unrestricted.length, "unrestricted tier")}, ${count(snapshot.adoption.partial.length, "partial tier")}`,
|
|
513
|
+
];
|
|
514
|
+
};
|
|
515
|
+
// The report of the tree against the manifest. Unlike `check`, it never
|
|
516
|
+
// fails: it is a measurement, and the manifest it measures against may be one
|
|
517
|
+
// the tree was never expected to satisfy yet — `--against` names a target.
|
|
518
|
+
// `ok` in the document says what `check` would have done.
|
|
519
|
+
export const conformance = (policy, roots, options) => Effect.gen(function* () {
|
|
520
|
+
const snapshot = snapshotOf(policy, roots, options.manifestPath);
|
|
521
|
+
yield* report(options.format === "json"
|
|
522
|
+
? [JSON.stringify(snapshot, null, 2)]
|
|
523
|
+
: renderSnapshot(policy, snapshot));
|
|
524
|
+
});
|
|
146
525
|
// How much of the tree the policy reaches. A probe proves a rule can fire;
|
|
147
526
|
// this is whether the files are there to fire on. Reported per family, with the
|
|
148
527
|
// adoption backlog — the tiers that said "not tightened yet" — beneath it.
|
|
149
528
|
export const coverage = (policy, roots) => Effect.gen(function* () {
|
|
150
|
-
|
|
529
|
+
// The limits are held over the files they count, so that is what the
|
|
530
|
+
// rows are over: what `limits.outside` names is said, and left out.
|
|
531
|
+
const walked = listSourceFiles(policy.repoRoot, roots, policy.languages);
|
|
532
|
+
const files = countedFiles(walked, outsideOf(policy));
|
|
151
533
|
const found = coverageOf(policy, files);
|
|
152
534
|
const fractions = fractionsOf(found);
|
|
153
535
|
const floors = policy.config.limits?.coverage ?? {};
|
|
536
|
+
const ceilings = policy.config.limits?.unreached ?? {};
|
|
154
537
|
const row = (family, covered, note) => {
|
|
155
538
|
const floor = floors[family];
|
|
156
539
|
const mark = floor === undefined
|
|
@@ -158,10 +541,10 @@ export const coverage = (policy, roots) => Effect.gen(function* () {
|
|
|
158
541
|
: fractions[family] >= floor
|
|
159
542
|
? ` ≥ ${percent(floor)} ✓`
|
|
160
543
|
: ` < ${percent(floor)} ✗`;
|
|
161
|
-
return ` ${family.padEnd(10)} ${String(covered).padStart(5)}/${String(found.files)} ${percent(fractions[family]).padStart(4)} ${note}${mark}`;
|
|
544
|
+
return ` ${family.padEnd(10)} ${String(covered).padStart(5)}/${String(found.files)} ${percent(fractions[family]).padStart(4)} ${note}${mark}${unreachedMark(covered, found.files, ceilings[family])}`;
|
|
162
545
|
};
|
|
163
546
|
yield* report([
|
|
164
|
-
`${String(
|
|
547
|
+
`${String(walked.length)} files under ${roots.join(", ")}${outsideNote(walked.length - files.length)}`,
|
|
165
548
|
"",
|
|
166
549
|
row("imports", found.imports.covered, "under an import allowlist"),
|
|
167
550
|
row("structure", found.structure.enumerated, `in an enumerated folder (${String(found.structure.open)} in an open one, ${String(found.structure.total - found.structure.enumerated - found.structure.open)} in none)`),
|
|
@@ -190,7 +573,7 @@ export const writeBaseline = (policy, roots) => Effect.gen(function* () {
|
|
|
190
573
|
});
|
|
191
574
|
// The question a tree config makes harder to answer than a flat one: given a
|
|
192
575
|
// file, what governs it? A flat config you grep; a tree you have to walk.
|
|
193
|
-
export const explain = (policy, file) => Effect.gen(function* () {
|
|
576
|
+
export const explain = (policy, file, roots = ["packages"]) => Effect.gen(function* () {
|
|
194
577
|
const relative = path.relative(policy.repoRoot, path.resolve(policy.repoRoot, file));
|
|
195
578
|
const selected = rulesSelecting(policy.importRules, relative);
|
|
196
579
|
// An allowlist rule names no `to` — it fires when the target matches none of
|
|
@@ -204,7 +587,7 @@ export const explain = (policy, file) => Effect.gen(function* () {
|
|
|
204
587
|
const governing = policy.structure.folders.filter((rule) => rule.folder.some((pattern) => pattern.test(path.dirname(relative))));
|
|
205
588
|
const naming = policy.structure.naming.filter((rule) => rule.file.some((pattern) => pattern.test(relative)) &&
|
|
206
589
|
!rule.fileNot.some((pattern) => pattern.test(relative)));
|
|
207
|
-
const firstSentence = (message) => `${message.split(". ")[0] ?? message}.`;
|
|
590
|
+
const firstSentence = (message) => `${(message.split(". ")[0] ?? message).replace(/\.$/, "")}.`;
|
|
208
591
|
const named = (rule) => ` ${rule.name} — ${firstSentence(rule.message)}`;
|
|
209
592
|
// The families beyond imports and structure: which rules of each speak to
|
|
210
593
|
// this file at all. What they are evaluated against is `facts`' answer.
|
|
@@ -222,6 +605,9 @@ export const explain = (policy, file) => Effect.gen(function* () {
|
|
|
222
605
|
.map((rule) => `${named(rule)} (reach)`),
|
|
223
606
|
];
|
|
224
607
|
const section = (title, lines) => lines.length === 0 ? [] : ["", title, ...lines];
|
|
608
|
+
// The campaigns selecting the file, evaluated over their files only
|
|
609
|
+
// when one does: a sector's phase is derived from all of them.
|
|
610
|
+
const campaignLines = policy.campaigns.explainLines(policy, relative, () => collectFindings(policy, roots).campaigns);
|
|
225
611
|
yield* report([
|
|
226
612
|
relative,
|
|
227
613
|
"",
|
|
@@ -251,6 +637,7 @@ export const explain = (policy, file) => Effect.gen(function* () {
|
|
|
251
637
|
...section(" vocabulary (members):", vocabulary.map(named)),
|
|
252
638
|
...section(" may export (surface):", surface.map(named)),
|
|
253
639
|
...section(" graph:", graph),
|
|
640
|
+
...section(" campaigns:", campaignLines),
|
|
254
641
|
]);
|
|
255
642
|
});
|
|
256
643
|
// The other half of `explain`. `explain` says which rules select a file; this
|
|
@@ -329,12 +716,35 @@ resolve:
|
|
|
329
716
|
baseline: .architecture-baseline.json
|
|
330
717
|
|
|
331
718
|
# Ceilings on how many tiers may say "not tightened yet". At zero, raising one
|
|
332
|
-
# is a line in this file a reviewer sees.
|
|
719
|
+
# is a line in this file a reviewer sees. The same block takes coverage floors
|
|
720
|
+
# and ceilings on what \`architecture conformance\` measures, once there are
|
|
721
|
+
# numbers to write.
|
|
333
722
|
# https://dataquail.github.io/goodbones/architecture-rules/enforcement/adoption/
|
|
334
723
|
limits:
|
|
335
724
|
unrestricted: 0
|
|
336
725
|
partial: 0
|
|
337
726
|
|
|
727
|
+
# Refactors the repository is running, each an object: objectives (a
|
|
728
|
+
# detector with a ledger under \`.architecture-campaigns/\` of every place the
|
|
729
|
+
# pattern still occurs), over sectors the code births through a perimeter,
|
|
730
|
+
# through phases toward an end. Fill one in, then
|
|
731
|
+
# \`architecture objectives clear <id>\` to write its ledgers.
|
|
732
|
+
# https://dataquail.github.io/goodbones/architecture-rules/manifest/campaigns/
|
|
733
|
+
# campaigns:
|
|
734
|
+
# js-to-ts:
|
|
735
|
+
# why: The strict tsconfig cannot land while any src file is JavaScript.
|
|
736
|
+
# how: Rename to .ts, add types at the module boundary, leave the body alone.
|
|
737
|
+
# scope: { path: "src/**", extensions: [.js, .jsx] }
|
|
738
|
+
# perimeter: file
|
|
739
|
+
# objectives:
|
|
740
|
+
# is-ts:
|
|
741
|
+
# intent: A JavaScript file is one the strict compiler never checks.
|
|
742
|
+
# holdout: file
|
|
743
|
+
# match: { path: { file: "\\\\.(js|jsx)$" } }
|
|
744
|
+
# probes: { fires: [{ path: src/legacy/util.js }], ignores: [{ path: src/util.ts }] }
|
|
745
|
+
# staleAfter: 14d
|
|
746
|
+
# onComplete: remove
|
|
747
|
+
|
|
338
748
|
# The repository. One open root, reaching itself and the runtime; run
|
|
339
749
|
# \`architecture check\` to see what else it reaches, and write that down here.
|
|
340
750
|
# https://dataquail.github.io/goodbones/architecture-rules/manifest/imports/
|
|
@@ -403,45 +813,112 @@ export const migrate = (repoRoot, configFilename) => Effect.gen(function* () {
|
|
|
403
813
|
`Then delete ${path.basename(from)}: a repository with two manifests is refused.`,
|
|
404
814
|
]);
|
|
405
815
|
});
|
|
816
|
+
// The campaign commands, answered by the family the host was composed with:
|
|
817
|
+
// `campaigns` alone is the status table, `status --changed` the nudge,
|
|
818
|
+
// `attest` and `note` write a sector's record, `history` replays the ledgers;
|
|
819
|
+
// `objectives clear` and `objectives concede` write the ledgers. Without
|
|
820
|
+
// `@goodbones/campaigns` installed, each says what to install.
|
|
821
|
+
const verbContext = (policy, defaultRoots, argv, configFilename) => ({
|
|
822
|
+
policy,
|
|
823
|
+
defaultRoots,
|
|
824
|
+
argv,
|
|
825
|
+
evaluate: (roots) => collectFindings(policy, roots).campaigns,
|
|
826
|
+
reload: (repoRoot) => loadPolicyFromFile(repoRoot, configFilename),
|
|
827
|
+
manifestPath: manifestPathOf(policy.repoRoot, configFilename),
|
|
828
|
+
});
|
|
829
|
+
export const objectives = (policy, defaultRoots, argv) => policy.campaigns.objectives(verbContext(policy, defaultRoots, argv));
|
|
830
|
+
export const campaigns = (policy, defaultRoots, argv, configFilename) => policy.campaigns.campaigns(verbContext(policy, defaultRoots, argv, configFilename));
|
|
831
|
+
// The commands that judge a campaign, and so ask its report source.
|
|
832
|
+
const READS_REPORTS = new Set([
|
|
833
|
+
"check",
|
|
834
|
+
"conformance",
|
|
835
|
+
"baseline",
|
|
836
|
+
"campaigns",
|
|
837
|
+
"objectives",
|
|
838
|
+
"explain",
|
|
839
|
+
]);
|
|
406
840
|
export const run = (repoRoot, argv,
|
|
407
841
|
// From ARCHITECTURE_CONFIG. Absent, the manifest is discovered by name.
|
|
408
842
|
configFilename) => Effect.gen(function* () {
|
|
409
843
|
const [command = "check", ...rest] = argv;
|
|
410
|
-
// The
|
|
844
|
+
// The three commands that write a manifest rather than read one.
|
|
411
845
|
if (command === "init")
|
|
412
846
|
return yield* init(repoRoot);
|
|
413
847
|
if (command === "migrate")
|
|
414
848
|
return yield* migrate(repoRoot, configFilename);
|
|
849
|
+
if (command === "infer") {
|
|
850
|
+
yield* infer(repoRoot, rest, configFilename);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
// `--against <file>` measures the tree against a manifest other than the
|
|
854
|
+
// repository's own — the target the team is moving toward. Only
|
|
855
|
+
// `conformance` takes it: a `check` against a manifest nobody is held to
|
|
856
|
+
// yet would fail for no one's benefit.
|
|
857
|
+
const againstAt = rest.indexOf("--against");
|
|
858
|
+
const against = againstAt === -1 ? undefined : rest[againstAt + 1];
|
|
859
|
+
if (againstAt !== -1 && (against === undefined || against.startsWith("--"))) {
|
|
860
|
+
return yield* Effect.fail(fail("--against needs a manifest path"));
|
|
861
|
+
}
|
|
862
|
+
if (against !== undefined && command !== "conformance") {
|
|
863
|
+
return yield* Effect.fail(fail(`--against is a \`conformance\` flag; ${command} does not take it`));
|
|
864
|
+
}
|
|
865
|
+
const manifestFilename = against ?? configFilename;
|
|
415
866
|
const policy = yield* Effect.tryPromise({
|
|
416
|
-
try: () => loadPolicyFromFile(repoRoot,
|
|
867
|
+
try: () => loadPolicyFromFile(repoRoot, manifestFilename),
|
|
417
868
|
catch: (cause) => fail(String(cause)),
|
|
418
869
|
});
|
|
419
870
|
yield* Effect.sync(() => {
|
|
420
871
|
for (const notice of policy.notices)
|
|
421
872
|
process.stderr.write(`deprecated: ${notice}\n`);
|
|
422
873
|
});
|
|
423
|
-
|
|
874
|
+
// Every `report` a campaign names is read now, before any file asks:
|
|
875
|
+
// a term's several commands run at once rather than one after another
|
|
876
|
+
// the first time a campaign selects a file. What cannot be read is kept
|
|
877
|
+
// for the campaign to report; a report that does not parse is refused.
|
|
878
|
+
if (READS_REPORTS.has(command)) {
|
|
879
|
+
yield* Effect.tryPromise({
|
|
880
|
+
try: () => policy.campaigns.readReports(policy),
|
|
881
|
+
catch: (cause) => fail(String(cause)),
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
const json = rest.includes("--json");
|
|
885
|
+
const positional = rest.filter((argument, index) => argument !== "--json" &&
|
|
886
|
+
argument !== "--against" &&
|
|
887
|
+
(againstAt === -1 || index !== againstAt + 1));
|
|
888
|
+
const roots = positional.length > 0 ? positional : ["packages"];
|
|
424
889
|
switch (command) {
|
|
890
|
+
case "campaigns":
|
|
891
|
+
return yield* campaigns(policy, ["packages"], rest, configFilename);
|
|
892
|
+
case "objectives":
|
|
893
|
+
return yield* objectives(policy, ["packages"], rest);
|
|
425
894
|
case "check":
|
|
426
|
-
return yield* check(policy, roots
|
|
895
|
+
return yield* check(policy, roots, {
|
|
896
|
+
format: json ? "json" : "text",
|
|
897
|
+
manifestPath: manifestPathOf(repoRoot, configFilename),
|
|
898
|
+
});
|
|
899
|
+
case "conformance":
|
|
900
|
+
return yield* conformance(policy, roots, {
|
|
901
|
+
format: json ? "json" : "text",
|
|
902
|
+
manifestPath: manifestPathOf(repoRoot, manifestFilename),
|
|
903
|
+
});
|
|
427
904
|
case "baseline":
|
|
428
905
|
return yield* writeBaseline(policy, roots);
|
|
429
906
|
case "explain": {
|
|
430
|
-
const [file] =
|
|
907
|
+
const [file, ...explainRoots] = positional;
|
|
431
908
|
if (file === undefined)
|
|
432
909
|
return yield* Effect.fail(fail("explain needs a file path"));
|
|
433
|
-
return yield* explain(policy, file);
|
|
910
|
+
return yield* explain(policy, file, explainRoots.length > 0 ? explainRoots : ["packages"]);
|
|
434
911
|
}
|
|
435
912
|
case "coverage":
|
|
436
913
|
return yield* coverage(policy, roots);
|
|
437
914
|
case "facts": {
|
|
438
|
-
const [file] =
|
|
915
|
+
const [file] = positional;
|
|
439
916
|
if (file === undefined)
|
|
440
917
|
return yield* Effect.fail(fail("facts needs a file path"));
|
|
441
|
-
return yield* facts(policy, file,
|
|
918
|
+
return yield* facts(policy, file, json ? "json" : "text");
|
|
442
919
|
}
|
|
443
920
|
default:
|
|
444
|
-
return yield* Effect.fail(fail(`unknown command "${command}". Try: check | baseline | coverage | explain <file> | facts <file> [--json] | init | migrate`));
|
|
921
|
+
return yield* Effect.fail(fail(`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`));
|
|
445
922
|
}
|
|
446
923
|
});
|
|
447
924
|
export const fingerprint = fingerprintOf;
|