@goodbones/cli 0.1.0-beta.1 → 0.1.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/dts/config-loader.d.ts +6 -1
- package/build/dts/config-loader.d.ts.map +1 -1
- package/build/dts/infer.d.ts +34 -0
- package/build/dts/infer.d.ts.map +1 -0
- package/build/dts/run.d.ts +78 -5
- package/build/dts/run.d.ts.map +1 -1
- package/build/esm/config-loader.js +52 -5
- 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/main.js +1 -1
- package/build/esm/main.js.map +1 -1
- package/build/esm/run.js +948 -66
- package/build/esm/run.js.map +1 -1
- package/package.json +4 -3
- package/src/config-loader.ts +65 -5
- package/src/infer.ts +511 -0
- package/src/main.ts +3 -1
- package/src/run.ts +1315 -86
package/build/esm/run.js
CHANGED
|
@@ -1,38 +1,74 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
4
|
import * as path from "node:path";
|
|
3
|
-
import { baselineOf, coverageOf,
|
|
5
|
+
import { allowed, baselineOf, campaignsSelecting, CONFORMANCE_MEASURES, coverageOf, cyclesIn, decodeBaseline, decodeManifest, EMPTY_BASELINE, entryOf, evaluateCampaigns, evaluateGraph, evaluateMemberSite, evaluateResolvedEdge, evaluateSelectedBindings, evaluateStructure, evaluateSurface, explainCampaign, exportRulesSelecting, findManifestFile, fingerprintOf, formatManifestYaml, formatMessage, fractionsOf, hasGraphRules, heightOf, isComplete, isStalled, leafTermsOf, ledgerArithmeticHolds, ledgerOf, listSourceFiles, makeBaselineFilter, MANIFEST_FILENAMES, MANIFEST_SCHEMA_ID, memberRulesSelecting, progressOf, pruned, readManifestFile, reconcile, reportSpecsOf, requiredSiblingsOf, residueOf, rulesSelecting, serializeBaseline, serializeLedger, slackOf, SNAPSHOT_VERSION, staleEntriesOf, surfaceRulesSelecting, vacancyOf, } from "@goodbones/core";
|
|
4
6
|
import * as Effect from "effect/Effect";
|
|
5
7
|
import * as Result from "effect/Result";
|
|
6
|
-
import { loadPolicyFromFile } from "./config-loader.js";
|
|
8
|
+
import { loadPolicyFromFile, manifestPathOf } from "./config-loader.js";
|
|
7
9
|
import { buildGraph } from "./graph.js";
|
|
10
|
+
import { infer } from "./infer.js";
|
|
8
11
|
import { sourceFactsOf } from "./source-facts.js";
|
|
9
12
|
const fail = (message) => ({ _tag: "CliFailure", message });
|
|
10
|
-
export const collectFindings = (policy, roots) => {
|
|
13
|
+
export const collectFindings = (policy, roots, options = {}) => {
|
|
11
14
|
const files = listSourceFiles(policy.repoRoot, roots, policy.languages);
|
|
12
15
|
const violations = [];
|
|
16
|
+
const campaigns = [];
|
|
13
17
|
const unresolved = [];
|
|
14
|
-
|
|
15
|
-
//
|
|
18
|
+
const edges = [];
|
|
19
|
+
// Each file is read and parsed at most once, whether the per-file
|
|
20
|
+
// families, the graph pass or a campaign asks first.
|
|
21
|
+
const texts = new Map();
|
|
22
|
+
const textOf = (file) => {
|
|
23
|
+
const cached = texts.get(file);
|
|
24
|
+
if (cached !== undefined)
|
|
25
|
+
return cached;
|
|
26
|
+
const text = readFileSync(path.join(policy.repoRoot, file), "utf8");
|
|
27
|
+
texts.set(file, text);
|
|
28
|
+
return text;
|
|
29
|
+
};
|
|
16
30
|
const parsed = new Map();
|
|
17
31
|
const factsOf = (file) => {
|
|
18
32
|
const cached = parsed.get(file);
|
|
19
33
|
if (cached !== undefined)
|
|
20
34
|
return cached;
|
|
21
|
-
const facts =
|
|
35
|
+
const facts = policy.extractor.factsOf(file, textOf(file));
|
|
22
36
|
parsed.set(file, facts);
|
|
23
37
|
return facts;
|
|
24
38
|
};
|
|
25
39
|
// The graph is the whole repository resolved at once — the one question no
|
|
26
40
|
// per-file adapter can ask — and is built only when a rule needs it.
|
|
27
|
-
|
|
28
|
-
|
|
41
|
+
const graph = options.graph === true || hasGraphRules(policy.graph)
|
|
42
|
+
? buildGraph(files, policy.resolver, factsOf)
|
|
43
|
+
: null;
|
|
44
|
+
if (graph !== null && hasGraphRules(policy.graph)) {
|
|
45
|
+
for (const violation of evaluateGraph(policy.graph, graph))
|
|
29
46
|
violations.push(violation);
|
|
30
|
-
}
|
|
31
47
|
}
|
|
32
48
|
for (const file of files) {
|
|
33
49
|
for (const violation of evaluateStructure(policy.structure, policy.fileSystem, file)) {
|
|
34
50
|
violations.push(violation);
|
|
35
51
|
}
|
|
52
|
+
// A campaign selects by its scope. The file is parsed by the scope's
|
|
53
|
+
// matcher once, and only when a term of some selected campaign reads the
|
|
54
|
+
// syntax tree.
|
|
55
|
+
const selectedCampaigns = campaignsSelecting(policy.campaignRules, file);
|
|
56
|
+
if (selectedCampaigns.length > 0) {
|
|
57
|
+
const text = textOf(file);
|
|
58
|
+
const needsSyntax = selectedCampaigns.some((rule) => leafTermsOf(rule.detect).some((leaf) => leaf === "syntax" || leaf === "report" || leaf === "fn"));
|
|
59
|
+
for (const hit of evaluateCampaigns(selectedCampaigns, {
|
|
60
|
+
file,
|
|
61
|
+
text,
|
|
62
|
+
facts: factsOf(file),
|
|
63
|
+
resolver: policy.resolver,
|
|
64
|
+
fileSystem: policy.fileSystem,
|
|
65
|
+
syntax: needsSyntax ? policy.syntax.parse(file, text) : null,
|
|
66
|
+
functions: policy.functions,
|
|
67
|
+
reports: policy.reports,
|
|
68
|
+
})) {
|
|
69
|
+
campaigns.push(hit);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
36
72
|
const selectedImports = rulesSelecting(policy.importRules, file);
|
|
37
73
|
const selectedExports = exportRulesSelecting(policy.exportRules, file);
|
|
38
74
|
const selectedMembers = memberRulesSelecting(policy.memberRules, file);
|
|
@@ -54,17 +90,23 @@ export const collectFindings = (policy, roots) => {
|
|
|
54
90
|
}
|
|
55
91
|
for (const specifier of facts.specifiers) {
|
|
56
92
|
const edge = { importer: file, specifier };
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (
|
|
93
|
+
// A file no import rule selects never needs its imports resolved, which
|
|
94
|
+
// is what keeps resolution off the hot path for the bulk of the repo.
|
|
95
|
+
if (selectedImports.length > 0) {
|
|
96
|
+
const resolved = policy.resolver.resolve(file, specifier);
|
|
97
|
+
if (Result.isFailure(resolved)) {
|
|
98
|
+
if (policy.config.resolve.unresolved === "off")
|
|
99
|
+
continue;
|
|
100
|
+
if (policy.ignoreUnresolved.some((pattern) => pattern.test(specifier)))
|
|
101
|
+
continue;
|
|
102
|
+
unresolved.push({ file, specifier, detail: resolved.failure.detail });
|
|
62
103
|
continue;
|
|
63
|
-
|
|
64
|
-
|
|
104
|
+
}
|
|
105
|
+
edges.push({ importer: file, target: resolved.success });
|
|
106
|
+
for (const violation of evaluateResolvedEdge(selectedImports, file, resolved.success)) {
|
|
107
|
+
violations.push(violation);
|
|
108
|
+
}
|
|
65
109
|
}
|
|
66
|
-
for (const violation of imported.success)
|
|
67
|
-
violations.push(violation);
|
|
68
110
|
const bound = facts.bindings.get(specifier) ?? [];
|
|
69
111
|
const exported = evaluateSelectedBindings(selectedExports, policy.resolver, {
|
|
70
112
|
...edge,
|
|
@@ -76,7 +118,7 @@ export const collectFindings = (policy, roots) => {
|
|
|
76
118
|
}
|
|
77
119
|
}
|
|
78
120
|
}
|
|
79
|
-
return { violations, unresolved, files: files.length };
|
|
121
|
+
return { violations, campaigns, unresolved, files: files.length, edges, graph };
|
|
80
122
|
};
|
|
81
123
|
const baselinePathOf = (policy) => policy.config.baseline === undefined
|
|
82
124
|
? null
|
|
@@ -97,52 +139,507 @@ const report = (lines) => Effect.sync(() => {
|
|
|
97
139
|
process.stdout.write(`${line}\n`);
|
|
98
140
|
});
|
|
99
141
|
const describe = (violation) => ` ${violation.file}\n ${formatMessage(violation)}`;
|
|
100
|
-
|
|
101
|
-
|
|
142
|
+
// Each campaign's hits against its ledger. A campaign with no ledger has
|
|
143
|
+
// every hit new; one with no hits and no ledger has nothing to say.
|
|
144
|
+
const campaignReportsOf = (policy, hits) => policy.campaignRules.map((rule) => {
|
|
145
|
+
const own = hits.filter((hit) => hit.campaign === rule.id).map((hit) => hit.violation);
|
|
146
|
+
const ledger = policy.ledgers.get(rule.id);
|
|
147
|
+
if (ledger === undefined) {
|
|
148
|
+
return {
|
|
149
|
+
id: rule.id,
|
|
150
|
+
count: own.length,
|
|
151
|
+
new: [...new Set(own.map(entryOf))].sort(),
|
|
152
|
+
stale: [],
|
|
153
|
+
drifted: 0,
|
|
154
|
+
missingLedger: true,
|
|
155
|
+
arithmetic: true,
|
|
156
|
+
complete: own.length === 0,
|
|
157
|
+
stalled: false,
|
|
158
|
+
onComplete: rule.onComplete,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const state = reconcile(ledger, own, rule.unit);
|
|
162
|
+
return {
|
|
163
|
+
id: rule.id,
|
|
164
|
+
count: own.length,
|
|
165
|
+
new: [...new Set(state.unrecorded.map(entryOf))].sort(),
|
|
166
|
+
stale: state.stale,
|
|
167
|
+
drifted: state.drifted.length,
|
|
168
|
+
missingLedger: false,
|
|
169
|
+
arithmetic: ledgerArithmeticHolds(ledger),
|
|
170
|
+
complete: isComplete(ledger) && own.length === 0,
|
|
171
|
+
stalled: isStalled(rule, ledger, policy.now),
|
|
172
|
+
onComplete: rule.onComplete,
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
// Whether a campaign hit is carried by its ledger — exactly, or by anchor.
|
|
176
|
+
const ledgeredFilter = (policy, hits) => {
|
|
177
|
+
const carried = new Set();
|
|
178
|
+
for (const rule of policy.campaignRules) {
|
|
179
|
+
const ledger = policy.ledgers.get(rule.id);
|
|
180
|
+
if (ledger === undefined)
|
|
181
|
+
continue;
|
|
182
|
+
const own = hits.filter((hit) => hit.campaign === rule.id).map((hit) => hit.violation);
|
|
183
|
+
for (const one of reconcile(ledger, own, rule.unit).ledgered)
|
|
184
|
+
carried.add(one);
|
|
185
|
+
}
|
|
186
|
+
return (hit) => carried.has(hit.violation);
|
|
187
|
+
};
|
|
188
|
+
// Why a campaign report is not ok, in the order `check` explains it.
|
|
189
|
+
const campaignFailuresOf = (campaigns) => [
|
|
190
|
+
...campaigns.filter((one) => one.stale.length > 0).map(() => "stale ledger entries"),
|
|
191
|
+
...campaigns.filter((one) => !one.arithmetic).map(() => "ledger arithmetic does not hold"),
|
|
192
|
+
...campaigns
|
|
193
|
+
.filter((one) => one.missingLedger && one.count > 0)
|
|
194
|
+
.map((one) => `campaign ${one.id} has no ledger`),
|
|
195
|
+
...campaigns
|
|
196
|
+
.filter((one) => !one.missingLedger && one.new.length > 0)
|
|
197
|
+
.map(() => "unrecorded campaign growth"),
|
|
198
|
+
...campaigns
|
|
199
|
+
.filter((one) => one.complete && !one.missingLedger && one.onComplete === "remove")
|
|
200
|
+
.map((one) => `campaign ${one.id} is complete and declared onComplete: remove`),
|
|
201
|
+
];
|
|
202
|
+
const COVERAGE_FAMILIES = [
|
|
203
|
+
"imports",
|
|
204
|
+
"structure",
|
|
205
|
+
"members",
|
|
206
|
+
"surface",
|
|
207
|
+
"graph",
|
|
208
|
+
];
|
|
209
|
+
const sha256Of = (file) => {
|
|
210
|
+
try {
|
|
211
|
+
return createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return "";
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
export const checkReport = (policy, roots, manifestPath) => reportOf(policy, roots, manifestPath, collectFindings(policy, roots)).report;
|
|
218
|
+
const measuresOf = (policy, files, edges) => {
|
|
219
|
+
// Slack is measured over the walked files as well as the edges: an
|
|
220
|
+
// allowlist that selects no file is vacant, and its entries are reported as
|
|
221
|
+
// that rather than as lines nobody needs.
|
|
222
|
+
const { concentration, slack } = slackOf(policy.importRules, edges, files);
|
|
223
|
+
return {
|
|
224
|
+
residue: residueOf(policy, files),
|
|
225
|
+
vacant: vacancyOf(policy.importRules, files),
|
|
226
|
+
slack,
|
|
227
|
+
concentration,
|
|
228
|
+
};
|
|
229
|
+
};
|
|
230
|
+
// A fragment entry is concentrated when it is used at fewer than half the
|
|
231
|
+
// nodes granted it — the text report's threshold, and the ceiling's.
|
|
232
|
+
const isConcentrated = (one) => one.usedAt * 2 < one.of;
|
|
233
|
+
const countsOf = (measures) => ({
|
|
234
|
+
residue: measures.residue.files.length,
|
|
235
|
+
vacant: measures.vacant.length,
|
|
236
|
+
slack: measures.slack.length,
|
|
237
|
+
concentration: measures.concentration.filter(isConcentrated).length,
|
|
238
|
+
});
|
|
239
|
+
const reportOf = (policy, roots, manifestPath, findings) => {
|
|
102
240
|
const baseline = readBaseline(policy);
|
|
103
|
-
const reportable = unbaselined(baseline, findings.violations);
|
|
104
241
|
const stale = staleEntriesOf(baseline, findings.violations);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
242
|
+
const { isBaselined } = makeBaselineFilter(baseline);
|
|
243
|
+
const isLedgered = ledgeredFilter(policy, findings.campaigns);
|
|
244
|
+
const violations = [
|
|
245
|
+
...findings.violations.map((violation) => ({
|
|
246
|
+
...violation,
|
|
247
|
+
fingerprint: fingerprintOf(violation),
|
|
248
|
+
baselined: isBaselined(violation),
|
|
249
|
+
ledgered: false,
|
|
250
|
+
})),
|
|
251
|
+
...findings.campaigns.map((hit) => ({
|
|
252
|
+
...hit.violation,
|
|
253
|
+
fingerprint: fingerprintOf(hit.violation),
|
|
254
|
+
baselined: false,
|
|
255
|
+
ledgered: isLedgered(hit),
|
|
256
|
+
})),
|
|
257
|
+
];
|
|
258
|
+
const campaigns = campaignReportsOf(policy, findings.campaigns);
|
|
259
|
+
// The floors. A policy states how much of the tree it reaches, per
|
|
260
|
+
// family; falling under is a policy that quietly stopped covering files.
|
|
261
|
+
const floors = policy.config.limits?.coverage ?? {};
|
|
262
|
+
const files = listSourceFiles(policy.repoRoot, roots, policy.languages);
|
|
263
|
+
const found = coverageOf(policy, files);
|
|
264
|
+
const covered = (family) => family === "structure" ? found.structure.enumerated : found[family].covered;
|
|
265
|
+
const coverage = Object.fromEntries(COVERAGE_FAMILIES.map((family) => {
|
|
266
|
+
const floor = floors[family];
|
|
267
|
+
return [
|
|
268
|
+
family,
|
|
269
|
+
{
|
|
270
|
+
covered: covered(family),
|
|
271
|
+
total: found.files,
|
|
272
|
+
...(floor === undefined ? {} : { floor }),
|
|
273
|
+
},
|
|
274
|
+
];
|
|
275
|
+
}));
|
|
276
|
+
const shortfalls = shortfallsOf(coverage);
|
|
277
|
+
// The ceilings. What no family reaches, what no file is under and what
|
|
278
|
+
// nothing imports through are each a count the policy may hold itself
|
|
279
|
+
// to; rising over one is a manifest that quietly widened.
|
|
280
|
+
const ceilings = policy.config.limits?.conformance ?? {};
|
|
281
|
+
const measures = measuresOf(policy, files, findings.edges);
|
|
282
|
+
const counts = countsOf(measures);
|
|
283
|
+
const conformance = Object.fromEntries(CONFORMANCE_MEASURES.map((measure) => {
|
|
284
|
+
const ceiling = ceilings[measure];
|
|
285
|
+
return [measure, { count: counts[measure], ...(ceiling === undefined ? {} : { ceiling }) }];
|
|
286
|
+
}));
|
|
287
|
+
const excesses = excessesOf(conformance);
|
|
288
|
+
const reportable = violations.filter((one) => !one.baselined && !one.ledgered).length;
|
|
289
|
+
const report = {
|
|
290
|
+
version: 1,
|
|
291
|
+
files: findings.files,
|
|
292
|
+
roots,
|
|
293
|
+
ok: reportable === 0 &&
|
|
294
|
+
findings.unresolved.length === 0 &&
|
|
295
|
+
stale.length === 0 &&
|
|
296
|
+
shortfalls.length === 0 &&
|
|
297
|
+
excesses.length === 0 &&
|
|
298
|
+
campaignFailuresOf(campaigns).length === 0,
|
|
299
|
+
manifest: {
|
|
300
|
+
path: path.relative(policy.repoRoot, manifestPath).replaceAll(path.sep, "/"),
|
|
301
|
+
sha256: sha256Of(manifestPath),
|
|
302
|
+
},
|
|
303
|
+
violations,
|
|
304
|
+
unresolved: findings.unresolved,
|
|
305
|
+
stale,
|
|
306
|
+
coverage,
|
|
307
|
+
conformance,
|
|
308
|
+
adoption: {
|
|
309
|
+
unrestricted: policy.adoption.unrestricted,
|
|
310
|
+
partial: policy.adoption.partial,
|
|
311
|
+
},
|
|
312
|
+
campaigns,
|
|
313
|
+
};
|
|
314
|
+
return { report, measures, files };
|
|
315
|
+
};
|
|
316
|
+
const shortfallsOf = (coverage) => COVERAGE_FAMILIES.flatMap((family) => {
|
|
317
|
+
const { covered, floor, total } = coverage[family];
|
|
318
|
+
const actual = total === 0 ? 1 : covered / total;
|
|
319
|
+
return floor === undefined || actual >= floor ? [] : [{ family, actual, floor }];
|
|
320
|
+
});
|
|
321
|
+
const excessesOf = (conformance) => CONFORMANCE_MEASURES.flatMap((measure) => {
|
|
322
|
+
const { ceiling, count } = conformance[measure];
|
|
323
|
+
return ceiling === undefined || count <= ceiling ? [] : [{ measure, count, ceiling }];
|
|
324
|
+
});
|
|
325
|
+
const failureOf = (report, shortfalls) => {
|
|
326
|
+
if (report.stale.length > 0)
|
|
327
|
+
return fail("stale baseline entries");
|
|
328
|
+
const [campaignFailure] = campaignFailuresOf(report.campaigns);
|
|
329
|
+
if (campaignFailure !== undefined)
|
|
330
|
+
return fail(campaignFailure);
|
|
331
|
+
if (shortfalls.length > 0)
|
|
332
|
+
return fail("coverage below floor");
|
|
333
|
+
if (excessesOf(report.conformance).length > 0)
|
|
334
|
+
return fail("conformance above ceiling");
|
|
335
|
+
if (report.ok)
|
|
336
|
+
return null;
|
|
337
|
+
return fail("architecture violations");
|
|
338
|
+
};
|
|
339
|
+
// What each measure counts, as the failure names it.
|
|
340
|
+
const MEASURE_NOUNS = {
|
|
341
|
+
residue: ["file no family reaches", "files no family reaches"],
|
|
342
|
+
vacant: ["node no file is under", "nodes no file is under"],
|
|
343
|
+
slack: ["allowance nothing imports through", "allowances nothing imports through"],
|
|
344
|
+
concentration: [
|
|
345
|
+
"allowance used at fewer than half the nodes granted",
|
|
346
|
+
"allowances used at fewer than half the nodes granted",
|
|
347
|
+
],
|
|
348
|
+
};
|
|
349
|
+
const describeExcess = (one) => {
|
|
350
|
+
const [singular, plural] = MEASURE_NOUNS[one.measure];
|
|
351
|
+
return ` ${one.measure}: ${count(one.count, singular, plural)}, ceiling ${String(one.ceiling)}`;
|
|
352
|
+
};
|
|
353
|
+
// A campaign hit, with the campaign's `how` as its instruction.
|
|
354
|
+
const describeHit = (violation) => ` ${violation.file}${violation.subject === null ? "" : ` (${violation.subject})`}\n ${formatMessage(violation)}`;
|
|
355
|
+
const renderCampaigns = (report) => {
|
|
356
|
+
const hits = report.violations.filter((one) => one.kind === "campaign");
|
|
357
|
+
return report.campaigns.flatMap((campaign) => {
|
|
358
|
+
const rule = `campaign/${campaign.id}`;
|
|
359
|
+
const fresh = new Set(campaign.new);
|
|
360
|
+
const own = hits.filter((one) => one.ruleName === rule && fresh.has(entryOf(one)));
|
|
361
|
+
if (campaign.missingLedger && campaign.count > 0) {
|
|
362
|
+
return [
|
|
363
|
+
"",
|
|
364
|
+
`campaign ${campaign.id}: ${count(campaign.count, "hit")} and no ledger. Record them before they count as growth:`,
|
|
365
|
+
"",
|
|
366
|
+
` architecture campaigns init ${campaign.id}`,
|
|
367
|
+
];
|
|
368
|
+
}
|
|
369
|
+
const complete = campaign.complete && !campaign.missingLedger;
|
|
370
|
+
return [
|
|
371
|
+
...(campaign.new.length === 0
|
|
372
|
+
? []
|
|
373
|
+
: [
|
|
374
|
+
"",
|
|
375
|
+
`campaign ${campaign.id}: ${count(campaign.new.length, "new hit")} the ledger does not carry. Fix them, or record why the count may rise:`,
|
|
376
|
+
...own.map(describeHit),
|
|
377
|
+
"",
|
|
378
|
+
` architecture campaigns allow ${campaign.id} --reason "<why>"`,
|
|
379
|
+
]),
|
|
380
|
+
...(campaign.stale.length === 0
|
|
381
|
+
? []
|
|
382
|
+
: [
|
|
383
|
+
"",
|
|
384
|
+
`campaign ${campaign.id}: ${count(campaign.stale.length, "ledger entry", "ledger entries")} no longer fire. The code was fixed; prune them:`,
|
|
385
|
+
...campaign.stale.map((entry) => ` ${entry}`),
|
|
386
|
+
"",
|
|
387
|
+
` architecture campaigns prune ${campaign.id}`,
|
|
388
|
+
]),
|
|
389
|
+
...(campaign.arithmetic
|
|
390
|
+
? []
|
|
391
|
+
: [
|
|
392
|
+
"",
|
|
393
|
+
`campaign ${campaign.id}: the ledger does not add up (entries ≠ initial + allowed − fixed). An entry was added by hand; remove it, or record it with \`campaigns allow\`.`,
|
|
394
|
+
]),
|
|
395
|
+
...(complete && campaign.onComplete === "remove"
|
|
396
|
+
? [
|
|
397
|
+
"",
|
|
398
|
+
`campaign ${campaign.id} is complete and declares onComplete: remove. Delete it from the manifest, and its ledger.`,
|
|
399
|
+
]
|
|
400
|
+
: []),
|
|
401
|
+
...(campaign.stalled
|
|
402
|
+
? [
|
|
403
|
+
"",
|
|
404
|
+
`notice: campaign ${campaign.id} has stalled — no entry has left its ledger within its staleAfter.`,
|
|
405
|
+
]
|
|
406
|
+
: []),
|
|
407
|
+
...(complete && campaign.onComplete === "keep"
|
|
408
|
+
? ["", `notice: campaign ${campaign.id} is complete, and stays as a guard.`]
|
|
409
|
+
: []),
|
|
410
|
+
];
|
|
411
|
+
});
|
|
412
|
+
};
|
|
413
|
+
const renderText = (report) => {
|
|
414
|
+
const reportable = report.violations.filter((one) => one.kind !== "campaign" && !one.baselined);
|
|
415
|
+
const carried = report.violations.filter((one) => one.kind !== "campaign").length - reportable.length;
|
|
416
|
+
const shortfalls = shortfallsOf(report.coverage);
|
|
417
|
+
const excesses = excessesOf(report.conformance);
|
|
418
|
+
return [
|
|
419
|
+
...reportable.map(describe),
|
|
420
|
+
...report.unresolved.map((one) => ` unresolved: ${one.file} → ${one.specifier} (${one.detail})`),
|
|
109
421
|
"",
|
|
110
|
-
`${String(
|
|
422
|
+
`${String(report.files)} files, ${String(reportable.length)} violations` +
|
|
111
423
|
(carried > 0 ? `, ${String(carried)} carried by the baseline` : ""),
|
|
112
|
-
]);
|
|
113
|
-
if (stale.length > 0) {
|
|
114
424
|
// The ratchet: a fixed violation must leave the baseline, or the floor
|
|
115
425
|
// 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
|
-
|
|
426
|
+
...(report.stale.length === 0
|
|
427
|
+
? []
|
|
428
|
+
: [
|
|
429
|
+
"",
|
|
430
|
+
`${String(report.stale.length)} baseline entries no longer fire. The code was fixed; prune them:`,
|
|
431
|
+
...report.stale.map((entry) => ` ${entry}`),
|
|
432
|
+
"",
|
|
433
|
+
" architecture baseline # rewrites the file from what still fires",
|
|
434
|
+
]),
|
|
435
|
+
...(shortfalls.length === 0
|
|
436
|
+
? []
|
|
437
|
+
: [
|
|
438
|
+
"",
|
|
439
|
+
"coverage is below the floor the policy states for itself:",
|
|
440
|
+
...shortfalls.map((one) => ` ${one.family}: ${percent(one.actual)} covered, floor ${percent(one.floor)}`),
|
|
441
|
+
"",
|
|
442
|
+
" architecture coverage # which files no rule reaches",
|
|
443
|
+
]),
|
|
444
|
+
...(excesses.length === 0
|
|
445
|
+
? []
|
|
446
|
+
: [
|
|
447
|
+
"",
|
|
448
|
+
"conformance is above the ceiling the policy states for itself:",
|
|
449
|
+
...excesses.map(describeExcess),
|
|
450
|
+
"",
|
|
451
|
+
" architecture conformance # which files, nodes and allowances",
|
|
452
|
+
]),
|
|
453
|
+
...renderCampaigns(report),
|
|
454
|
+
];
|
|
455
|
+
};
|
|
456
|
+
export const check = (policy, roots, options) => Effect.gen(function* () {
|
|
457
|
+
const report_ = checkReport(policy, roots, options.manifestPath);
|
|
458
|
+
// JSON is one object on stdout and nothing else there; the failure, when
|
|
459
|
+
// there is one, is a sentence on stderr and the exit code, as in text.
|
|
460
|
+
yield* report(options.format === "json" ? [JSON.stringify(report_, null, 2)] : renderText(report_));
|
|
461
|
+
const failure = failureOf(report_, shortfallsOf(report_.coverage));
|
|
462
|
+
if (failure !== null)
|
|
463
|
+
return yield* Effect.fail(failure);
|
|
144
464
|
});
|
|
145
465
|
const percent = (fraction) => `${String(Math.floor(fraction * 100))}%`;
|
|
466
|
+
const count = (n, noun, plural = `${noun}s`) => `${String(n)} ${n === 1 ? noun : plural}`;
|
|
467
|
+
// The conformance snapshot: `check`'s report grown with what no family
|
|
468
|
+
// reaches, what the allowlists permit and nothing uses, the cycle count and
|
|
469
|
+
// the size of the debt — the whole distance between the tree and the
|
|
470
|
+
// manifest, as one document another run can be compared against. Its shape
|
|
471
|
+
// is the core's `Snapshot`, and the schema published beside the manifest's.
|
|
472
|
+
export const snapshotOf = (policy, roots, manifestPath) => {
|
|
473
|
+
const findings = collectFindings(policy, roots, { graph: true });
|
|
474
|
+
const { files, measures, report: report_ } = reportOf(policy, roots, manifestPath, findings);
|
|
475
|
+
const graph = findings.graph ?? { files, edges: new Map() };
|
|
476
|
+
const heights = heightOf(graph);
|
|
477
|
+
// Leaf edges first. A violation names a target when it is about an edge;
|
|
478
|
+
// the cost of fixing it is roughly how much of the graph stands beneath
|
|
479
|
+
// that target, so the ones nearest the ground come first and a reader
|
|
480
|
+
// starting at the top of the list is starting where a fix stays local.
|
|
481
|
+
// Ties keep the fingerprint order, so the list is the same on every run.
|
|
482
|
+
const heightOfViolation = (one) => heights.get(one.subject ?? "") ?? heights.get(one.file) ?? 0;
|
|
483
|
+
const violations = [...report_.violations].sort((left, right) => {
|
|
484
|
+
const byHeight = heightOfViolation(left) - heightOfViolation(right);
|
|
485
|
+
return byHeight !== 0 ? byHeight : left.fingerprint.localeCompare(right.fingerprint);
|
|
486
|
+
});
|
|
487
|
+
const campaigns = policy.campaignRules.map((rule) => {
|
|
488
|
+
const ledger = policy.ledgers.get(rule.id);
|
|
489
|
+
const own = findings.campaigns.filter((hit) => hit.campaign === rule.id).length;
|
|
490
|
+
const base = {
|
|
491
|
+
id: rule.id,
|
|
492
|
+
...(rule.title === null ? {} : { title: rule.title }),
|
|
493
|
+
...(rule.owner === null ? {} : { owner: rule.owner }),
|
|
494
|
+
initial: own,
|
|
495
|
+
allowed: 0,
|
|
496
|
+
count: own,
|
|
497
|
+
fixed: 0,
|
|
498
|
+
progress: 0,
|
|
499
|
+
lastProgress: new Date(policy.now).toISOString(),
|
|
500
|
+
regressions: 0,
|
|
501
|
+
stalled: false,
|
|
502
|
+
complete: own === 0,
|
|
503
|
+
onComplete: rule.onComplete,
|
|
504
|
+
ledgered: false,
|
|
505
|
+
};
|
|
506
|
+
if (ledger === undefined)
|
|
507
|
+
return base;
|
|
508
|
+
return {
|
|
509
|
+
...base,
|
|
510
|
+
initial: ledger.initial,
|
|
511
|
+
allowed: ledger.regressions.reduce((sum, one) => sum + one.delta, 0),
|
|
512
|
+
count: ledger.entries.length,
|
|
513
|
+
fixed: ledger.fixed,
|
|
514
|
+
progress: progressOf(ledger),
|
|
515
|
+
lastProgress: ledger.lastProgress,
|
|
516
|
+
regressions: ledger.regressions.length,
|
|
517
|
+
stalled: isStalled(rule, ledger, policy.now),
|
|
518
|
+
complete: isComplete(ledger),
|
|
519
|
+
ledgered: true,
|
|
520
|
+
};
|
|
521
|
+
});
|
|
522
|
+
return {
|
|
523
|
+
version: SNAPSHOT_VERSION,
|
|
524
|
+
manifest: report_.manifest,
|
|
525
|
+
roots: report_.roots,
|
|
526
|
+
files: report_.files,
|
|
527
|
+
ok: report_.ok,
|
|
528
|
+
coverage: report_.coverage,
|
|
529
|
+
conformance: report_.conformance,
|
|
530
|
+
residue: measures.residue,
|
|
531
|
+
vacant: measures.vacant,
|
|
532
|
+
violations,
|
|
533
|
+
unresolved: report_.unresolved,
|
|
534
|
+
stale: report_.stale,
|
|
535
|
+
baseline: { size: readBaseline(policy).entries.length },
|
|
536
|
+
cycles: cyclesIn(graph).length,
|
|
537
|
+
slack: measures.slack,
|
|
538
|
+
concentration: measures.concentration,
|
|
539
|
+
adoption: report_.adoption,
|
|
540
|
+
campaigns,
|
|
541
|
+
};
|
|
542
|
+
};
|
|
543
|
+
// The campaigns, stalled and complete first, then by progress.
|
|
544
|
+
const renderCampaignRows = (campaigns) => {
|
|
545
|
+
const width = Math.max(0, ...campaigns.map((one) => one.id.length));
|
|
546
|
+
const state = (one) => !one.ledgered ? "no ledger" : one.complete ? "complete" : one.stalled ? "stalled" : "";
|
|
547
|
+
const ordered = [...campaigns].sort((left, right) => {
|
|
548
|
+
const rank = (one) => one.stalled ? 0 : one.complete && one.ledgered ? 1 : 2;
|
|
549
|
+
const byRank = rank(left) - rank(right);
|
|
550
|
+
return byRank !== 0 ? byRank : left.progress - right.progress;
|
|
551
|
+
});
|
|
552
|
+
return ordered.map((one) => ` ${one.id.padEnd(width)} ${percent(one.progress).padStart(4)} ${String(one.count).padStart(5)} left` +
|
|
553
|
+
` ${String(one.fixed)} fixed ${String(one.allowed)} allowed` +
|
|
554
|
+
(one.owner === undefined ? "" : ` ${one.owner}`) +
|
|
555
|
+
(state(one) === "" ? "" : ` ${state(one)}`));
|
|
556
|
+
};
|
|
557
|
+
const renderSnapshot = (snapshot) => {
|
|
558
|
+
const reportable = snapshot.violations.filter((one) => !one.baselined && !one.ledgered);
|
|
559
|
+
const carried = snapshot.violations.length - reportable.length;
|
|
560
|
+
const row = (family) => {
|
|
561
|
+
const { covered, floor, total } = snapshot.coverage[family];
|
|
562
|
+
const fraction = total === 0 ? 1 : covered / total;
|
|
563
|
+
const mark = floor === undefined
|
|
564
|
+
? ""
|
|
565
|
+
: fraction >= floor
|
|
566
|
+
? ` ≥ ${percent(floor)} ✓`
|
|
567
|
+
: ` < ${percent(floor)} ✗`;
|
|
568
|
+
return ` ${family.padEnd(10)} ${String(covered).padStart(5)}/${String(total)} ${percent(fraction).padStart(4)}${mark}`;
|
|
569
|
+
};
|
|
570
|
+
const section = (title, lines) => [
|
|
571
|
+
"",
|
|
572
|
+
title,
|
|
573
|
+
...lines,
|
|
574
|
+
];
|
|
575
|
+
const vacantWidth = Math.max(0, ...snapshot.vacant.map((one) => one.node.length));
|
|
576
|
+
// The document carries every partly-used fragment entry; the text shows
|
|
577
|
+
// the ones concentrated enough to read as a per-file rule written wide.
|
|
578
|
+
const concentrated = snapshot.concentration.filter(isConcentrated);
|
|
579
|
+
// The ceiling beside each measure that has one, and the ratchet's nudge
|
|
580
|
+
// when the count has fallen under it: a ceiling is lowered by hand.
|
|
581
|
+
const ceilingMark = (measure) => {
|
|
582
|
+
const { ceiling, count: actual } = snapshot.conformance[measure];
|
|
583
|
+
if (ceiling === undefined)
|
|
584
|
+
return "";
|
|
585
|
+
if (actual > ceiling)
|
|
586
|
+
return ` > ${String(ceiling)} ✗`;
|
|
587
|
+
return ` ≤ ${String(ceiling)} ✓${actual < ceiling ? `, lower it to ${String(actual)}` : ""}`;
|
|
588
|
+
};
|
|
589
|
+
return [
|
|
590
|
+
`${String(snapshot.files)} files under ${snapshot.roots.join(", ")}, against ${snapshot.manifest.path}`,
|
|
591
|
+
...section("coverage", COVERAGE_FAMILIES.map(row)),
|
|
592
|
+
...section(`residue: ${count(snapshot.residue.files.length, "file")} no family reaches` +
|
|
593
|
+
(snapshot.residue.folders.length === 0
|
|
594
|
+
? ""
|
|
595
|
+
: `, ${count(snapshot.residue.folders.length, "folder")} wholly`) +
|
|
596
|
+
ceilingMark("residue"), [
|
|
597
|
+
...snapshot.residue.folders.map((folder) => ` ${folder}/`),
|
|
598
|
+
...snapshot.residue.files
|
|
599
|
+
.filter((file) => !snapshot.residue.folders.some((folder) => file.startsWith(`${folder}/`)))
|
|
600
|
+
.map((file) => ` ${file}`),
|
|
601
|
+
]),
|
|
602
|
+
...section(`vacant: ${count(snapshot.vacant.length, "node")} ${snapshot.vacant.length === 1 ? "selects" : "select"} no file` +
|
|
603
|
+
ceilingMark("vacant"), snapshot.vacant.map((one) => ` ${one.node.padEnd(vacantWidth)} ${count(one.allowances, "allowance")}`)),
|
|
604
|
+
...section(`violations: ${count(reportable.length, "reportable")}` +
|
|
605
|
+
(carried > 0 ? `, ${String(carried)} carried by the baseline or a ledger` : "") +
|
|
606
|
+
(snapshot.stale.length > 0
|
|
607
|
+
? `, ${count(snapshot.stale.length, "stale entry", "stale entries")}`
|
|
608
|
+
: "") +
|
|
609
|
+
(reportable.length > 0 ? " — nearest the ground first" : ""), reportable.map(describe)),
|
|
610
|
+
...(snapshot.unresolved.length === 0
|
|
611
|
+
? []
|
|
612
|
+
: section(`unresolved: ${count(snapshot.unresolved.length, "import")} no rule can police`, snapshot.unresolved.map((one) => ` ${one.file} → ${one.specifier} (${one.detail})`))),
|
|
613
|
+
...section(`slack: ${count(snapshot.slack.length, "allowance")} nothing imports through` +
|
|
614
|
+
ceilingMark("slack"), snapshot.slack.map((one) => ` ${one.node}: ${one.kind} ${JSON.stringify(one.entry)}` +
|
|
615
|
+
(one.of === undefined ? "" : ` (via use, at ${count(one.of, "node")})`))),
|
|
616
|
+
...(concentrated.length === 0 && snapshot.conformance.concentration.ceiling === undefined
|
|
617
|
+
? []
|
|
618
|
+
: section(`concentrated: ${count(concentrated.length, "allowance")} used at fewer than half the nodes granted` +
|
|
619
|
+
ceilingMark("concentration"), concentrated.map((one) => ` ${one.fragment}: ${one.kind} ${JSON.stringify(one.entry)} used at ${String(one.usedAt)} of ${count(one.of, "node")}`))),
|
|
620
|
+
...(snapshot.campaigns.length === 0
|
|
621
|
+
? []
|
|
622
|
+
: section(`campaigns: ${count(snapshot.campaigns.length, "campaign")}` +
|
|
623
|
+
(snapshot.campaigns.some((one) => one.stalled)
|
|
624
|
+
? `, ${count(snapshot.campaigns.filter((one) => one.stalled).length, "stalled", "stalled")}`
|
|
625
|
+
: "") +
|
|
626
|
+
(snapshot.campaigns.some((one) => one.complete && one.ledgered)
|
|
627
|
+
? `, ${count(snapshot.campaigns.filter((one) => one.complete && one.ledgered).length, "complete", "complete")}`
|
|
628
|
+
: ""), renderCampaignRows(snapshot.campaigns))),
|
|
629
|
+
"",
|
|
630
|
+
`cycles: ${String(snapshot.cycles)}`,
|
|
631
|
+
`baseline: ${count(snapshot.baseline.size, "entry", "entries")}`,
|
|
632
|
+
`adoption: ${count(snapshot.adoption.unrestricted.length, "unrestricted tier")}, ${count(snapshot.adoption.partial.length, "partial tier")}`,
|
|
633
|
+
];
|
|
634
|
+
};
|
|
635
|
+
// The report of the tree against the manifest. Unlike `check`, it never
|
|
636
|
+
// fails: it is a measurement, and the manifest it measures against may be one
|
|
637
|
+
// the tree was never expected to satisfy yet — `--against` names a target.
|
|
638
|
+
// `ok` in the document says what `check` would have done.
|
|
639
|
+
export const conformance = (policy, roots, options) => Effect.gen(function* () {
|
|
640
|
+
const snapshot = snapshotOf(policy, roots, options.manifestPath);
|
|
641
|
+
yield* report(options.format === "json" ? [JSON.stringify(snapshot, null, 2)] : renderSnapshot(snapshot));
|
|
642
|
+
});
|
|
146
643
|
// How much of the tree the policy reaches. A probe proves a rule can fire;
|
|
147
644
|
// this is whether the files are there to fire on. Reported per family, with the
|
|
148
645
|
// adoption backlog — the tiers that said "not tightened yet" — beneath it.
|
|
@@ -204,7 +701,7 @@ export const explain = (policy, file) => Effect.gen(function* () {
|
|
|
204
701
|
const governing = policy.structure.folders.filter((rule) => rule.folder.some((pattern) => pattern.test(path.dirname(relative))));
|
|
205
702
|
const naming = policy.structure.naming.filter((rule) => rule.file.some((pattern) => pattern.test(relative)) &&
|
|
206
703
|
!rule.fileNot.some((pattern) => pattern.test(relative)));
|
|
207
|
-
const firstSentence = (message) => `${message.split(". ")[0] ?? message}.`;
|
|
704
|
+
const firstSentence = (message) => `${(message.split(". ")[0] ?? message).replace(/\.$/, "")}.`;
|
|
208
705
|
const named = (rule) => ` ${rule.name} — ${firstSentence(rule.message)}`;
|
|
209
706
|
// The families beyond imports and structure: which rules of each speak to
|
|
210
707
|
// this file at all. What they are evaluated against is `facts`' answer.
|
|
@@ -222,6 +719,29 @@ export const explain = (policy, file) => Effect.gen(function* () {
|
|
|
222
719
|
.map((rule) => `${named(rule)} (reach)`),
|
|
223
720
|
];
|
|
224
721
|
const section = (title, lines) => lines.length === 0 ? [] : ["", title, ...lines];
|
|
722
|
+
// Each campaign selecting the file, with its truth table: one line per
|
|
723
|
+
// leaf term and what it answered here, so a detector that "should fire"
|
|
724
|
+
// and does not shows which term is not saying what its author thinks.
|
|
725
|
+
const selectedCampaigns = campaignsSelecting(policy.campaignRules, relative);
|
|
726
|
+
const campaignLines = selectedCampaigns.flatMap((rule) => {
|
|
727
|
+
const at = path.join(policy.repoRoot, relative);
|
|
728
|
+
const text = existsSync(at) ? readFileSync(at, "utf8") : "";
|
|
729
|
+
const input = {
|
|
730
|
+
file: relative,
|
|
731
|
+
text,
|
|
732
|
+
facts: policy.extractor.factsOf(relative, text),
|
|
733
|
+
resolver: policy.resolver,
|
|
734
|
+
fileSystem: policy.fileSystem,
|
|
735
|
+
syntax: policy.syntax.parse(relative, text),
|
|
736
|
+
functions: policy.functions,
|
|
737
|
+
reports: policy.reports,
|
|
738
|
+
};
|
|
739
|
+
const hits = evaluateCampaigns([rule], input);
|
|
740
|
+
return [
|
|
741
|
+
` ${rule.name} — ${firstSentence(rule.why)} (${rule.unit}; ${hits.length === 0 ? "no hit" : count(hits.length, "hit")})`,
|
|
742
|
+
...explainCampaign(rule, input).map((line) => ` ${line.answer ? "✓" : "✗"} ${line.term}${line.count === undefined ? "" : ` (${String(line.count)})`}`),
|
|
743
|
+
];
|
|
744
|
+
});
|
|
225
745
|
yield* report([
|
|
226
746
|
relative,
|
|
227
747
|
"",
|
|
@@ -251,6 +771,7 @@ export const explain = (policy, file) => Effect.gen(function* () {
|
|
|
251
771
|
...section(" vocabulary (members):", vocabulary.map(named)),
|
|
252
772
|
...section(" may export (surface):", surface.map(named)),
|
|
253
773
|
...section(" graph:", graph),
|
|
774
|
+
...section(" campaigns:", campaignLines),
|
|
254
775
|
]);
|
|
255
776
|
});
|
|
256
777
|
// The other half of `explain`. `explain` says which rules select a file; this
|
|
@@ -299,20 +820,381 @@ export const facts = (policy, file, format = "text") => Effect.gen(function* ()
|
|
|
299
820
|
...read.exportSites.map((site) => ` ${site.kind.padEnd(9)} ${site.name} (${site.reexport ? "re-export" : site.declares})`),
|
|
300
821
|
]);
|
|
301
822
|
});
|
|
302
|
-
|
|
823
|
+
const SCHEMA_HEADER = `# yaml-language-server: $schema=${MANIFEST_SCHEMA_ID}\n`;
|
|
824
|
+
// A first manifest: one open root that reaches itself, the ceilings at zero,
|
|
825
|
+
// and a comment per section naming the page that explains it. Tight enough to
|
|
826
|
+
// fire on the first external import — which is the moment the author learns
|
|
827
|
+
// where the allowlist is — and small enough to read in one sitting.
|
|
828
|
+
const STARTER_MANIFEST = `${SCHEMA_HEADER}#
|
|
829
|
+
# The architecture policy: one manifest of this repository.
|
|
830
|
+
# https://dataquail.github.io/goodbones/architecture-rules/manifest/
|
|
831
|
+
#
|
|
832
|
+
# A key ending in \`/\` is a folder; anything else is a file. The default is
|
|
833
|
+
# tight: a folder admits only the children it lists, and a file may import only
|
|
834
|
+
# what it or an ancestor allows. Laxity is opted into, by name, at the node that
|
|
835
|
+
# wants it. Quote every glob — \`*\` and \`@\` mean something else to YAML bare.
|
|
836
|
+
|
|
837
|
+
# How an import specifier becomes a file. Every pattern below is matched
|
|
838
|
+
# against a resolved path, so this is what makes the rest mean anything.
|
|
839
|
+
# https://dataquail.github.io/goodbones/architecture-rules/enforcement/resolution/
|
|
840
|
+
resolve:
|
|
841
|
+
scopes:
|
|
842
|
+
- files: ""
|
|
843
|
+
language: typescript
|
|
844
|
+
options: { tsconfig: tsconfig.json }
|
|
845
|
+
unresolved: error
|
|
846
|
+
|
|
847
|
+
# Violations this repository is carrying while it adopts the policy, keyed by
|
|
848
|
+
# fingerprint. Written by \`architecture baseline\`; the floor only rises.
|
|
849
|
+
# https://dataquail.github.io/goodbones/architecture-rules/enforcement/baseline/
|
|
850
|
+
baseline: .architecture-baseline.json
|
|
851
|
+
|
|
852
|
+
# Ceilings on how many tiers may say "not tightened yet". At zero, raising one
|
|
853
|
+
# is a line in this file a reviewer sees. The same block takes coverage floors
|
|
854
|
+
# and ceilings on what \`architecture conformance\` measures, once there are
|
|
855
|
+
# numbers to write.
|
|
856
|
+
# https://dataquail.github.io/goodbones/architecture-rules/enforcement/adoption/
|
|
857
|
+
limits:
|
|
858
|
+
unrestricted: 0
|
|
859
|
+
partial: 0
|
|
860
|
+
|
|
861
|
+
# Migrations the repository is running, each with a detector, a rationale, a
|
|
862
|
+
# guide, an owner and a ledger of every place the pattern still occurs. Fill
|
|
863
|
+
# one in, then \`architecture campaigns init <id>\` to write its ledger.
|
|
864
|
+
# https://dataquail.github.io/goodbones/architecture-rules/manifest/campaigns/
|
|
865
|
+
# campaigns:
|
|
866
|
+
# - id: js-to-ts
|
|
867
|
+
# why: The strict tsconfig cannot land while any src file is JavaScript.
|
|
868
|
+
# how: Rename to .ts, add types at the module boundary, leave the body alone.
|
|
869
|
+
# scope: ["src/**"]
|
|
870
|
+
# unit: file
|
|
871
|
+
# detect: { path: { file: "\\.(js|jsx)$" } }
|
|
872
|
+
# probes: { fires: [{ path: src/legacy/util.js }], ignores: [{ path: src/util.ts }] }
|
|
873
|
+
# staleAfter: 14d
|
|
874
|
+
# onComplete: remove
|
|
875
|
+
|
|
876
|
+
# The repository. One open root, reaching itself and the runtime; run
|
|
877
|
+
# \`architecture check\` to see what else it reaches, and write that down here.
|
|
878
|
+
# https://dataquail.github.io/goodbones/architecture-rules/manifest/imports/
|
|
879
|
+
tree:
|
|
880
|
+
"src/":
|
|
881
|
+
message: "src/ is the whole program. Nothing in it is layered yet."
|
|
882
|
+
layout: open
|
|
883
|
+
imports:
|
|
884
|
+
message: "This import is not on the allowlist."
|
|
885
|
+
allow: ["src/**", "node:**"]
|
|
886
|
+
# npm packages this tier may reach, by name.
|
|
887
|
+
external: []
|
|
888
|
+
children: {}
|
|
889
|
+
`;
|
|
890
|
+
// A starter manifest, for a repository that has none.
|
|
891
|
+
export const init = (repoRoot) => Effect.gen(function* () {
|
|
892
|
+
const present = MANIFEST_FILENAMES.filter((name) => existsSync(path.resolve(repoRoot, name)));
|
|
893
|
+
if (present.length > 0) {
|
|
894
|
+
return yield* Effect.fail(fail(`${present.join(", ")} already exists. \`init\` writes a starter manifest for a ` +
|
|
895
|
+
`repository that has none, and does not overwrite one.`));
|
|
896
|
+
}
|
|
897
|
+
yield* Effect.sync(() => {
|
|
898
|
+
writeFileSync(path.resolve(repoRoot, "architecture.yaml"), STARTER_MANIFEST);
|
|
899
|
+
});
|
|
900
|
+
yield* report([
|
|
901
|
+
"wrote architecture.yaml.",
|
|
902
|
+
"",
|
|
903
|
+
" architecture check # what src/ reaches today; add it to the allowlist by name",
|
|
904
|
+
" architecture coverage # how much of the tree the policy reaches",
|
|
905
|
+
]);
|
|
906
|
+
});
|
|
907
|
+
// The same manifest as a data file. Nothing is hoisted into `defs` — which
|
|
908
|
+
// subtrees are worth naming is the author's call — and comments do not
|
|
909
|
+
// survive, since no tool carries them across; the report says so.
|
|
910
|
+
export const migrate = (repoRoot, configFilename) => Effect.gen(function* () {
|
|
911
|
+
const from = yield* Effect.try({
|
|
912
|
+
try: () => configFilename === undefined
|
|
913
|
+
? findManifestFile(repoRoot)
|
|
914
|
+
: path.resolve(repoRoot, configFilename),
|
|
915
|
+
catch: (cause) => fail(String(cause)),
|
|
916
|
+
});
|
|
917
|
+
if (![".mjs", ".js", ".cjs"].includes(path.extname(from))) {
|
|
918
|
+
return yield* Effect.fail(fail(`${path.basename(from)} is already a data file. \`migrate\` reads a JavaScript ` +
|
|
919
|
+
`manifest and writes the same policy as architecture.yaml.`));
|
|
920
|
+
}
|
|
921
|
+
const to = path.resolve(repoRoot, "architecture.yaml");
|
|
922
|
+
if (existsSync(to)) {
|
|
923
|
+
return yield* Effect.fail(fail("architecture.yaml already exists; `migrate` does not overwrite it."));
|
|
924
|
+
}
|
|
925
|
+
const read = yield* Effect.tryPromise({
|
|
926
|
+
try: () => readManifestFile(from),
|
|
927
|
+
catch: (cause) => fail(String(cause)),
|
|
928
|
+
});
|
|
929
|
+
// Written only if it decodes: a manifest that does not load as a module
|
|
930
|
+
// is not going to load as YAML either, and the error names why.
|
|
931
|
+
const decoded = decodeManifest(from, read.manifest);
|
|
932
|
+
if (Result.isFailure(decoded))
|
|
933
|
+
return yield* Effect.fail(fail(decoded.failure.message));
|
|
934
|
+
yield* Effect.sync(() => {
|
|
935
|
+
writeFileSync(to, `${SCHEMA_HEADER}\n${formatManifestYaml(read.manifest)}`);
|
|
936
|
+
});
|
|
937
|
+
yield* report([
|
|
938
|
+
`wrote architecture.yaml from ${path.basename(from)}.`,
|
|
939
|
+
"",
|
|
940
|
+
"Comments were not carried over; port the ones worth keeping by hand.",
|
|
941
|
+
`Then delete ${path.basename(from)}: a repository with two manifests is refused.`,
|
|
942
|
+
]);
|
|
943
|
+
});
|
|
944
|
+
// The ledgers. `campaigns` alone is the status table; `init` writes a
|
|
945
|
+
// campaign's first ledger from what fires today; `prune` removes what no
|
|
946
|
+
// longer fires; `allow` is the one way an entry is added, and it records why.
|
|
947
|
+
const ledgerPathOf = (policy, id) => path.resolve(policy.repoRoot, policy.ledgerDir, `${id}.json`);
|
|
948
|
+
const writeLedger = (policy, ledger) => {
|
|
949
|
+
const at = ledgerPathOf(policy, ledger.id);
|
|
950
|
+
mkdirSync(path.dirname(at), { recursive: true });
|
|
951
|
+
writeFileSync(at, serializeLedger(ledger));
|
|
952
|
+
};
|
|
953
|
+
const campaignNamed = (policy, id) => policy.campaignRules.find((rule) => rule.id === id) ?? null;
|
|
954
|
+
// The author of a regression: `--by`, else git's user.email, else the
|
|
955
|
+
// GIT_AUTHOR_EMAIL the environment carries. Without one the record is refused
|
|
956
|
+
// rather than written blank, since the record is the point.
|
|
957
|
+
const authorOf = (given) => {
|
|
958
|
+
if (given !== undefined && given !== "")
|
|
959
|
+
return given;
|
|
960
|
+
try {
|
|
961
|
+
const email = execFileSync("git", ["config", "user.email"], {
|
|
962
|
+
encoding: "utf8",
|
|
963
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
964
|
+
}).trim();
|
|
965
|
+
if (email !== "")
|
|
966
|
+
return email;
|
|
967
|
+
}
|
|
968
|
+
catch {
|
|
969
|
+
// git absent, or no email configured
|
|
970
|
+
}
|
|
971
|
+
const fromEnvironment = process.env.GIT_AUTHOR_EMAIL;
|
|
972
|
+
return fromEnvironment === undefined || fromEnvironment === "" ? null : fromEnvironment;
|
|
973
|
+
};
|
|
974
|
+
const flagOf = (argv, flag) => {
|
|
975
|
+
const at = argv.indexOf(flag);
|
|
976
|
+
const value = at === -1 ? undefined : argv[at + 1];
|
|
977
|
+
return value === undefined || value.startsWith("--") ? undefined : value;
|
|
978
|
+
};
|
|
979
|
+
const CAMPAIGN_SUBCOMMANDS = ["init", "prune", "allow"];
|
|
980
|
+
const CAMPAIGN_VALUE_FLAGS = ["--reason", "--by", "--entries"];
|
|
981
|
+
// `campaigns [init <id> | prune [<id>] | allow <id> --reason <text>] [roots…]`:
|
|
982
|
+
// the subcommand and its id come first; whatever positional is left names
|
|
983
|
+
// the roots to walk, as it does for every other command.
|
|
984
|
+
export const campaignArgsOf = (argv, ids) => {
|
|
985
|
+
const positional = [];
|
|
986
|
+
for (let at = 0; at < argv.length; at += 1) {
|
|
987
|
+
const one = argv[at] ?? "";
|
|
988
|
+
if (CAMPAIGN_VALUE_FLAGS.includes(one)) {
|
|
989
|
+
at += 1;
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
if (!one.startsWith("--"))
|
|
993
|
+
positional.push(one);
|
|
994
|
+
}
|
|
995
|
+
const [first, second, ...rest] = positional;
|
|
996
|
+
if (first === undefined || !CAMPAIGN_SUBCOMMANDS.includes(first)) {
|
|
997
|
+
return { subcommand: undefined, id: undefined, roots: positional };
|
|
998
|
+
}
|
|
999
|
+
// `prune` takes an optional id; the next word is one only if a campaign
|
|
1000
|
+
// has that name, else it is a root.
|
|
1001
|
+
const takesId = first !== "prune" || (second !== undefined && ids.includes(second));
|
|
1002
|
+
return takesId
|
|
1003
|
+
? { subcommand: first, id: second, roots: rest }
|
|
1004
|
+
: { subcommand: first, id: undefined, roots: second === undefined ? [] : [second, ...rest] };
|
|
1005
|
+
};
|
|
1006
|
+
export const campaigns = (policy, defaultRoots, argv) => Effect.gen(function* () {
|
|
1007
|
+
const parsed = campaignArgsOf(argv, policy.campaignRules.map((rule) => rule.id));
|
|
1008
|
+
const { id, subcommand } = parsed;
|
|
1009
|
+
const roots = parsed.roots.length > 0 ? parsed.roots : defaultRoots;
|
|
1010
|
+
if (policy.campaignRules.length === 0) {
|
|
1011
|
+
return yield* report(["this policy declares no campaigns."]);
|
|
1012
|
+
}
|
|
1013
|
+
const hitsOf = () => collectFindings(policy, roots).campaigns;
|
|
1014
|
+
const own = (hits, campaign) => hits.filter((hit) => hit.campaign === campaign).map((hit) => hit.violation);
|
|
1015
|
+
switch (subcommand) {
|
|
1016
|
+
case undefined: {
|
|
1017
|
+
const snapshot = snapshotOf(policy, roots, manifestPathOf(policy.repoRoot));
|
|
1018
|
+
return yield* report([
|
|
1019
|
+
`${count(snapshot.campaigns.length, "campaign")} under ${roots.join(", ")}`,
|
|
1020
|
+
"",
|
|
1021
|
+
...renderCampaignRows(snapshot.campaigns),
|
|
1022
|
+
"",
|
|
1023
|
+
" architecture campaigns init <id> # write a ledger from what fires today",
|
|
1024
|
+
" architecture campaigns prune [<id>] # drop entries that no longer fire",
|
|
1025
|
+
' architecture campaigns allow <id> --reason "<why>" # record why the count may rise',
|
|
1026
|
+
]);
|
|
1027
|
+
}
|
|
1028
|
+
case "init": {
|
|
1029
|
+
if (id === undefined)
|
|
1030
|
+
return yield* Effect.fail(fail("campaigns init needs a campaign id"));
|
|
1031
|
+
const rule = campaignNamed(policy, id);
|
|
1032
|
+
if (rule === null)
|
|
1033
|
+
return yield* Effect.fail(fail(`no campaign is named "${id}"`));
|
|
1034
|
+
if (policy.ledgers.has(id)) {
|
|
1035
|
+
return yield* Effect.fail(fail(`${path.relative(policy.repoRoot, ledgerPathOf(policy, id))} already exists. \`init\` ` +
|
|
1036
|
+
`writes a campaign's first ledger and does not overwrite one; \`prune\` and \`allow\` ` +
|
|
1037
|
+
`are how it changes.`));
|
|
1038
|
+
}
|
|
1039
|
+
const ledger = ledgerOf(id, own(hitsOf(), id), policy.now);
|
|
1040
|
+
yield* Effect.sync(() => {
|
|
1041
|
+
writeLedger(policy, ledger);
|
|
1042
|
+
});
|
|
1043
|
+
return yield* report([
|
|
1044
|
+
`${count(ledger.entries.length, "hit")} recorded in ${path.relative(policy.repoRoot, ledgerPathOf(policy, id))}.`,
|
|
1045
|
+
"Each one is a place the campaign has yet to reach. Fixing one means pruning its line.",
|
|
1046
|
+
]);
|
|
1047
|
+
}
|
|
1048
|
+
case "prune": {
|
|
1049
|
+
const targets = id === undefined
|
|
1050
|
+
? policy.campaignRules
|
|
1051
|
+
: [campaignNamed(policy, id)].filter((one) => one !== null);
|
|
1052
|
+
if (id !== undefined && targets.length === 0) {
|
|
1053
|
+
return yield* Effect.fail(fail(`no campaign is named "${id}"`));
|
|
1054
|
+
}
|
|
1055
|
+
const hits = hitsOf();
|
|
1056
|
+
const lines = [];
|
|
1057
|
+
for (const rule of targets) {
|
|
1058
|
+
const ledger = policy.ledgers.get(rule.id);
|
|
1059
|
+
if (ledger === undefined) {
|
|
1060
|
+
lines.push(`${rule.id}: no ledger to prune (run \`campaigns init ${rule.id}\`).`);
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
const next = pruned(ledger, own(hits, rule.id), rule.unit, policy.now);
|
|
1064
|
+
const removed = ledger.entries.length - next.entries.length;
|
|
1065
|
+
const rewritten = next.entries.filter((entry) => !ledger.entries.includes(entry)).length;
|
|
1066
|
+
if (removed === 0 && rewritten === 0) {
|
|
1067
|
+
lines.push(`${rule.id}: nothing to prune.`);
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
yield* Effect.sync(() => {
|
|
1071
|
+
writeLedger(policy, next);
|
|
1072
|
+
});
|
|
1073
|
+
lines.push(`${rule.id}: ${count(removed, "entry", "entries")} pruned` +
|
|
1074
|
+
(rewritten > 0 ? `, ${count(rewritten, "entry", "entries")} rewritten` : "") +
|
|
1075
|
+
`; ${count(next.entries.length, "entry", "entries")} left.`);
|
|
1076
|
+
}
|
|
1077
|
+
return yield* report(lines);
|
|
1078
|
+
}
|
|
1079
|
+
case "allow": {
|
|
1080
|
+
if (id === undefined)
|
|
1081
|
+
return yield* Effect.fail(fail("campaigns allow needs a campaign id"));
|
|
1082
|
+
const rule = campaignNamed(policy, id);
|
|
1083
|
+
if (rule === null)
|
|
1084
|
+
return yield* Effect.fail(fail(`no campaign is named "${id}"`));
|
|
1085
|
+
const ledger = policy.ledgers.get(id);
|
|
1086
|
+
if (ledger === undefined) {
|
|
1087
|
+
return yield* Effect.fail(fail(`campaign ${id} has no ledger yet; run \`campaigns init ${id}\` first.`));
|
|
1088
|
+
}
|
|
1089
|
+
const reason = flagOf(argv, "--reason");
|
|
1090
|
+
if (reason === undefined) {
|
|
1091
|
+
return yield* Effect.fail(fail("campaigns allow needs --reason <text>: growth is recorded with why, or not at all."));
|
|
1092
|
+
}
|
|
1093
|
+
const by = authorOf(flagOf(argv, "--by"));
|
|
1094
|
+
if (by === null) {
|
|
1095
|
+
return yield* Effect.fail(fail("campaigns allow needs an author: pass --by <email>, or set git's user.email."));
|
|
1096
|
+
}
|
|
1097
|
+
const state = reconcile(ledger, own(hitsOf(), id), rule.unit);
|
|
1098
|
+
const unrecorded = [...new Set(state.unrecorded.map(entryOf))].sort();
|
|
1099
|
+
// `--entries` allows a subset and refuses the rest: a pull request
|
|
1100
|
+
// that legitimately adds one hit while another is an accident.
|
|
1101
|
+
const chosen = flagOf(argv, "--entries")
|
|
1102
|
+
?.split(",")
|
|
1103
|
+
.map((one) => one.trim()) ?? unrecorded;
|
|
1104
|
+
const unknown = chosen.filter((entry) => !unrecorded.includes(entry));
|
|
1105
|
+
if (unknown.length > 0) {
|
|
1106
|
+
return yield* Effect.fail(fail(`these entries are not unrecorded hits of ${id}: ${unknown.join(", ")}`));
|
|
1107
|
+
}
|
|
1108
|
+
if (chosen.length === 0) {
|
|
1109
|
+
return yield* report([`${id}: nothing to allow; every hit is in the ledger.`]);
|
|
1110
|
+
}
|
|
1111
|
+
const next = allowed(ledger, chosen, { at: policy.now, by, reason });
|
|
1112
|
+
yield* Effect.sync(() => {
|
|
1113
|
+
writeLedger(policy, next);
|
|
1114
|
+
});
|
|
1115
|
+
const left = unrecorded.filter((entry) => !chosen.includes(entry));
|
|
1116
|
+
return yield* report([
|
|
1117
|
+
`${id}: ${count(chosen.length, "entry", "entries")} allowed, recorded as a regression by ${by}.`,
|
|
1118
|
+
...chosen.map((entry) => ` ${entry}`),
|
|
1119
|
+
...(left.length === 0
|
|
1120
|
+
? []
|
|
1121
|
+
: ["", `${count(left.length, "hit")} left unrecorded; check still fails on them.`]),
|
|
1122
|
+
]);
|
|
1123
|
+
}
|
|
1124
|
+
default:
|
|
1125
|
+
return yield* Effect.fail(fail(`unknown campaigns subcommand "${subcommand}". Try: campaigns | campaigns init <id> | campaigns prune [<id>] | campaigns allow <id> --reason <text> [--by <email>] [--entries a,b]`));
|
|
1126
|
+
}
|
|
1127
|
+
});
|
|
1128
|
+
// The commands that judge a campaign, and so ask its report source.
|
|
1129
|
+
const READS_REPORTS = new Set([
|
|
1130
|
+
"check",
|
|
1131
|
+
"conformance",
|
|
1132
|
+
"baseline",
|
|
1133
|
+
"campaigns",
|
|
1134
|
+
"explain",
|
|
1135
|
+
]);
|
|
1136
|
+
export const run = (repoRoot, argv,
|
|
1137
|
+
// From ARCHITECTURE_CONFIG. Absent, the manifest is discovered by name.
|
|
1138
|
+
configFilename) => Effect.gen(function* () {
|
|
303
1139
|
const [command = "check", ...rest] = argv;
|
|
1140
|
+
// The three commands that write a manifest rather than read one.
|
|
1141
|
+
if (command === "init")
|
|
1142
|
+
return yield* init(repoRoot);
|
|
1143
|
+
if (command === "migrate")
|
|
1144
|
+
return yield* migrate(repoRoot, configFilename);
|
|
1145
|
+
if (command === "infer") {
|
|
1146
|
+
yield* infer(repoRoot, rest, configFilename);
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
// `--against <file>` measures the tree against a manifest other than the
|
|
1150
|
+
// repository's own — the target the team is moving toward. Only
|
|
1151
|
+
// `conformance` takes it: a `check` against a manifest nobody is held to
|
|
1152
|
+
// yet would fail for no one's benefit.
|
|
1153
|
+
const againstAt = rest.indexOf("--against");
|
|
1154
|
+
const against = againstAt === -1 ? undefined : rest[againstAt + 1];
|
|
1155
|
+
if (againstAt !== -1 && (against === undefined || against.startsWith("--"))) {
|
|
1156
|
+
return yield* Effect.fail(fail("--against needs a manifest path"));
|
|
1157
|
+
}
|
|
1158
|
+
if (against !== undefined && command !== "conformance") {
|
|
1159
|
+
return yield* Effect.fail(fail(`--against is a \`conformance\` flag; ${command} does not take it`));
|
|
1160
|
+
}
|
|
1161
|
+
const manifestFilename = against ?? configFilename;
|
|
304
1162
|
const policy = yield* Effect.tryPromise({
|
|
305
|
-
try: () => loadPolicyFromFile(repoRoot),
|
|
1163
|
+
try: () => loadPolicyFromFile(repoRoot, manifestFilename),
|
|
306
1164
|
catch: (cause) => fail(String(cause)),
|
|
307
1165
|
});
|
|
308
1166
|
yield* Effect.sync(() => {
|
|
309
1167
|
for (const notice of policy.notices)
|
|
310
1168
|
process.stderr.write(`deprecated: ${notice}\n`);
|
|
311
1169
|
});
|
|
312
|
-
|
|
1170
|
+
// Every `report` a campaign names is read now, before any file asks:
|
|
1171
|
+
// a term's several commands run at once rather than one after another
|
|
1172
|
+
// the first time a campaign selects a file. What cannot be read is kept
|
|
1173
|
+
// for the campaign to report; a report that does not parse is refused.
|
|
1174
|
+
if (READS_REPORTS.has(command)) {
|
|
1175
|
+
yield* Effect.tryPromise({
|
|
1176
|
+
try: () => Promise.all(reportSpecsOf(policy.campaignRules).map((spec) => policy.reports.read?.(spec))),
|
|
1177
|
+
catch: (cause) => fail(String(cause)),
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
const json = rest.includes("--json");
|
|
1181
|
+
const positional = rest.filter((argument, index) => argument !== "--json" &&
|
|
1182
|
+
argument !== "--against" &&
|
|
1183
|
+
(againstAt === -1 || index !== againstAt + 1));
|
|
1184
|
+
const roots = positional.length > 0 ? positional : ["packages"];
|
|
313
1185
|
switch (command) {
|
|
1186
|
+
case "campaigns":
|
|
1187
|
+
return yield* campaigns(policy, ["packages"], rest);
|
|
314
1188
|
case "check":
|
|
315
|
-
return yield* check(policy, roots
|
|
1189
|
+
return yield* check(policy, roots, {
|
|
1190
|
+
format: json ? "json" : "text",
|
|
1191
|
+
manifestPath: manifestPathOf(repoRoot, configFilename),
|
|
1192
|
+
});
|
|
1193
|
+
case "conformance":
|
|
1194
|
+
return yield* conformance(policy, roots, {
|
|
1195
|
+
format: json ? "json" : "text",
|
|
1196
|
+
manifestPath: manifestPathOf(repoRoot, manifestFilename),
|
|
1197
|
+
});
|
|
316
1198
|
case "baseline":
|
|
317
1199
|
return yield* writeBaseline(policy, roots);
|
|
318
1200
|
case "explain": {
|
|
@@ -324,13 +1206,13 @@ export const run = (repoRoot, argv) => Effect.gen(function* () {
|
|
|
324
1206
|
case "coverage":
|
|
325
1207
|
return yield* coverage(policy, roots);
|
|
326
1208
|
case "facts": {
|
|
327
|
-
const [file] =
|
|
1209
|
+
const [file] = positional;
|
|
328
1210
|
if (file === undefined)
|
|
329
1211
|
return yield* Effect.fail(fail("facts needs a file path"));
|
|
330
|
-
return yield* facts(policy, file,
|
|
1212
|
+
return yield* facts(policy, file, json ? "json" : "text");
|
|
331
1213
|
}
|
|
332
1214
|
default:
|
|
333
|
-
return yield* Effect.fail(fail(`unknown command "${command}". Try: check | baseline | coverage | explain <file> | facts <file> [--json]`));
|
|
1215
|
+
return yield* Effect.fail(fail(`unknown command "${command}". Try: check [--json] | conformance [--json] [--against <manifest>] | baseline | campaigns [init <id> | prune [<id>] | allow <id> --reason <text>] | coverage | explain <file> | facts <file> [--json] | init | infer | migrate`));
|
|
334
1216
|
}
|
|
335
1217
|
});
|
|
336
1218
|
export const fingerprint = fingerprintOf;
|