@goodbones/cli 0.1.0-beta.7 → 0.1.0-beta.8
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 +1 -0
- package/build/dts/config-loader.d.ts.map +1 -1
- package/build/dts/run.d.ts +22 -1
- package/build/dts/run.d.ts.map +1 -1
- package/build/esm/config-loader.js +30 -4
- package/build/esm/config-loader.js.map +1 -1
- package/build/esm/run.js +472 -20
- package/build/esm/run.js.map +1 -1
- package/package.json +4 -3
- package/src/config-loader.ts +31 -3
- package/src/run.ts +590 -20
package/build/esm/run.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
4
|
import * as path from "node:path";
|
|
4
|
-
import { baselineOf, coverageOf, cyclesIn, decodeBaseline, decodeManifest, EMPTY_BASELINE, evaluateGraph, evaluateMemberSite, evaluateResolvedEdge, evaluateSelectedBindings, evaluateStructure, evaluateSurface, exportRulesSelecting, findManifestFile, fingerprintOf, formatManifestYaml, formatMessage, fractionsOf, hasGraphRules, heightOf, listSourceFiles, makeBaselineFilter, MANIFEST_FILENAMES, MANIFEST_SCHEMA_ID, memberRulesSelecting, readManifestFile, requiredSiblingsOf, residueOf, rulesSelecting, serializeBaseline, slackOf, SNAPSHOT_VERSION, staleEntriesOf, surfaceRulesSelecting, vacancyOf, } from "@goodbones/core";
|
|
5
|
+
import { allowed, baselineOf, campaignsSelecting, 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, requiredSiblingsOf, residueOf, rulesSelecting, serializeBaseline, serializeLedger, slackOf, SNAPSHOT_VERSION, staleEntriesOf, surfaceRulesSelecting, vacancyOf, } from "@goodbones/core";
|
|
5
6
|
import * as Effect from "effect/Effect";
|
|
6
7
|
import * as Result from "effect/Result";
|
|
7
8
|
import { loadPolicyFromFile, manifestPathOf } from "./config-loader.js";
|
|
@@ -12,16 +13,26 @@ const fail = (message) => ({ _tag: "CliFailure", message });
|
|
|
12
13
|
export const collectFindings = (policy, roots, options = {}) => {
|
|
13
14
|
const files = listSourceFiles(policy.repoRoot, roots, policy.languages);
|
|
14
15
|
const violations = [];
|
|
16
|
+
const campaigns = [];
|
|
15
17
|
const unresolved = [];
|
|
16
18
|
const edges = [];
|
|
17
|
-
// Each file is parsed at most once, whether the per-file
|
|
18
|
-
// graph pass asks first.
|
|
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
|
+
};
|
|
19
30
|
const parsed = new Map();
|
|
20
31
|
const factsOf = (file) => {
|
|
21
32
|
const cached = parsed.get(file);
|
|
22
33
|
if (cached !== undefined)
|
|
23
34
|
return cached;
|
|
24
|
-
const facts =
|
|
35
|
+
const facts = policy.extractor.factsOf(file, textOf(file));
|
|
25
36
|
parsed.set(file, facts);
|
|
26
37
|
return facts;
|
|
27
38
|
};
|
|
@@ -38,6 +49,26 @@ export const collectFindings = (policy, roots, options = {}) => {
|
|
|
38
49
|
for (const violation of evaluateStructure(policy.structure, policy.fileSystem, file)) {
|
|
39
50
|
violations.push(violation);
|
|
40
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
|
+
}
|
|
41
72
|
const selectedImports = rulesSelecting(policy.importRules, file);
|
|
42
73
|
const selectedExports = exportRulesSelecting(policy.exportRules, file);
|
|
43
74
|
const selectedMembers = memberRulesSelecting(policy.memberRules, file);
|
|
@@ -87,7 +118,7 @@ export const collectFindings = (policy, roots, options = {}) => {
|
|
|
87
118
|
}
|
|
88
119
|
}
|
|
89
120
|
}
|
|
90
|
-
return { violations, unresolved, files: files.length, edges, graph };
|
|
121
|
+
return { violations, campaigns, unresolved, files: files.length, edges, graph };
|
|
91
122
|
};
|
|
92
123
|
const baselinePathOf = (policy) => policy.config.baseline === undefined
|
|
93
124
|
? null
|
|
@@ -108,6 +139,66 @@ const report = (lines) => Effect.sync(() => {
|
|
|
108
139
|
process.stdout.write(`${line}\n`);
|
|
109
140
|
});
|
|
110
141
|
const describe = (violation) => ` ${violation.file}\n ${formatMessage(violation)}`;
|
|
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
|
+
];
|
|
111
202
|
const COVERAGE_FAMILIES = [
|
|
112
203
|
"imports",
|
|
113
204
|
"structure",
|
|
@@ -128,11 +219,22 @@ const reportOf = (policy, roots, manifestPath, findings) => {
|
|
|
128
219
|
const baseline = readBaseline(policy);
|
|
129
220
|
const stale = staleEntriesOf(baseline, findings.violations);
|
|
130
221
|
const { isBaselined } = makeBaselineFilter(baseline);
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
222
|
+
const isLedgered = ledgeredFilter(policy, findings.campaigns);
|
|
223
|
+
const violations = [
|
|
224
|
+
...findings.violations.map((violation) => ({
|
|
225
|
+
...violation,
|
|
226
|
+
fingerprint: fingerprintOf(violation),
|
|
227
|
+
baselined: isBaselined(violation),
|
|
228
|
+
ledgered: false,
|
|
229
|
+
})),
|
|
230
|
+
...findings.campaigns.map((hit) => ({
|
|
231
|
+
...hit.violation,
|
|
232
|
+
fingerprint: fingerprintOf(hit.violation),
|
|
233
|
+
baselined: false,
|
|
234
|
+
ledgered: isLedgered(hit),
|
|
235
|
+
})),
|
|
236
|
+
];
|
|
237
|
+
const campaigns = campaignReportsOf(policy, findings.campaigns);
|
|
136
238
|
// The floors. A policy states how much of the tree it reaches, per
|
|
137
239
|
// family; falling under is a policy that quietly stopped covering files.
|
|
138
240
|
const floors = policy.config.limits?.coverage ?? {};
|
|
@@ -150,7 +252,7 @@ const reportOf = (policy, roots, manifestPath, findings) => {
|
|
|
150
252
|
];
|
|
151
253
|
}));
|
|
152
254
|
const shortfalls = shortfallsOf(coverage);
|
|
153
|
-
const reportable = violations.filter((one) => !one.baselined).length;
|
|
255
|
+
const reportable = violations.filter((one) => !one.baselined && !one.ledgered).length;
|
|
154
256
|
return {
|
|
155
257
|
version: 1,
|
|
156
258
|
files: findings.files,
|
|
@@ -158,7 +260,8 @@ const reportOf = (policy, roots, manifestPath, findings) => {
|
|
|
158
260
|
ok: reportable === 0 &&
|
|
159
261
|
findings.unresolved.length === 0 &&
|
|
160
262
|
stale.length === 0 &&
|
|
161
|
-
shortfalls.length === 0
|
|
263
|
+
shortfalls.length === 0 &&
|
|
264
|
+
campaignFailuresOf(campaigns).length === 0,
|
|
162
265
|
manifest: {
|
|
163
266
|
path: path.relative(policy.repoRoot, manifestPath).replaceAll(path.sep, "/"),
|
|
164
267
|
sha256: sha256Of(manifestPath),
|
|
@@ -171,6 +274,7 @@ const reportOf = (policy, roots, manifestPath, findings) => {
|
|
|
171
274
|
unrestricted: policy.adoption.unrestricted,
|
|
172
275
|
partial: policy.adoption.partial,
|
|
173
276
|
},
|
|
277
|
+
campaigns,
|
|
174
278
|
};
|
|
175
279
|
};
|
|
176
280
|
const shortfallsOf = (coverage) => COVERAGE_FAMILIES.flatMap((family) => {
|
|
@@ -181,15 +285,78 @@ const shortfallsOf = (coverage) => COVERAGE_FAMILIES.flatMap((family) => {
|
|
|
181
285
|
const failureOf = (report, shortfalls) => {
|
|
182
286
|
if (report.stale.length > 0)
|
|
183
287
|
return fail("stale baseline entries");
|
|
288
|
+
const [campaignFailure] = campaignFailuresOf(report.campaigns);
|
|
289
|
+
if (campaignFailure !== undefined)
|
|
290
|
+
return fail(campaignFailure);
|
|
184
291
|
if (shortfalls.length > 0)
|
|
185
292
|
return fail("coverage below floor");
|
|
186
293
|
if (report.ok)
|
|
187
294
|
return null;
|
|
188
295
|
return fail("architecture violations");
|
|
189
296
|
};
|
|
297
|
+
// A campaign hit, with the campaign's `how` as its instruction.
|
|
298
|
+
const describeHit = (violation) => ` ${violation.file}${violation.subject === null ? "" : ` (${violation.subject})`}\n ${formatMessage(violation)}`;
|
|
299
|
+
const renderCampaigns = (report) => {
|
|
300
|
+
const hits = report.violations.filter((one) => one.kind === "campaign");
|
|
301
|
+
return report.campaigns.flatMap((campaign) => {
|
|
302
|
+
const rule = `campaign/${campaign.id}`;
|
|
303
|
+
const fresh = new Set(campaign.new);
|
|
304
|
+
const own = hits.filter((one) => one.ruleName === rule && fresh.has(entryOf(one)));
|
|
305
|
+
if (campaign.missingLedger && campaign.count > 0) {
|
|
306
|
+
return [
|
|
307
|
+
"",
|
|
308
|
+
`campaign ${campaign.id}: ${count(campaign.count, "hit")} and no ledger. Record them before they count as growth:`,
|
|
309
|
+
"",
|
|
310
|
+
` architecture campaigns init ${campaign.id}`,
|
|
311
|
+
];
|
|
312
|
+
}
|
|
313
|
+
const complete = campaign.complete && !campaign.missingLedger;
|
|
314
|
+
return [
|
|
315
|
+
...(campaign.new.length === 0
|
|
316
|
+
? []
|
|
317
|
+
: [
|
|
318
|
+
"",
|
|
319
|
+
`campaign ${campaign.id}: ${count(campaign.new.length, "new hit")} the ledger does not carry. Fix them, or record why the count may rise:`,
|
|
320
|
+
...own.map(describeHit),
|
|
321
|
+
"",
|
|
322
|
+
` architecture campaigns allow ${campaign.id} --reason "<why>"`,
|
|
323
|
+
]),
|
|
324
|
+
...(campaign.stale.length === 0
|
|
325
|
+
? []
|
|
326
|
+
: [
|
|
327
|
+
"",
|
|
328
|
+
`campaign ${campaign.id}: ${count(campaign.stale.length, "ledger entry", "ledger entries")} no longer fire. The code was fixed; prune them:`,
|
|
329
|
+
...campaign.stale.map((entry) => ` ${entry}`),
|
|
330
|
+
"",
|
|
331
|
+
` architecture campaigns prune ${campaign.id}`,
|
|
332
|
+
]),
|
|
333
|
+
...(campaign.arithmetic
|
|
334
|
+
? []
|
|
335
|
+
: [
|
|
336
|
+
"",
|
|
337
|
+
`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\`.`,
|
|
338
|
+
]),
|
|
339
|
+
...(complete && campaign.onComplete === "remove"
|
|
340
|
+
? [
|
|
341
|
+
"",
|
|
342
|
+
`campaign ${campaign.id} is complete and declares onComplete: remove. Delete it from the manifest, and its ledger.`,
|
|
343
|
+
]
|
|
344
|
+
: []),
|
|
345
|
+
...(campaign.stalled
|
|
346
|
+
? [
|
|
347
|
+
"",
|
|
348
|
+
`notice: campaign ${campaign.id} has stalled — no entry has left its ledger within its staleAfter.`,
|
|
349
|
+
]
|
|
350
|
+
: []),
|
|
351
|
+
...(complete && campaign.onComplete === "keep"
|
|
352
|
+
? ["", `notice: campaign ${campaign.id} is complete, and stays as a guard.`]
|
|
353
|
+
: []),
|
|
354
|
+
];
|
|
355
|
+
});
|
|
356
|
+
};
|
|
190
357
|
const renderText = (report) => {
|
|
191
|
-
const reportable = report.violations.filter((one) => !one.baselined);
|
|
192
|
-
const carried = report.violations.length - reportable.length;
|
|
358
|
+
const reportable = report.violations.filter((one) => one.kind !== "campaign" && !one.baselined);
|
|
359
|
+
const carried = report.violations.filter((one) => one.kind !== "campaign").length - reportable.length;
|
|
193
360
|
const shortfalls = shortfallsOf(report.coverage);
|
|
194
361
|
return [
|
|
195
362
|
...reportable.map(describe),
|
|
@@ -217,6 +384,7 @@ const renderText = (report) => {
|
|
|
217
384
|
"",
|
|
218
385
|
" architecture coverage # which files no rule reaches",
|
|
219
386
|
]),
|
|
387
|
+
...renderCampaigns(report),
|
|
220
388
|
];
|
|
221
389
|
};
|
|
222
390
|
export const check = (policy, roots, options) => Effect.gen(function* () {
|
|
@@ -229,6 +397,7 @@ export const check = (policy, roots, options) => Effect.gen(function* () {
|
|
|
229
397
|
return yield* Effect.fail(failure);
|
|
230
398
|
});
|
|
231
399
|
const percent = (fraction) => `${String(Math.floor(fraction * 100))}%`;
|
|
400
|
+
const count = (n, noun, plural = `${noun}s`) => `${String(n)} ${n === 1 ? noun : plural}`;
|
|
232
401
|
// The conformance snapshot: `check`'s report grown with what no family
|
|
233
402
|
// reaches, what the allowlists permit and nothing uses, the cycle count and
|
|
234
403
|
// the size of the debt — the whole distance between the tree and the
|
|
@@ -254,6 +423,41 @@ export const snapshotOf = (policy, roots, manifestPath) => {
|
|
|
254
423
|
// allowlist that selects no file is vacant, and its entries are reported as
|
|
255
424
|
// that rather than as lines nobody needs.
|
|
256
425
|
const { concentration, slack } = slackOf(policy.importRules, findings.edges, files);
|
|
426
|
+
const campaigns = policy.campaignRules.map((rule) => {
|
|
427
|
+
const ledger = policy.ledgers.get(rule.id);
|
|
428
|
+
const own = findings.campaigns.filter((hit) => hit.campaign === rule.id).length;
|
|
429
|
+
const base = {
|
|
430
|
+
id: rule.id,
|
|
431
|
+
...(rule.title === null ? {} : { title: rule.title }),
|
|
432
|
+
...(rule.owner === null ? {} : { owner: rule.owner }),
|
|
433
|
+
initial: own,
|
|
434
|
+
allowed: 0,
|
|
435
|
+
count: own,
|
|
436
|
+
fixed: 0,
|
|
437
|
+
progress: 0,
|
|
438
|
+
lastProgress: new Date(policy.now).toISOString(),
|
|
439
|
+
regressions: 0,
|
|
440
|
+
stalled: false,
|
|
441
|
+
complete: own === 0,
|
|
442
|
+
onComplete: rule.onComplete,
|
|
443
|
+
ledgered: false,
|
|
444
|
+
};
|
|
445
|
+
if (ledger === undefined)
|
|
446
|
+
return base;
|
|
447
|
+
return {
|
|
448
|
+
...base,
|
|
449
|
+
initial: ledger.initial,
|
|
450
|
+
allowed: ledger.regressions.reduce((sum, one) => sum + one.delta, 0),
|
|
451
|
+
count: ledger.entries.length,
|
|
452
|
+
fixed: ledger.fixed,
|
|
453
|
+
progress: progressOf(ledger),
|
|
454
|
+
lastProgress: ledger.lastProgress,
|
|
455
|
+
regressions: ledger.regressions.length,
|
|
456
|
+
stalled: isStalled(rule, ledger, policy.now),
|
|
457
|
+
complete: isComplete(ledger),
|
|
458
|
+
ledgered: true,
|
|
459
|
+
};
|
|
460
|
+
});
|
|
257
461
|
return {
|
|
258
462
|
version: SNAPSHOT_VERSION,
|
|
259
463
|
manifest: report_.manifest,
|
|
@@ -271,12 +475,26 @@ export const snapshotOf = (policy, roots, manifestPath) => {
|
|
|
271
475
|
slack,
|
|
272
476
|
concentration,
|
|
273
477
|
adoption: report_.adoption,
|
|
478
|
+
campaigns,
|
|
274
479
|
};
|
|
275
480
|
};
|
|
481
|
+
// The campaigns, stalled and complete first, then by progress.
|
|
482
|
+
const renderCampaignRows = (campaigns) => {
|
|
483
|
+
const width = Math.max(0, ...campaigns.map((one) => one.id.length));
|
|
484
|
+
const state = (one) => !one.ledgered ? "no ledger" : one.complete ? "complete" : one.stalled ? "stalled" : "";
|
|
485
|
+
const ordered = [...campaigns].sort((left, right) => {
|
|
486
|
+
const rank = (one) => one.stalled ? 0 : one.complete && one.ledgered ? 1 : 2;
|
|
487
|
+
const byRank = rank(left) - rank(right);
|
|
488
|
+
return byRank !== 0 ? byRank : left.progress - right.progress;
|
|
489
|
+
});
|
|
490
|
+
return ordered.map((one) => ` ${one.id.padEnd(width)} ${percent(one.progress).padStart(4)} ${String(one.count).padStart(5)} left` +
|
|
491
|
+
` ${String(one.fixed)} fixed ${String(one.allowed)} allowed` +
|
|
492
|
+
(one.owner === undefined ? "" : ` ${one.owner}`) +
|
|
493
|
+
(state(one) === "" ? "" : ` ${state(one)}`));
|
|
494
|
+
};
|
|
276
495
|
const renderSnapshot = (snapshot) => {
|
|
277
|
-
const reportable = snapshot.violations.filter((one) => !one.baselined);
|
|
496
|
+
const reportable = snapshot.violations.filter((one) => !one.baselined && !one.ledgered);
|
|
278
497
|
const carried = snapshot.violations.length - reportable.length;
|
|
279
|
-
const count = (n, noun, plural = `${noun}s`) => `${String(n)} ${n === 1 ? noun : plural}`;
|
|
280
498
|
const row = (family) => {
|
|
281
499
|
const { covered, floor, total } = snapshot.coverage[family];
|
|
282
500
|
const fraction = total === 0 ? 1 : covered / total;
|
|
@@ -310,7 +528,7 @@ const renderSnapshot = (snapshot) => {
|
|
|
310
528
|
]),
|
|
311
529
|
...section(`vacant: ${count(snapshot.vacant.length, "node")} ${snapshot.vacant.length === 1 ? "selects" : "select"} no file`, snapshot.vacant.map((one) => ` ${one.node.padEnd(vacantWidth)} ${count(one.allowances, "allowance")}`)),
|
|
312
530
|
...section(`violations: ${count(reportable.length, "reportable")}` +
|
|
313
|
-
(carried > 0 ? `, ${String(carried)} carried by the baseline` : "") +
|
|
531
|
+
(carried > 0 ? `, ${String(carried)} carried by the baseline or a ledger` : "") +
|
|
314
532
|
(snapshot.stale.length > 0
|
|
315
533
|
? `, ${count(snapshot.stale.length, "stale entry", "stale entries")}`
|
|
316
534
|
: "") +
|
|
@@ -323,6 +541,15 @@ const renderSnapshot = (snapshot) => {
|
|
|
323
541
|
...(concentrated.length === 0
|
|
324
542
|
? []
|
|
325
543
|
: section(`concentrated: ${count(concentrated.length, "allowance")} used at fewer than half the nodes granted`, concentrated.map((one) => ` ${one.fragment}: ${one.kind} ${JSON.stringify(one.entry)} used at ${String(one.usedAt)} of ${count(one.of, "node")}`))),
|
|
544
|
+
...(snapshot.campaigns.length === 0
|
|
545
|
+
? []
|
|
546
|
+
: section(`campaigns: ${count(snapshot.campaigns.length, "campaign")}` +
|
|
547
|
+
(snapshot.campaigns.some((one) => one.stalled)
|
|
548
|
+
? `, ${count(snapshot.campaigns.filter((one) => one.stalled).length, "stalled", "stalled")}`
|
|
549
|
+
: "") +
|
|
550
|
+
(snapshot.campaigns.some((one) => one.complete && one.ledgered)
|
|
551
|
+
? `, ${count(snapshot.campaigns.filter((one) => one.complete && one.ledgered).length, "complete", "complete")}`
|
|
552
|
+
: ""), renderCampaignRows(snapshot.campaigns))),
|
|
326
553
|
"",
|
|
327
554
|
`cycles: ${String(snapshot.cycles)}`,
|
|
328
555
|
`baseline: ${count(snapshot.baseline.size, "entry", "entries")}`,
|
|
@@ -398,7 +625,7 @@ export const explain = (policy, file) => Effect.gen(function* () {
|
|
|
398
625
|
const governing = policy.structure.folders.filter((rule) => rule.folder.some((pattern) => pattern.test(path.dirname(relative))));
|
|
399
626
|
const naming = policy.structure.naming.filter((rule) => rule.file.some((pattern) => pattern.test(relative)) &&
|
|
400
627
|
!rule.fileNot.some((pattern) => pattern.test(relative)));
|
|
401
|
-
const firstSentence = (message) => `${message.split(". ")[0] ?? message}.`;
|
|
628
|
+
const firstSentence = (message) => `${(message.split(". ")[0] ?? message).replace(/\.$/, "")}.`;
|
|
402
629
|
const named = (rule) => ` ${rule.name} — ${firstSentence(rule.message)}`;
|
|
403
630
|
// The families beyond imports and structure: which rules of each speak to
|
|
404
631
|
// this file at all. What they are evaluated against is `facts`' answer.
|
|
@@ -416,6 +643,29 @@ export const explain = (policy, file) => Effect.gen(function* () {
|
|
|
416
643
|
.map((rule) => `${named(rule)} (reach)`),
|
|
417
644
|
];
|
|
418
645
|
const section = (title, lines) => lines.length === 0 ? [] : ["", title, ...lines];
|
|
646
|
+
// Each campaign selecting the file, with its truth table: one line per
|
|
647
|
+
// leaf term and what it answered here, so a detector that "should fire"
|
|
648
|
+
// and does not shows which term is not saying what its author thinks.
|
|
649
|
+
const selectedCampaigns = campaignsSelecting(policy.campaignRules, relative);
|
|
650
|
+
const campaignLines = selectedCampaigns.flatMap((rule) => {
|
|
651
|
+
const at = path.join(policy.repoRoot, relative);
|
|
652
|
+
const text = existsSync(at) ? readFileSync(at, "utf8") : "";
|
|
653
|
+
const input = {
|
|
654
|
+
file: relative,
|
|
655
|
+
text,
|
|
656
|
+
facts: policy.extractor.factsOf(relative, text),
|
|
657
|
+
resolver: policy.resolver,
|
|
658
|
+
fileSystem: policy.fileSystem,
|
|
659
|
+
syntax: policy.syntax.parse(relative, text),
|
|
660
|
+
functions: policy.functions,
|
|
661
|
+
reports: policy.reports,
|
|
662
|
+
};
|
|
663
|
+
const hits = evaluateCampaigns([rule], input);
|
|
664
|
+
return [
|
|
665
|
+
` ${rule.name} — ${firstSentence(rule.why)} (${rule.unit}; ${hits.length === 0 ? "no hit" : count(hits.length, "hit")})`,
|
|
666
|
+
...explainCampaign(rule, input).map((line) => ` ${line.answer ? "✓" : "✗"} ${line.term}${line.count === undefined ? "" : ` (${String(line.count)})`}`),
|
|
667
|
+
];
|
|
668
|
+
});
|
|
419
669
|
yield* report([
|
|
420
670
|
relative,
|
|
421
671
|
"",
|
|
@@ -445,6 +695,7 @@ export const explain = (policy, file) => Effect.gen(function* () {
|
|
|
445
695
|
...section(" vocabulary (members):", vocabulary.map(named)),
|
|
446
696
|
...section(" may export (surface):", surface.map(named)),
|
|
447
697
|
...section(" graph:", graph),
|
|
698
|
+
...section(" campaigns:", campaignLines),
|
|
448
699
|
]);
|
|
449
700
|
});
|
|
450
701
|
// The other half of `explain`. `explain` says which rules select a file; this
|
|
@@ -529,6 +780,21 @@ limits:
|
|
|
529
780
|
unrestricted: 0
|
|
530
781
|
partial: 0
|
|
531
782
|
|
|
783
|
+
# Migrations the repository is running, each with a detector, a rationale, a
|
|
784
|
+
# guide, an owner and a ledger of every place the pattern still occurs. Fill
|
|
785
|
+
# one in, then \`architecture campaigns init <id>\` to write its ledger.
|
|
786
|
+
# https://dataquail.github.io/goodbones/architecture-rules/manifest/campaigns/
|
|
787
|
+
# campaigns:
|
|
788
|
+
# - id: js-to-ts
|
|
789
|
+
# why: The strict tsconfig cannot land while any src file is JavaScript.
|
|
790
|
+
# how: Rename to .ts, add types at the module boundary, leave the body alone.
|
|
791
|
+
# scope: ["src/**"]
|
|
792
|
+
# unit: file
|
|
793
|
+
# detect: { path: { file: "\\.(js|jsx)$" } }
|
|
794
|
+
# probes: { fires: [{ path: src/legacy/util.js }], ignores: [{ path: src/util.ts }] }
|
|
795
|
+
# staleAfter: 14d
|
|
796
|
+
# onComplete: remove
|
|
797
|
+
|
|
532
798
|
# The repository. One open root, reaching itself and the runtime; run
|
|
533
799
|
# \`architecture check\` to see what else it reaches, and write that down here.
|
|
534
800
|
# https://dataquail.github.io/goodbones/architecture-rules/manifest/imports/
|
|
@@ -597,6 +863,190 @@ export const migrate = (repoRoot, configFilename) => Effect.gen(function* () {
|
|
|
597
863
|
`Then delete ${path.basename(from)}: a repository with two manifests is refused.`,
|
|
598
864
|
]);
|
|
599
865
|
});
|
|
866
|
+
// The ledgers. `campaigns` alone is the status table; `init` writes a
|
|
867
|
+
// campaign's first ledger from what fires today; `prune` removes what no
|
|
868
|
+
// longer fires; `allow` is the one way an entry is added, and it records why.
|
|
869
|
+
const ledgerPathOf = (policy, id) => path.resolve(policy.repoRoot, policy.ledgerDir, `${id}.json`);
|
|
870
|
+
const writeLedger = (policy, ledger) => {
|
|
871
|
+
const at = ledgerPathOf(policy, ledger.id);
|
|
872
|
+
mkdirSync(path.dirname(at), { recursive: true });
|
|
873
|
+
writeFileSync(at, serializeLedger(ledger));
|
|
874
|
+
};
|
|
875
|
+
const campaignNamed = (policy, id) => policy.campaignRules.find((rule) => rule.id === id) ?? null;
|
|
876
|
+
// The author of a regression: `--by`, else git's user.email, else the
|
|
877
|
+
// GIT_AUTHOR_EMAIL the environment carries. Without one the record is refused
|
|
878
|
+
// rather than written blank, since the record is the point.
|
|
879
|
+
const authorOf = (given) => {
|
|
880
|
+
if (given !== undefined && given !== "")
|
|
881
|
+
return given;
|
|
882
|
+
try {
|
|
883
|
+
const email = execFileSync("git", ["config", "user.email"], {
|
|
884
|
+
encoding: "utf8",
|
|
885
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
886
|
+
}).trim();
|
|
887
|
+
if (email !== "")
|
|
888
|
+
return email;
|
|
889
|
+
}
|
|
890
|
+
catch {
|
|
891
|
+
// git absent, or no email configured
|
|
892
|
+
}
|
|
893
|
+
const fromEnvironment = process.env.GIT_AUTHOR_EMAIL;
|
|
894
|
+
return fromEnvironment === undefined || fromEnvironment === "" ? null : fromEnvironment;
|
|
895
|
+
};
|
|
896
|
+
const flagOf = (argv, flag) => {
|
|
897
|
+
const at = argv.indexOf(flag);
|
|
898
|
+
const value = at === -1 ? undefined : argv[at + 1];
|
|
899
|
+
return value === undefined || value.startsWith("--") ? undefined : value;
|
|
900
|
+
};
|
|
901
|
+
const CAMPAIGN_SUBCOMMANDS = ["init", "prune", "allow"];
|
|
902
|
+
const CAMPAIGN_VALUE_FLAGS = ["--reason", "--by", "--entries"];
|
|
903
|
+
// `campaigns [init <id> | prune [<id>] | allow <id> --reason <text>] [roots…]`:
|
|
904
|
+
// the subcommand and its id come first; whatever positional is left names
|
|
905
|
+
// the roots to walk, as it does for every other command.
|
|
906
|
+
export const campaignArgsOf = (argv, ids) => {
|
|
907
|
+
const positional = [];
|
|
908
|
+
for (let at = 0; at < argv.length; at += 1) {
|
|
909
|
+
const one = argv[at] ?? "";
|
|
910
|
+
if (CAMPAIGN_VALUE_FLAGS.includes(one)) {
|
|
911
|
+
at += 1;
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
if (!one.startsWith("--"))
|
|
915
|
+
positional.push(one);
|
|
916
|
+
}
|
|
917
|
+
const [first, second, ...rest] = positional;
|
|
918
|
+
if (first === undefined || !CAMPAIGN_SUBCOMMANDS.includes(first)) {
|
|
919
|
+
return { subcommand: undefined, id: undefined, roots: positional };
|
|
920
|
+
}
|
|
921
|
+
// `prune` takes an optional id; the next word is one only if a campaign
|
|
922
|
+
// has that name, else it is a root.
|
|
923
|
+
const takesId = first !== "prune" || (second !== undefined && ids.includes(second));
|
|
924
|
+
return takesId
|
|
925
|
+
? { subcommand: first, id: second, roots: rest }
|
|
926
|
+
: { subcommand: first, id: undefined, roots: second === undefined ? [] : [second, ...rest] };
|
|
927
|
+
};
|
|
928
|
+
export const campaigns = (policy, defaultRoots, argv) => Effect.gen(function* () {
|
|
929
|
+
const parsed = campaignArgsOf(argv, policy.campaignRules.map((rule) => rule.id));
|
|
930
|
+
const { id, subcommand } = parsed;
|
|
931
|
+
const roots = parsed.roots.length > 0 ? parsed.roots : defaultRoots;
|
|
932
|
+
if (policy.campaignRules.length === 0) {
|
|
933
|
+
return yield* report(["this policy declares no campaigns."]);
|
|
934
|
+
}
|
|
935
|
+
const hitsOf = () => collectFindings(policy, roots).campaigns;
|
|
936
|
+
const own = (hits, campaign) => hits.filter((hit) => hit.campaign === campaign).map((hit) => hit.violation);
|
|
937
|
+
switch (subcommand) {
|
|
938
|
+
case undefined: {
|
|
939
|
+
const snapshot = snapshotOf(policy, roots, manifestPathOf(policy.repoRoot));
|
|
940
|
+
return yield* report([
|
|
941
|
+
`${count(snapshot.campaigns.length, "campaign")} under ${roots.join(", ")}`,
|
|
942
|
+
"",
|
|
943
|
+
...renderCampaignRows(snapshot.campaigns),
|
|
944
|
+
"",
|
|
945
|
+
" architecture campaigns init <id> # write a ledger from what fires today",
|
|
946
|
+
" architecture campaigns prune [<id>] # drop entries that no longer fire",
|
|
947
|
+
' architecture campaigns allow <id> --reason "<why>" # record why the count may rise',
|
|
948
|
+
]);
|
|
949
|
+
}
|
|
950
|
+
case "init": {
|
|
951
|
+
if (id === undefined)
|
|
952
|
+
return yield* Effect.fail(fail("campaigns init needs a campaign id"));
|
|
953
|
+
const rule = campaignNamed(policy, id);
|
|
954
|
+
if (rule === null)
|
|
955
|
+
return yield* Effect.fail(fail(`no campaign is named "${id}"`));
|
|
956
|
+
if (policy.ledgers.has(id)) {
|
|
957
|
+
return yield* Effect.fail(fail(`${path.relative(policy.repoRoot, ledgerPathOf(policy, id))} already exists. \`init\` ` +
|
|
958
|
+
`writes a campaign's first ledger and does not overwrite one; \`prune\` and \`allow\` ` +
|
|
959
|
+
`are how it changes.`));
|
|
960
|
+
}
|
|
961
|
+
const ledger = ledgerOf(id, own(hitsOf(), id), policy.now);
|
|
962
|
+
yield* Effect.sync(() => {
|
|
963
|
+
writeLedger(policy, ledger);
|
|
964
|
+
});
|
|
965
|
+
return yield* report([
|
|
966
|
+
`${count(ledger.entries.length, "hit")} recorded in ${path.relative(policy.repoRoot, ledgerPathOf(policy, id))}.`,
|
|
967
|
+
"Each one is a place the campaign has yet to reach. Fixing one means pruning its line.",
|
|
968
|
+
]);
|
|
969
|
+
}
|
|
970
|
+
case "prune": {
|
|
971
|
+
const targets = id === undefined
|
|
972
|
+
? policy.campaignRules
|
|
973
|
+
: [campaignNamed(policy, id)].filter((one) => one !== null);
|
|
974
|
+
if (id !== undefined && targets.length === 0) {
|
|
975
|
+
return yield* Effect.fail(fail(`no campaign is named "${id}"`));
|
|
976
|
+
}
|
|
977
|
+
const hits = hitsOf();
|
|
978
|
+
const lines = [];
|
|
979
|
+
for (const rule of targets) {
|
|
980
|
+
const ledger = policy.ledgers.get(rule.id);
|
|
981
|
+
if (ledger === undefined) {
|
|
982
|
+
lines.push(`${rule.id}: no ledger to prune (run \`campaigns init ${rule.id}\`).`);
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
const next = pruned(ledger, own(hits, rule.id), rule.unit, policy.now);
|
|
986
|
+
const removed = ledger.entries.length - next.entries.length;
|
|
987
|
+
const rewritten = next.entries.filter((entry) => !ledger.entries.includes(entry)).length;
|
|
988
|
+
if (removed === 0 && rewritten === 0) {
|
|
989
|
+
lines.push(`${rule.id}: nothing to prune.`);
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
yield* Effect.sync(() => {
|
|
993
|
+
writeLedger(policy, next);
|
|
994
|
+
});
|
|
995
|
+
lines.push(`${rule.id}: ${count(removed, "entry", "entries")} pruned` +
|
|
996
|
+
(rewritten > 0 ? `, ${count(rewritten, "entry", "entries")} rewritten` : "") +
|
|
997
|
+
`; ${count(next.entries.length, "entry", "entries")} left.`);
|
|
998
|
+
}
|
|
999
|
+
return yield* report(lines);
|
|
1000
|
+
}
|
|
1001
|
+
case "allow": {
|
|
1002
|
+
if (id === undefined)
|
|
1003
|
+
return yield* Effect.fail(fail("campaigns allow needs a campaign id"));
|
|
1004
|
+
const rule = campaignNamed(policy, id);
|
|
1005
|
+
if (rule === null)
|
|
1006
|
+
return yield* Effect.fail(fail(`no campaign is named "${id}"`));
|
|
1007
|
+
const ledger = policy.ledgers.get(id);
|
|
1008
|
+
if (ledger === undefined) {
|
|
1009
|
+
return yield* Effect.fail(fail(`campaign ${id} has no ledger yet; run \`campaigns init ${id}\` first.`));
|
|
1010
|
+
}
|
|
1011
|
+
const reason = flagOf(argv, "--reason");
|
|
1012
|
+
if (reason === undefined) {
|
|
1013
|
+
return yield* Effect.fail(fail("campaigns allow needs --reason <text>: growth is recorded with why, or not at all."));
|
|
1014
|
+
}
|
|
1015
|
+
const by = authorOf(flagOf(argv, "--by"));
|
|
1016
|
+
if (by === null) {
|
|
1017
|
+
return yield* Effect.fail(fail("campaigns allow needs an author: pass --by <email>, or set git's user.email."));
|
|
1018
|
+
}
|
|
1019
|
+
const state = reconcile(ledger, own(hitsOf(), id), rule.unit);
|
|
1020
|
+
const unrecorded = [...new Set(state.unrecorded.map(entryOf))].sort();
|
|
1021
|
+
// `--entries` allows a subset and refuses the rest: a pull request
|
|
1022
|
+
// that legitimately adds one hit while another is an accident.
|
|
1023
|
+
const chosen = flagOf(argv, "--entries")
|
|
1024
|
+
?.split(",")
|
|
1025
|
+
.map((one) => one.trim()) ?? unrecorded;
|
|
1026
|
+
const unknown = chosen.filter((entry) => !unrecorded.includes(entry));
|
|
1027
|
+
if (unknown.length > 0) {
|
|
1028
|
+
return yield* Effect.fail(fail(`these entries are not unrecorded hits of ${id}: ${unknown.join(", ")}`));
|
|
1029
|
+
}
|
|
1030
|
+
if (chosen.length === 0) {
|
|
1031
|
+
return yield* report([`${id}: nothing to allow; every hit is in the ledger.`]);
|
|
1032
|
+
}
|
|
1033
|
+
const next = allowed(ledger, chosen, { at: policy.now, by, reason });
|
|
1034
|
+
yield* Effect.sync(() => {
|
|
1035
|
+
writeLedger(policy, next);
|
|
1036
|
+
});
|
|
1037
|
+
const left = unrecorded.filter((entry) => !chosen.includes(entry));
|
|
1038
|
+
return yield* report([
|
|
1039
|
+
`${id}: ${count(chosen.length, "entry", "entries")} allowed, recorded as a regression by ${by}.`,
|
|
1040
|
+
...chosen.map((entry) => ` ${entry}`),
|
|
1041
|
+
...(left.length === 0
|
|
1042
|
+
? []
|
|
1043
|
+
: ["", `${count(left.length, "hit")} left unrecorded; check still fails on them.`]),
|
|
1044
|
+
]);
|
|
1045
|
+
}
|
|
1046
|
+
default:
|
|
1047
|
+
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]`));
|
|
1048
|
+
}
|
|
1049
|
+
});
|
|
600
1050
|
export const run = (repoRoot, argv,
|
|
601
1051
|
// From ARCHITECTURE_CONFIG. Absent, the manifest is discovered by name.
|
|
602
1052
|
configFilename) => Effect.gen(function* () {
|
|
@@ -637,6 +1087,8 @@ configFilename) => Effect.gen(function* () {
|
|
|
637
1087
|
(againstAt === -1 || index !== againstAt + 1));
|
|
638
1088
|
const roots = positional.length > 0 ? positional : ["packages"];
|
|
639
1089
|
switch (command) {
|
|
1090
|
+
case "campaigns":
|
|
1091
|
+
return yield* campaigns(policy, ["packages"], rest);
|
|
640
1092
|
case "check":
|
|
641
1093
|
return yield* check(policy, roots, {
|
|
642
1094
|
format: json ? "json" : "text",
|
|
@@ -664,7 +1116,7 @@ configFilename) => Effect.gen(function* () {
|
|
|
664
1116
|
return yield* facts(policy, file, json ? "json" : "text");
|
|
665
1117
|
}
|
|
666
1118
|
default:
|
|
667
|
-
return yield* Effect.fail(fail(`unknown command "${command}". Try: check [--json] | conformance [--json] [--against <manifest>] | baseline | coverage | explain <file> | facts <file> [--json] | init | infer | migrate`));
|
|
1119
|
+
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`));
|
|
668
1120
|
}
|
|
669
1121
|
});
|
|
670
1122
|
export const fingerprint = fingerprintOf;
|