@goodbones/cli 0.1.0-beta.6 → 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 +594 -24
package/src/run.ts
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
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
5
|
|
|
5
6
|
import {
|
|
7
|
+
allowed,
|
|
6
8
|
type Baseline,
|
|
7
9
|
baselineOf,
|
|
10
|
+
type CampaignHit,
|
|
11
|
+
campaignsSelecting,
|
|
12
|
+
type CompiledCampaign,
|
|
8
13
|
type CoverageFamily,
|
|
9
14
|
coverageOf,
|
|
10
15
|
cyclesIn,
|
|
11
16
|
decodeBaseline,
|
|
12
17
|
decodeManifest,
|
|
13
18
|
EMPTY_BASELINE,
|
|
19
|
+
entryOf,
|
|
20
|
+
evaluateCampaigns,
|
|
14
21
|
evaluateGraph,
|
|
15
22
|
evaluateMemberSite,
|
|
16
23
|
evaluateResolvedEdge,
|
|
17
24
|
evaluateSelectedBindings,
|
|
18
25
|
evaluateStructure,
|
|
19
26
|
evaluateSurface,
|
|
27
|
+
explainCampaign,
|
|
20
28
|
exportRulesSelecting,
|
|
21
29
|
findManifestFile,
|
|
22
30
|
fingerprintOf,
|
|
@@ -26,20 +34,31 @@ import {
|
|
|
26
34
|
type Graph,
|
|
27
35
|
hasGraphRules,
|
|
28
36
|
heightOf,
|
|
37
|
+
isComplete,
|
|
38
|
+
isStalled,
|
|
39
|
+
leafTermsOf,
|
|
40
|
+
type Ledger,
|
|
41
|
+
ledgerArithmeticHolds,
|
|
42
|
+
ledgerOf,
|
|
29
43
|
listSourceFiles,
|
|
30
44
|
makeBaselineFilter,
|
|
31
45
|
MANIFEST_FILENAMES,
|
|
32
46
|
MANIFEST_SCHEMA_ID,
|
|
33
47
|
memberRulesSelecting,
|
|
34
48
|
type ObservedEdge,
|
|
49
|
+
progressOf,
|
|
50
|
+
pruned,
|
|
35
51
|
readManifestFile,
|
|
52
|
+
reconcile,
|
|
36
53
|
requiredSiblingsOf,
|
|
37
54
|
residueOf,
|
|
38
55
|
rulesSelecting,
|
|
39
56
|
serializeBaseline,
|
|
57
|
+
serializeLedger,
|
|
40
58
|
slackOf,
|
|
41
59
|
type Snapshot,
|
|
42
60
|
SNAPSHOT_VERSION,
|
|
61
|
+
type SnapshotCampaign,
|
|
43
62
|
type SourceFacts,
|
|
44
63
|
staleEntriesOf,
|
|
45
64
|
surfaceRulesSelecting,
|
|
@@ -61,10 +80,10 @@ import { sourceFactsOf } from "./source-facts.js";
|
|
|
61
80
|
// second way to ask the same question — and the only way to write a baseline,
|
|
62
81
|
// since that needs every finding at once rather than one file at a time.
|
|
63
82
|
//
|
|
64
|
-
// It covers all four families. The
|
|
65
|
-
// rather than oxlint's; both adapters meet at the
|
|
66
|
-
// a binding, a member site — so they answer to
|
|
67
|
-
// other.
|
|
83
|
+
// It covers all four families. The ones that need a syntax tree read it through
|
|
84
|
+
// the language pack's own parse rather than oxlint's; both adapters meet at the
|
|
85
|
+
// same vocabulary — a specifier, a binding, a member site — so they answer to
|
|
86
|
+
// the same core rather than to each other.
|
|
68
87
|
|
|
69
88
|
export type CliFailure = { readonly _tag: "CliFailure"; readonly message: string };
|
|
70
89
|
|
|
@@ -80,6 +99,10 @@ export type UnresolvedEdge = {
|
|
|
80
99
|
|
|
81
100
|
export type Findings = {
|
|
82
101
|
readonly violations: ReadonlyArray<Violation>;
|
|
102
|
+
// Every campaign hit, ledgered or not. Kept apart from the violations: a
|
|
103
|
+
// hit is debt a campaign is paying down, judged against its ledger rather
|
|
104
|
+
// than the baseline.
|
|
105
|
+
readonly campaigns: ReadonlyArray<CampaignHit>;
|
|
83
106
|
readonly unresolved: ReadonlyArray<UnresolvedEdge>;
|
|
84
107
|
readonly files: number;
|
|
85
108
|
// Every edge resolved from a file under an import rule — what the slack
|
|
@@ -103,16 +126,25 @@ export const collectFindings = (
|
|
|
103
126
|
): Findings => {
|
|
104
127
|
const files = listSourceFiles(policy.repoRoot, roots, policy.languages);
|
|
105
128
|
const violations: Array<Violation> = [];
|
|
129
|
+
const campaigns: Array<CampaignHit> = [];
|
|
106
130
|
const unresolved: Array<UnresolvedEdge> = [];
|
|
107
131
|
const edges: Array<ObservedEdge> = [];
|
|
108
132
|
|
|
109
|
-
// Each file is parsed at most once, whether the per-file
|
|
110
|
-
// graph pass asks first.
|
|
133
|
+
// Each file is read and parsed at most once, whether the per-file
|
|
134
|
+
// families, the graph pass or a campaign asks first.
|
|
135
|
+
const texts = new Map<string, string>();
|
|
136
|
+
const textOf = (file: string): string => {
|
|
137
|
+
const cached = texts.get(file);
|
|
138
|
+
if (cached !== undefined) return cached;
|
|
139
|
+
const text = readFileSync(path.join(policy.repoRoot, file), "utf8");
|
|
140
|
+
texts.set(file, text);
|
|
141
|
+
return text;
|
|
142
|
+
};
|
|
111
143
|
const parsed = new Map<string, SourceFacts>();
|
|
112
144
|
const factsOf = (file: string): SourceFacts => {
|
|
113
145
|
const cached = parsed.get(file);
|
|
114
146
|
if (cached !== undefined) return cached;
|
|
115
|
-
const facts =
|
|
147
|
+
const facts = policy.extractor.factsOf(file, textOf(file));
|
|
116
148
|
parsed.set(file, facts);
|
|
117
149
|
return facts;
|
|
118
150
|
};
|
|
@@ -132,6 +164,31 @@ export const collectFindings = (
|
|
|
132
164
|
violations.push(violation);
|
|
133
165
|
}
|
|
134
166
|
|
|
167
|
+
// A campaign selects by its scope. The file is parsed by the scope's
|
|
168
|
+
// matcher once, and only when a term of some selected campaign reads the
|
|
169
|
+
// syntax tree.
|
|
170
|
+
const selectedCampaigns = campaignsSelecting(policy.campaignRules, file);
|
|
171
|
+
if (selectedCampaigns.length > 0) {
|
|
172
|
+
const text = textOf(file);
|
|
173
|
+
const needsSyntax = selectedCampaigns.some((rule) =>
|
|
174
|
+
leafTermsOf(rule.detect).some(
|
|
175
|
+
(leaf) => leaf === "syntax" || leaf === "report" || leaf === "fn",
|
|
176
|
+
),
|
|
177
|
+
);
|
|
178
|
+
for (const hit of evaluateCampaigns(selectedCampaigns, {
|
|
179
|
+
file,
|
|
180
|
+
text,
|
|
181
|
+
facts: factsOf(file),
|
|
182
|
+
resolver: policy.resolver,
|
|
183
|
+
fileSystem: policy.fileSystem,
|
|
184
|
+
syntax: needsSyntax ? policy.syntax.parse(file, text) : null,
|
|
185
|
+
functions: policy.functions,
|
|
186
|
+
reports: policy.reports,
|
|
187
|
+
})) {
|
|
188
|
+
campaigns.push(hit);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
135
192
|
const selectedImports = rulesSelecting(policy.importRules, file);
|
|
136
193
|
const selectedExports = exportRulesSelecting(policy.exportRules, file);
|
|
137
194
|
const selectedMembers = memberRulesSelecting(policy.memberRules, file);
|
|
@@ -186,7 +243,7 @@ export const collectFindings = (
|
|
|
186
243
|
}
|
|
187
244
|
}
|
|
188
245
|
|
|
189
|
-
return { violations, unresolved, files: files.length, edges, graph };
|
|
246
|
+
return { violations, campaigns, unresolved, files: files.length, edges, graph };
|
|
190
247
|
};
|
|
191
248
|
|
|
192
249
|
const baselinePathOf = (policy: LoadedPolicy): string | null =>
|
|
@@ -218,6 +275,29 @@ const describe = (violation: Violation): string =>
|
|
|
218
275
|
export type ReportedViolation = Violation & {
|
|
219
276
|
readonly fingerprint: string;
|
|
220
277
|
readonly baselined: boolean;
|
|
278
|
+
// For a campaign hit: carried by the campaign's ledger, so `check` does
|
|
279
|
+
// not fail on it. The campaign analogue of `baselined`.
|
|
280
|
+
readonly ledgered: boolean;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// One campaign, as `check` sees it: how many hits, which are new, which
|
|
284
|
+
// ledger entries no longer fire, and whether the ledger adds up.
|
|
285
|
+
export type CampaignReport = {
|
|
286
|
+
readonly id: string;
|
|
287
|
+
readonly count: number;
|
|
288
|
+
// Hits the ledger does not carry — unrecorded growth, as entries.
|
|
289
|
+
readonly new: ReadonlyArray<string>;
|
|
290
|
+
// Ledger entries no hit produces — fixed, and waiting to be pruned.
|
|
291
|
+
readonly stale: ReadonlyArray<string>;
|
|
292
|
+
// Entries whose hash moved under a still-present anchor.
|
|
293
|
+
readonly drifted: number;
|
|
294
|
+
// No ledger file: `campaigns init` has not been run.
|
|
295
|
+
readonly missingLedger: boolean;
|
|
296
|
+
// `entries.length === initial + Σ delta − fixed`.
|
|
297
|
+
readonly arithmetic: boolean;
|
|
298
|
+
readonly complete: boolean;
|
|
299
|
+
readonly stalled: boolean;
|
|
300
|
+
readonly onComplete: "keep" | "remove";
|
|
221
301
|
};
|
|
222
302
|
|
|
223
303
|
export type CoverageReport = Readonly<
|
|
@@ -245,8 +325,77 @@ export type CheckReport = {
|
|
|
245
325
|
readonly unrestricted: ReadonlyArray<string>;
|
|
246
326
|
readonly partial: ReadonlyArray<string>;
|
|
247
327
|
};
|
|
328
|
+
readonly campaigns: ReadonlyArray<CampaignReport>;
|
|
248
329
|
};
|
|
249
330
|
|
|
331
|
+
// Each campaign's hits against its ledger. A campaign with no ledger has
|
|
332
|
+
// every hit new; one with no hits and no ledger has nothing to say.
|
|
333
|
+
const campaignReportsOf = (
|
|
334
|
+
policy: LoadedPolicy,
|
|
335
|
+
hits: ReadonlyArray<CampaignHit>,
|
|
336
|
+
): ReadonlyArray<CampaignReport> =>
|
|
337
|
+
policy.campaignRules.map((rule) => {
|
|
338
|
+
const own = hits.filter((hit) => hit.campaign === rule.id).map((hit) => hit.violation);
|
|
339
|
+
const ledger = policy.ledgers.get(rule.id);
|
|
340
|
+
if (ledger === undefined) {
|
|
341
|
+
return {
|
|
342
|
+
id: rule.id,
|
|
343
|
+
count: own.length,
|
|
344
|
+
new: [...new Set(own.map(entryOf))].sort(),
|
|
345
|
+
stale: [],
|
|
346
|
+
drifted: 0,
|
|
347
|
+
missingLedger: true,
|
|
348
|
+
arithmetic: true,
|
|
349
|
+
complete: own.length === 0,
|
|
350
|
+
stalled: false,
|
|
351
|
+
onComplete: rule.onComplete,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
const state = reconcile(ledger, own, rule.unit);
|
|
355
|
+
return {
|
|
356
|
+
id: rule.id,
|
|
357
|
+
count: own.length,
|
|
358
|
+
new: [...new Set(state.unrecorded.map(entryOf))].sort(),
|
|
359
|
+
stale: state.stale,
|
|
360
|
+
drifted: state.drifted.length,
|
|
361
|
+
missingLedger: false,
|
|
362
|
+
arithmetic: ledgerArithmeticHolds(ledger),
|
|
363
|
+
complete: isComplete(ledger) && own.length === 0,
|
|
364
|
+
stalled: isStalled(rule, ledger, policy.now),
|
|
365
|
+
onComplete: rule.onComplete,
|
|
366
|
+
};
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// Whether a campaign hit is carried by its ledger — exactly, or by anchor.
|
|
370
|
+
const ledgeredFilter = (
|
|
371
|
+
policy: LoadedPolicy,
|
|
372
|
+
hits: ReadonlyArray<CampaignHit>,
|
|
373
|
+
): ((hit: CampaignHit) => boolean) => {
|
|
374
|
+
const carried = new Set<Violation>();
|
|
375
|
+
for (const rule of policy.campaignRules) {
|
|
376
|
+
const ledger = policy.ledgers.get(rule.id);
|
|
377
|
+
if (ledger === undefined) continue;
|
|
378
|
+
const own = hits.filter((hit) => hit.campaign === rule.id).map((hit) => hit.violation);
|
|
379
|
+
for (const one of reconcile(ledger, own, rule.unit).ledgered) carried.add(one);
|
|
380
|
+
}
|
|
381
|
+
return (hit) => carried.has(hit.violation);
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// Why a campaign report is not ok, in the order `check` explains it.
|
|
385
|
+
const campaignFailuresOf = (campaigns: ReadonlyArray<CampaignReport>): ReadonlyArray<string> => [
|
|
386
|
+
...campaigns.filter((one) => one.stale.length > 0).map(() => "stale ledger entries"),
|
|
387
|
+
...campaigns.filter((one) => !one.arithmetic).map(() => "ledger arithmetic does not hold"),
|
|
388
|
+
...campaigns
|
|
389
|
+
.filter((one) => one.missingLedger && one.count > 0)
|
|
390
|
+
.map((one) => `campaign ${one.id} has no ledger`),
|
|
391
|
+
...campaigns
|
|
392
|
+
.filter((one) => !one.missingLedger && one.new.length > 0)
|
|
393
|
+
.map(() => "unrecorded campaign growth"),
|
|
394
|
+
...campaigns
|
|
395
|
+
.filter((one) => one.complete && !one.missingLedger && one.onComplete === "remove")
|
|
396
|
+
.map((one) => `campaign ${one.id} is complete and declared onComplete: remove`),
|
|
397
|
+
];
|
|
398
|
+
|
|
250
399
|
const COVERAGE_FAMILIES: ReadonlyArray<CoverageFamily> = [
|
|
251
400
|
"imports",
|
|
252
401
|
"structure",
|
|
@@ -278,11 +427,22 @@ const reportOf = (
|
|
|
278
427
|
const baseline = readBaseline(policy);
|
|
279
428
|
const stale = staleEntriesOf(baseline, findings.violations);
|
|
280
429
|
const { isBaselined } = makeBaselineFilter(baseline);
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
430
|
+
const isLedgered = ledgeredFilter(policy, findings.campaigns);
|
|
431
|
+
const violations = [
|
|
432
|
+
...findings.violations.map((violation) => ({
|
|
433
|
+
...violation,
|
|
434
|
+
fingerprint: fingerprintOf(violation),
|
|
435
|
+
baselined: isBaselined(violation),
|
|
436
|
+
ledgered: false,
|
|
437
|
+
})),
|
|
438
|
+
...findings.campaigns.map((hit) => ({
|
|
439
|
+
...hit.violation,
|
|
440
|
+
fingerprint: fingerprintOf(hit.violation),
|
|
441
|
+
baselined: false,
|
|
442
|
+
ledgered: isLedgered(hit),
|
|
443
|
+
})),
|
|
444
|
+
];
|
|
445
|
+
const campaigns = campaignReportsOf(policy, findings.campaigns);
|
|
286
446
|
|
|
287
447
|
// The floors. A policy states how much of the tree it reaches, per
|
|
288
448
|
// family; falling under is a policy that quietly stopped covering files.
|
|
@@ -305,7 +465,7 @@ const reportOf = (
|
|
|
305
465
|
) as CoverageReport;
|
|
306
466
|
const shortfalls = shortfallsOf(coverage);
|
|
307
467
|
|
|
308
|
-
const reportable = violations.filter((one) => !one.baselined).length;
|
|
468
|
+
const reportable = violations.filter((one) => !one.baselined && !one.ledgered).length;
|
|
309
469
|
return {
|
|
310
470
|
version: 1,
|
|
311
471
|
files: findings.files,
|
|
@@ -314,7 +474,8 @@ const reportOf = (
|
|
|
314
474
|
reportable === 0 &&
|
|
315
475
|
findings.unresolved.length === 0 &&
|
|
316
476
|
stale.length === 0 &&
|
|
317
|
-
shortfalls.length === 0
|
|
477
|
+
shortfalls.length === 0 &&
|
|
478
|
+
campaignFailuresOf(campaigns).length === 0,
|
|
318
479
|
manifest: {
|
|
319
480
|
path: path.relative(policy.repoRoot, manifestPath).replaceAll(path.sep, "/"),
|
|
320
481
|
sha256: sha256Of(manifestPath),
|
|
@@ -327,6 +488,7 @@ const reportOf = (
|
|
|
327
488
|
unrestricted: policy.adoption.unrestricted,
|
|
328
489
|
partial: policy.adoption.partial,
|
|
329
490
|
},
|
|
491
|
+
campaigns,
|
|
330
492
|
};
|
|
331
493
|
};
|
|
332
494
|
|
|
@@ -351,14 +513,80 @@ const failureOf = (
|
|
|
351
513
|
shortfalls: ReadonlyArray<Shortfall>,
|
|
352
514
|
): CliFailure | null => {
|
|
353
515
|
if (report.stale.length > 0) return fail("stale baseline entries");
|
|
516
|
+
const [campaignFailure] = campaignFailuresOf(report.campaigns);
|
|
517
|
+
if (campaignFailure !== undefined) return fail(campaignFailure);
|
|
354
518
|
if (shortfalls.length > 0) return fail("coverage below floor");
|
|
355
519
|
if (report.ok) return null;
|
|
356
520
|
return fail("architecture violations");
|
|
357
521
|
};
|
|
358
522
|
|
|
523
|
+
// A campaign hit, with the campaign's `how` as its instruction.
|
|
524
|
+
const describeHit = (violation: ReportedViolation): string =>
|
|
525
|
+
` ${violation.file}${violation.subject === null ? "" : ` (${violation.subject})`}\n ${formatMessage(violation)}`;
|
|
526
|
+
|
|
527
|
+
const renderCampaigns = (report: CheckReport): ReadonlyArray<string> => {
|
|
528
|
+
const hits = report.violations.filter((one) => one.kind === "campaign");
|
|
529
|
+
return report.campaigns.flatMap((campaign): ReadonlyArray<string> => {
|
|
530
|
+
const rule = `campaign/${campaign.id}`;
|
|
531
|
+
const fresh = new Set(campaign.new);
|
|
532
|
+
const own = hits.filter((one) => one.ruleName === rule && fresh.has(entryOf(one)));
|
|
533
|
+
if (campaign.missingLedger && campaign.count > 0) {
|
|
534
|
+
return [
|
|
535
|
+
"",
|
|
536
|
+
`campaign ${campaign.id}: ${count(campaign.count, "hit")} and no ledger. Record them before they count as growth:`,
|
|
537
|
+
"",
|
|
538
|
+
` architecture campaigns init ${campaign.id}`,
|
|
539
|
+
];
|
|
540
|
+
}
|
|
541
|
+
const complete = campaign.complete && !campaign.missingLedger;
|
|
542
|
+
return [
|
|
543
|
+
...(campaign.new.length === 0
|
|
544
|
+
? []
|
|
545
|
+
: [
|
|
546
|
+
"",
|
|
547
|
+
`campaign ${campaign.id}: ${count(campaign.new.length, "new hit")} the ledger does not carry. Fix them, or record why the count may rise:`,
|
|
548
|
+
...own.map(describeHit),
|
|
549
|
+
"",
|
|
550
|
+
` architecture campaigns allow ${campaign.id} --reason "<why>"`,
|
|
551
|
+
]),
|
|
552
|
+
...(campaign.stale.length === 0
|
|
553
|
+
? []
|
|
554
|
+
: [
|
|
555
|
+
"",
|
|
556
|
+
`campaign ${campaign.id}: ${count(campaign.stale.length, "ledger entry", "ledger entries")} no longer fire. The code was fixed; prune them:`,
|
|
557
|
+
...campaign.stale.map((entry) => ` ${entry}`),
|
|
558
|
+
"",
|
|
559
|
+
` architecture campaigns prune ${campaign.id}`,
|
|
560
|
+
]),
|
|
561
|
+
...(campaign.arithmetic
|
|
562
|
+
? []
|
|
563
|
+
: [
|
|
564
|
+
"",
|
|
565
|
+
`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\`.`,
|
|
566
|
+
]),
|
|
567
|
+
...(complete && campaign.onComplete === "remove"
|
|
568
|
+
? [
|
|
569
|
+
"",
|
|
570
|
+
`campaign ${campaign.id} is complete and declares onComplete: remove. Delete it from the manifest, and its ledger.`,
|
|
571
|
+
]
|
|
572
|
+
: []),
|
|
573
|
+
...(campaign.stalled
|
|
574
|
+
? [
|
|
575
|
+
"",
|
|
576
|
+
`notice: campaign ${campaign.id} has stalled — no entry has left its ledger within its staleAfter.`,
|
|
577
|
+
]
|
|
578
|
+
: []),
|
|
579
|
+
...(complete && campaign.onComplete === "keep"
|
|
580
|
+
? ["", `notice: campaign ${campaign.id} is complete, and stays as a guard.`]
|
|
581
|
+
: []),
|
|
582
|
+
];
|
|
583
|
+
});
|
|
584
|
+
};
|
|
585
|
+
|
|
359
586
|
const renderText = (report: CheckReport): ReadonlyArray<string> => {
|
|
360
|
-
const reportable = report.violations.filter((one) => !one.baselined);
|
|
361
|
-
const carried =
|
|
587
|
+
const reportable = report.violations.filter((one) => one.kind !== "campaign" && !one.baselined);
|
|
588
|
+
const carried =
|
|
589
|
+
report.violations.filter((one) => one.kind !== "campaign").length - reportable.length;
|
|
362
590
|
const shortfalls = shortfallsOf(report.coverage);
|
|
363
591
|
return [
|
|
364
592
|
...reportable.map(describe),
|
|
@@ -390,6 +618,7 @@ const renderText = (report: CheckReport): ReadonlyArray<string> => {
|
|
|
390
618
|
"",
|
|
391
619
|
" architecture coverage # which files no rule reaches",
|
|
392
620
|
]),
|
|
621
|
+
...renderCampaigns(report),
|
|
393
622
|
];
|
|
394
623
|
};
|
|
395
624
|
|
|
@@ -416,6 +645,9 @@ export const check = (
|
|
|
416
645
|
|
|
417
646
|
const percent = (fraction: number): string => `${String(Math.floor(fraction * 100))}%`;
|
|
418
647
|
|
|
648
|
+
const count = (n: number, noun: string, plural = `${noun}s`): string =>
|
|
649
|
+
`${String(n)} ${n === 1 ? noun : plural}`;
|
|
650
|
+
|
|
419
651
|
// The conformance snapshot: `check`'s report grown with what no family
|
|
420
652
|
// reaches, what the allowlists permit and nothing uses, the cycle count and
|
|
421
653
|
// the size of the debt — the whole distance between the tree and the
|
|
@@ -449,6 +681,41 @@ export const snapshotOf = (
|
|
|
449
681
|
// that rather than as lines nobody needs.
|
|
450
682
|
const { concentration, slack } = slackOf(policy.importRules, findings.edges, files);
|
|
451
683
|
|
|
684
|
+
const campaigns: ReadonlyArray<SnapshotCampaign> = policy.campaignRules.map((rule) => {
|
|
685
|
+
const ledger = policy.ledgers.get(rule.id);
|
|
686
|
+
const own = findings.campaigns.filter((hit) => hit.campaign === rule.id).length;
|
|
687
|
+
const base: SnapshotCampaign = {
|
|
688
|
+
id: rule.id,
|
|
689
|
+
...(rule.title === null ? {} : { title: rule.title }),
|
|
690
|
+
...(rule.owner === null ? {} : { owner: rule.owner }),
|
|
691
|
+
initial: own,
|
|
692
|
+
allowed: 0,
|
|
693
|
+
count: own,
|
|
694
|
+
fixed: 0,
|
|
695
|
+
progress: 0,
|
|
696
|
+
lastProgress: new Date(policy.now).toISOString(),
|
|
697
|
+
regressions: 0,
|
|
698
|
+
stalled: false,
|
|
699
|
+
complete: own === 0,
|
|
700
|
+
onComplete: rule.onComplete,
|
|
701
|
+
ledgered: false,
|
|
702
|
+
};
|
|
703
|
+
if (ledger === undefined) return base;
|
|
704
|
+
return {
|
|
705
|
+
...base,
|
|
706
|
+
initial: ledger.initial,
|
|
707
|
+
allowed: ledger.regressions.reduce((sum, one) => sum + one.delta, 0),
|
|
708
|
+
count: ledger.entries.length,
|
|
709
|
+
fixed: ledger.fixed,
|
|
710
|
+
progress: progressOf(ledger),
|
|
711
|
+
lastProgress: ledger.lastProgress,
|
|
712
|
+
regressions: ledger.regressions.length,
|
|
713
|
+
stalled: isStalled(rule, ledger, policy.now),
|
|
714
|
+
complete: isComplete(ledger),
|
|
715
|
+
ledgered: true,
|
|
716
|
+
};
|
|
717
|
+
});
|
|
718
|
+
|
|
452
719
|
return {
|
|
453
720
|
version: SNAPSHOT_VERSION,
|
|
454
721
|
manifest: report_.manifest,
|
|
@@ -466,14 +733,33 @@ export const snapshotOf = (
|
|
|
466
733
|
slack,
|
|
467
734
|
concentration,
|
|
468
735
|
adoption: report_.adoption,
|
|
736
|
+
campaigns,
|
|
469
737
|
};
|
|
470
738
|
};
|
|
471
739
|
|
|
740
|
+
// The campaigns, stalled and complete first, then by progress.
|
|
741
|
+
const renderCampaignRows = (campaigns: ReadonlyArray<SnapshotCampaign>): ReadonlyArray<string> => {
|
|
742
|
+
const width = Math.max(0, ...campaigns.map((one) => one.id.length));
|
|
743
|
+
const state = (one: SnapshotCampaign): string =>
|
|
744
|
+
!one.ledgered ? "no ledger" : one.complete ? "complete" : one.stalled ? "stalled" : "";
|
|
745
|
+
const ordered = [...campaigns].sort((left, right) => {
|
|
746
|
+
const rank = (one: SnapshotCampaign): number =>
|
|
747
|
+
one.stalled ? 0 : one.complete && one.ledgered ? 1 : 2;
|
|
748
|
+
const byRank = rank(left) - rank(right);
|
|
749
|
+
return byRank !== 0 ? byRank : left.progress - right.progress;
|
|
750
|
+
});
|
|
751
|
+
return ordered.map(
|
|
752
|
+
(one) =>
|
|
753
|
+
` ${one.id.padEnd(width)} ${percent(one.progress).padStart(4)} ${String(one.count).padStart(5)} left` +
|
|
754
|
+
` ${String(one.fixed)} fixed ${String(one.allowed)} allowed` +
|
|
755
|
+
(one.owner === undefined ? "" : ` ${one.owner}`) +
|
|
756
|
+
(state(one) === "" ? "" : ` ${state(one)}`),
|
|
757
|
+
);
|
|
758
|
+
};
|
|
759
|
+
|
|
472
760
|
const renderSnapshot = (snapshot: Snapshot): ReadonlyArray<string> => {
|
|
473
|
-
const reportable = snapshot.violations.filter((one) => !one.baselined);
|
|
761
|
+
const reportable = snapshot.violations.filter((one) => !one.baselined && !one.ledgered);
|
|
474
762
|
const carried = snapshot.violations.length - reportable.length;
|
|
475
|
-
const count = (n: number, noun: string, plural = `${noun}s`): string =>
|
|
476
|
-
`${String(n)} ${n === 1 ? noun : plural}`;
|
|
477
763
|
const row = (family: CoverageFamily): string => {
|
|
478
764
|
const { covered, floor, total } = snapshot.coverage[family];
|
|
479
765
|
const fraction = total === 0 ? 1 : covered / total;
|
|
@@ -520,7 +806,7 @@ const renderSnapshot = (snapshot: Snapshot): ReadonlyArray<string> => {
|
|
|
520
806
|
),
|
|
521
807
|
...section(
|
|
522
808
|
`violations: ${count(reportable.length, "reportable")}` +
|
|
523
|
-
(carried > 0 ? `, ${String(carried)} carried by the baseline` : "") +
|
|
809
|
+
(carried > 0 ? `, ${String(carried)} carried by the baseline or a ledger` : "") +
|
|
524
810
|
(snapshot.stale.length > 0
|
|
525
811
|
? `, ${count(snapshot.stale.length, "stale entry", "stale entries")}`
|
|
526
812
|
: "") +
|
|
@@ -550,6 +836,18 @@ const renderSnapshot = (snapshot: Snapshot): ReadonlyArray<string> => {
|
|
|
550
836
|
` ${one.fragment}: ${one.kind} ${JSON.stringify(one.entry)} used at ${String(one.usedAt)} of ${count(one.of, "node")}`,
|
|
551
837
|
),
|
|
552
838
|
)),
|
|
839
|
+
...(snapshot.campaigns.length === 0
|
|
840
|
+
? []
|
|
841
|
+
: section(
|
|
842
|
+
`campaigns: ${count(snapshot.campaigns.length, "campaign")}` +
|
|
843
|
+
(snapshot.campaigns.some((one) => one.stalled)
|
|
844
|
+
? `, ${count(snapshot.campaigns.filter((one) => one.stalled).length, "stalled", "stalled")}`
|
|
845
|
+
: "") +
|
|
846
|
+
(snapshot.campaigns.some((one) => one.complete && one.ledgered)
|
|
847
|
+
? `, ${count(snapshot.campaigns.filter((one) => one.complete && one.ledgered).length, "complete", "complete")}`
|
|
848
|
+
: ""),
|
|
849
|
+
renderCampaignRows(snapshot.campaigns),
|
|
850
|
+
)),
|
|
553
851
|
"",
|
|
554
852
|
`cycles: ${String(snapshot.cycles)}`,
|
|
555
853
|
`baseline: ${count(snapshot.baseline.size, "entry", "entries")}`,
|
|
@@ -669,7 +967,8 @@ export const explain = (policy: LoadedPolicy, file: string): Effect.Effect<void,
|
|
|
669
967
|
!rule.fileNot.some((pattern) => pattern.test(relative)),
|
|
670
968
|
);
|
|
671
969
|
|
|
672
|
-
const firstSentence = (message: string) =>
|
|
970
|
+
const firstSentence = (message: string) =>
|
|
971
|
+
`${(message.split(". ")[0] ?? message).replace(/\.$/, "")}.`;
|
|
673
972
|
const named = (rule: { readonly name: string; readonly message: string }): string =>
|
|
674
973
|
` ${rule.name} — ${firstSentence(rule.message)}`;
|
|
675
974
|
|
|
@@ -695,6 +994,33 @@ export const explain = (policy: LoadedPolicy, file: string): Effect.Effect<void,
|
|
|
695
994
|
const section = (title: string, lines: ReadonlyArray<string>): ReadonlyArray<string> =>
|
|
696
995
|
lines.length === 0 ? [] : ["", title, ...lines];
|
|
697
996
|
|
|
997
|
+
// Each campaign selecting the file, with its truth table: one line per
|
|
998
|
+
// leaf term and what it answered here, so a detector that "should fire"
|
|
999
|
+
// and does not shows which term is not saying what its author thinks.
|
|
1000
|
+
const selectedCampaigns = campaignsSelecting(policy.campaignRules, relative);
|
|
1001
|
+
const campaignLines = selectedCampaigns.flatMap((rule) => {
|
|
1002
|
+
const at = path.join(policy.repoRoot, relative);
|
|
1003
|
+
const text = existsSync(at) ? readFileSync(at, "utf8") : "";
|
|
1004
|
+
const input = {
|
|
1005
|
+
file: relative,
|
|
1006
|
+
text,
|
|
1007
|
+
facts: policy.extractor.factsOf(relative, text),
|
|
1008
|
+
resolver: policy.resolver,
|
|
1009
|
+
fileSystem: policy.fileSystem,
|
|
1010
|
+
syntax: policy.syntax.parse(relative, text),
|
|
1011
|
+
functions: policy.functions,
|
|
1012
|
+
reports: policy.reports,
|
|
1013
|
+
};
|
|
1014
|
+
const hits = evaluateCampaigns([rule], input);
|
|
1015
|
+
return [
|
|
1016
|
+
` ${rule.name} — ${firstSentence(rule.why)} (${rule.unit}; ${hits.length === 0 ? "no hit" : count(hits.length, "hit")})`,
|
|
1017
|
+
...explainCampaign(rule, input).map(
|
|
1018
|
+
(line) =>
|
|
1019
|
+
` ${line.answer ? "✓" : "✗"} ${line.term}${line.count === undefined ? "" : ` (${String(line.count)})`}`,
|
|
1020
|
+
),
|
|
1021
|
+
];
|
|
1022
|
+
});
|
|
1023
|
+
|
|
698
1024
|
yield* report([
|
|
699
1025
|
relative,
|
|
700
1026
|
"",
|
|
@@ -727,6 +1053,7 @@ export const explain = (policy: LoadedPolicy, file: string): Effect.Effect<void,
|
|
|
727
1053
|
...section(" vocabulary (members):", vocabulary.map(named)),
|
|
728
1054
|
...section(" may export (surface):", surface.map(named)),
|
|
729
1055
|
...section(" graph:", graph),
|
|
1056
|
+
...section(" campaigns:", campaignLines),
|
|
730
1057
|
]);
|
|
731
1058
|
});
|
|
732
1059
|
|
|
@@ -830,6 +1157,21 @@ limits:
|
|
|
830
1157
|
unrestricted: 0
|
|
831
1158
|
partial: 0
|
|
832
1159
|
|
|
1160
|
+
# Migrations the repository is running, each with a detector, a rationale, a
|
|
1161
|
+
# guide, an owner and a ledger of every place the pattern still occurs. Fill
|
|
1162
|
+
# one in, then \`architecture campaigns init <id>\` to write its ledger.
|
|
1163
|
+
# https://dataquail.github.io/goodbones/architecture-rules/manifest/campaigns/
|
|
1164
|
+
# campaigns:
|
|
1165
|
+
# - id: js-to-ts
|
|
1166
|
+
# why: The strict tsconfig cannot land while any src file is JavaScript.
|
|
1167
|
+
# how: Rename to .ts, add types at the module boundary, leave the body alone.
|
|
1168
|
+
# scope: ["src/**"]
|
|
1169
|
+
# unit: file
|
|
1170
|
+
# detect: { path: { file: "\\.(js|jsx)$" } }
|
|
1171
|
+
# probes: { fires: [{ path: src/legacy/util.js }], ignores: [{ path: src/util.ts }] }
|
|
1172
|
+
# staleAfter: 14d
|
|
1173
|
+
# onComplete: remove
|
|
1174
|
+
|
|
833
1175
|
# The repository. One open root, reaching itself and the runtime; run
|
|
834
1176
|
# \`architecture check\` to see what else it reaches, and write that down here.
|
|
835
1177
|
# https://dataquail.github.io/goodbones/architecture-rules/manifest/imports/
|
|
@@ -918,6 +1260,232 @@ export const migrate = (
|
|
|
918
1260
|
]);
|
|
919
1261
|
});
|
|
920
1262
|
|
|
1263
|
+
// The ledgers. `campaigns` alone is the status table; `init` writes a
|
|
1264
|
+
// campaign's first ledger from what fires today; `prune` removes what no
|
|
1265
|
+
// longer fires; `allow` is the one way an entry is added, and it records why.
|
|
1266
|
+
const ledgerPathOf = (policy: LoadedPolicy, id: string): string =>
|
|
1267
|
+
path.resolve(policy.repoRoot, policy.ledgerDir, `${id}.json`);
|
|
1268
|
+
|
|
1269
|
+
const writeLedger = (policy: LoadedPolicy, ledger: Ledger): void => {
|
|
1270
|
+
const at = ledgerPathOf(policy, ledger.id);
|
|
1271
|
+
mkdirSync(path.dirname(at), { recursive: true });
|
|
1272
|
+
writeFileSync(at, serializeLedger(ledger));
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
const campaignNamed = (policy: LoadedPolicy, id: string): CompiledCampaign | null =>
|
|
1276
|
+
policy.campaignRules.find((rule) => rule.id === id) ?? null;
|
|
1277
|
+
|
|
1278
|
+
// The author of a regression: `--by`, else git's user.email, else the
|
|
1279
|
+
// GIT_AUTHOR_EMAIL the environment carries. Without one the record is refused
|
|
1280
|
+
// rather than written blank, since the record is the point.
|
|
1281
|
+
const authorOf = (given: string | undefined): string | null => {
|
|
1282
|
+
if (given !== undefined && given !== "") return given;
|
|
1283
|
+
try {
|
|
1284
|
+
const email = execFileSync("git", ["config", "user.email"], {
|
|
1285
|
+
encoding: "utf8",
|
|
1286
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1287
|
+
}).trim();
|
|
1288
|
+
if (email !== "") return email;
|
|
1289
|
+
} catch {
|
|
1290
|
+
// git absent, or no email configured
|
|
1291
|
+
}
|
|
1292
|
+
const fromEnvironment = process.env.GIT_AUTHOR_EMAIL;
|
|
1293
|
+
return fromEnvironment === undefined || fromEnvironment === "" ? null : fromEnvironment;
|
|
1294
|
+
};
|
|
1295
|
+
|
|
1296
|
+
const flagOf = (argv: ReadonlyArray<string>, flag: string): string | undefined => {
|
|
1297
|
+
const at = argv.indexOf(flag);
|
|
1298
|
+
const value = at === -1 ? undefined : argv[at + 1];
|
|
1299
|
+
return value === undefined || value.startsWith("--") ? undefined : value;
|
|
1300
|
+
};
|
|
1301
|
+
|
|
1302
|
+
const CAMPAIGN_SUBCOMMANDS = ["init", "prune", "allow"] as const;
|
|
1303
|
+
const CAMPAIGN_VALUE_FLAGS = ["--reason", "--by", "--entries"] as const;
|
|
1304
|
+
|
|
1305
|
+
// `campaigns [init <id> | prune [<id>] | allow <id> --reason <text>] [roots…]`:
|
|
1306
|
+
// the subcommand and its id come first; whatever positional is left names
|
|
1307
|
+
// the roots to walk, as it does for every other command.
|
|
1308
|
+
export const campaignArgsOf = (
|
|
1309
|
+
argv: ReadonlyArray<string>,
|
|
1310
|
+
ids: ReadonlyArray<string>,
|
|
1311
|
+
): {
|
|
1312
|
+
readonly subcommand: string | undefined;
|
|
1313
|
+
readonly id: string | undefined;
|
|
1314
|
+
readonly roots: ReadonlyArray<string>;
|
|
1315
|
+
} => {
|
|
1316
|
+
const positional: Array<string> = [];
|
|
1317
|
+
for (let at = 0; at < argv.length; at += 1) {
|
|
1318
|
+
const one = argv[at] ?? "";
|
|
1319
|
+
if ((CAMPAIGN_VALUE_FLAGS as ReadonlyArray<string>).includes(one)) {
|
|
1320
|
+
at += 1;
|
|
1321
|
+
continue;
|
|
1322
|
+
}
|
|
1323
|
+
if (!one.startsWith("--")) positional.push(one);
|
|
1324
|
+
}
|
|
1325
|
+
const [first, second, ...rest] = positional;
|
|
1326
|
+
if (first === undefined || !(CAMPAIGN_SUBCOMMANDS as ReadonlyArray<string>).includes(first)) {
|
|
1327
|
+
return { subcommand: undefined, id: undefined, roots: positional };
|
|
1328
|
+
}
|
|
1329
|
+
// `prune` takes an optional id; the next word is one only if a campaign
|
|
1330
|
+
// has that name, else it is a root.
|
|
1331
|
+
const takesId = first !== "prune" || (second !== undefined && ids.includes(second));
|
|
1332
|
+
return takesId
|
|
1333
|
+
? { subcommand: first, id: second, roots: rest }
|
|
1334
|
+
: { subcommand: first, id: undefined, roots: second === undefined ? [] : [second, ...rest] };
|
|
1335
|
+
};
|
|
1336
|
+
|
|
1337
|
+
export const campaigns = (
|
|
1338
|
+
policy: LoadedPolicy,
|
|
1339
|
+
defaultRoots: ReadonlyArray<string>,
|
|
1340
|
+
argv: ReadonlyArray<string>,
|
|
1341
|
+
): Effect.Effect<void, CliFailure> =>
|
|
1342
|
+
Effect.gen(function* () {
|
|
1343
|
+
const parsed = campaignArgsOf(
|
|
1344
|
+
argv,
|
|
1345
|
+
policy.campaignRules.map((rule) => rule.id),
|
|
1346
|
+
);
|
|
1347
|
+
const { id, subcommand } = parsed;
|
|
1348
|
+
const roots = parsed.roots.length > 0 ? parsed.roots : defaultRoots;
|
|
1349
|
+
if (policy.campaignRules.length === 0) {
|
|
1350
|
+
return yield* report(["this policy declares no campaigns."]);
|
|
1351
|
+
}
|
|
1352
|
+
const hitsOf = (): ReadonlyArray<CampaignHit> => collectFindings(policy, roots).campaigns;
|
|
1353
|
+
const own = (hits: ReadonlyArray<CampaignHit>, campaign: string): ReadonlyArray<Violation> =>
|
|
1354
|
+
hits.filter((hit) => hit.campaign === campaign).map((hit) => hit.violation);
|
|
1355
|
+
|
|
1356
|
+
switch (subcommand) {
|
|
1357
|
+
case undefined: {
|
|
1358
|
+
const snapshot = snapshotOf(policy, roots, manifestPathOf(policy.repoRoot));
|
|
1359
|
+
return yield* report([
|
|
1360
|
+
`${count(snapshot.campaigns.length, "campaign")} under ${roots.join(", ")}`,
|
|
1361
|
+
"",
|
|
1362
|
+
...renderCampaignRows(snapshot.campaigns),
|
|
1363
|
+
"",
|
|
1364
|
+
" architecture campaigns init <id> # write a ledger from what fires today",
|
|
1365
|
+
" architecture campaigns prune [<id>] # drop entries that no longer fire",
|
|
1366
|
+
' architecture campaigns allow <id> --reason "<why>" # record why the count may rise',
|
|
1367
|
+
]);
|
|
1368
|
+
}
|
|
1369
|
+
case "init": {
|
|
1370
|
+
if (id === undefined) return yield* Effect.fail(fail("campaigns init needs a campaign id"));
|
|
1371
|
+
const rule = campaignNamed(policy, id);
|
|
1372
|
+
if (rule === null) return yield* Effect.fail(fail(`no campaign is named "${id}"`));
|
|
1373
|
+
if (policy.ledgers.has(id)) {
|
|
1374
|
+
return yield* Effect.fail(
|
|
1375
|
+
fail(
|
|
1376
|
+
`${path.relative(policy.repoRoot, ledgerPathOf(policy, id))} already exists. \`init\` ` +
|
|
1377
|
+
`writes a campaign's first ledger and does not overwrite one; \`prune\` and \`allow\` ` +
|
|
1378
|
+
`are how it changes.`,
|
|
1379
|
+
),
|
|
1380
|
+
);
|
|
1381
|
+
}
|
|
1382
|
+
const ledger = ledgerOf(id, own(hitsOf(), id), policy.now);
|
|
1383
|
+
yield* Effect.sync(() => {
|
|
1384
|
+
writeLedger(policy, ledger);
|
|
1385
|
+
});
|
|
1386
|
+
return yield* report([
|
|
1387
|
+
`${count(ledger.entries.length, "hit")} recorded in ${path.relative(policy.repoRoot, ledgerPathOf(policy, id))}.`,
|
|
1388
|
+
"Each one is a place the campaign has yet to reach. Fixing one means pruning its line.",
|
|
1389
|
+
]);
|
|
1390
|
+
}
|
|
1391
|
+
case "prune": {
|
|
1392
|
+
const targets =
|
|
1393
|
+
id === undefined
|
|
1394
|
+
? policy.campaignRules
|
|
1395
|
+
: [campaignNamed(policy, id)].filter((one) => one !== null);
|
|
1396
|
+
if (id !== undefined && targets.length === 0) {
|
|
1397
|
+
return yield* Effect.fail(fail(`no campaign is named "${id}"`));
|
|
1398
|
+
}
|
|
1399
|
+
const hits = hitsOf();
|
|
1400
|
+
const lines: Array<string> = [];
|
|
1401
|
+
for (const rule of targets) {
|
|
1402
|
+
const ledger = policy.ledgers.get(rule.id);
|
|
1403
|
+
if (ledger === undefined) {
|
|
1404
|
+
lines.push(`${rule.id}: no ledger to prune (run \`campaigns init ${rule.id}\`).`);
|
|
1405
|
+
continue;
|
|
1406
|
+
}
|
|
1407
|
+
const next = pruned(ledger, own(hits, rule.id), rule.unit, policy.now);
|
|
1408
|
+
const removed = ledger.entries.length - next.entries.length;
|
|
1409
|
+
const rewritten = next.entries.filter((entry) => !ledger.entries.includes(entry)).length;
|
|
1410
|
+
if (removed === 0 && rewritten === 0) {
|
|
1411
|
+
lines.push(`${rule.id}: nothing to prune.`);
|
|
1412
|
+
continue;
|
|
1413
|
+
}
|
|
1414
|
+
yield* Effect.sync(() => {
|
|
1415
|
+
writeLedger(policy, next);
|
|
1416
|
+
});
|
|
1417
|
+
lines.push(
|
|
1418
|
+
`${rule.id}: ${count(removed, "entry", "entries")} pruned` +
|
|
1419
|
+
(rewritten > 0 ? `, ${count(rewritten, "entry", "entries")} rewritten` : "") +
|
|
1420
|
+
`; ${count(next.entries.length, "entry", "entries")} left.`,
|
|
1421
|
+
);
|
|
1422
|
+
}
|
|
1423
|
+
return yield* report(lines);
|
|
1424
|
+
}
|
|
1425
|
+
case "allow": {
|
|
1426
|
+
if (id === undefined)
|
|
1427
|
+
return yield* Effect.fail(fail("campaigns allow needs a campaign id"));
|
|
1428
|
+
const rule = campaignNamed(policy, id);
|
|
1429
|
+
if (rule === null) return yield* Effect.fail(fail(`no campaign is named "${id}"`));
|
|
1430
|
+
const ledger = policy.ledgers.get(id);
|
|
1431
|
+
if (ledger === undefined) {
|
|
1432
|
+
return yield* Effect.fail(
|
|
1433
|
+
fail(`campaign ${id} has no ledger yet; run \`campaigns init ${id}\` first.`),
|
|
1434
|
+
);
|
|
1435
|
+
}
|
|
1436
|
+
const reason = flagOf(argv, "--reason");
|
|
1437
|
+
if (reason === undefined) {
|
|
1438
|
+
return yield* Effect.fail(
|
|
1439
|
+
fail(
|
|
1440
|
+
"campaigns allow needs --reason <text>: growth is recorded with why, or not at all.",
|
|
1441
|
+
),
|
|
1442
|
+
);
|
|
1443
|
+
}
|
|
1444
|
+
const by = authorOf(flagOf(argv, "--by"));
|
|
1445
|
+
if (by === null) {
|
|
1446
|
+
return yield* Effect.fail(
|
|
1447
|
+
fail("campaigns allow needs an author: pass --by <email>, or set git's user.email."),
|
|
1448
|
+
);
|
|
1449
|
+
}
|
|
1450
|
+
const state = reconcile(ledger, own(hitsOf(), id), rule.unit);
|
|
1451
|
+
const unrecorded = [...new Set(state.unrecorded.map(entryOf))].sort();
|
|
1452
|
+
// `--entries` allows a subset and refuses the rest: a pull request
|
|
1453
|
+
// that legitimately adds one hit while another is an accident.
|
|
1454
|
+
const chosen =
|
|
1455
|
+
flagOf(argv, "--entries")
|
|
1456
|
+
?.split(",")
|
|
1457
|
+
.map((one) => one.trim()) ?? unrecorded;
|
|
1458
|
+
const unknown = chosen.filter((entry) => !unrecorded.includes(entry));
|
|
1459
|
+
if (unknown.length > 0) {
|
|
1460
|
+
return yield* Effect.fail(
|
|
1461
|
+
fail(`these entries are not unrecorded hits of ${id}: ${unknown.join(", ")}`),
|
|
1462
|
+
);
|
|
1463
|
+
}
|
|
1464
|
+
if (chosen.length === 0) {
|
|
1465
|
+
return yield* report([`${id}: nothing to allow; every hit is in the ledger.`]);
|
|
1466
|
+
}
|
|
1467
|
+
const next = allowed(ledger, chosen, { at: policy.now, by, reason });
|
|
1468
|
+
yield* Effect.sync(() => {
|
|
1469
|
+
writeLedger(policy, next);
|
|
1470
|
+
});
|
|
1471
|
+
const left = unrecorded.filter((entry) => !chosen.includes(entry));
|
|
1472
|
+
return yield* report([
|
|
1473
|
+
`${id}: ${count(chosen.length, "entry", "entries")} allowed, recorded as a regression by ${by}.`,
|
|
1474
|
+
...chosen.map((entry) => ` ${entry}`),
|
|
1475
|
+
...(left.length === 0
|
|
1476
|
+
? []
|
|
1477
|
+
: ["", `${count(left.length, "hit")} left unrecorded; check still fails on them.`]),
|
|
1478
|
+
]);
|
|
1479
|
+
}
|
|
1480
|
+
default:
|
|
1481
|
+
return yield* Effect.fail(
|
|
1482
|
+
fail(
|
|
1483
|
+
`unknown campaigns subcommand "${subcommand}". Try: campaigns | campaigns init <id> | campaigns prune [<id>] | campaigns allow <id> --reason <text> [--by <email>] [--entries a,b]`,
|
|
1484
|
+
),
|
|
1485
|
+
);
|
|
1486
|
+
}
|
|
1487
|
+
});
|
|
1488
|
+
|
|
921
1489
|
export const run = (
|
|
922
1490
|
repoRoot: string,
|
|
923
1491
|
argv: ReadonlyArray<string>,
|
|
@@ -969,6 +1537,8 @@ export const run = (
|
|
|
969
1537
|
const roots = positional.length > 0 ? positional : ["packages"];
|
|
970
1538
|
|
|
971
1539
|
switch (command) {
|
|
1540
|
+
case "campaigns":
|
|
1541
|
+
return yield* campaigns(policy, ["packages"], rest);
|
|
972
1542
|
case "check":
|
|
973
1543
|
return yield* check(policy, roots, {
|
|
974
1544
|
format: json ? "json" : "text",
|
|
@@ -996,7 +1566,7 @@ export const run = (
|
|
|
996
1566
|
default:
|
|
997
1567
|
return yield* Effect.fail(
|
|
998
1568
|
fail(
|
|
999
|
-
`unknown command "${command}". Try: check [--json] | conformance [--json] [--against <manifest>] | baseline | coverage | explain <file> | facts <file> [--json] | init | infer | migrate`,
|
|
1569
|
+
`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`,
|
|
1000
1570
|
),
|
|
1001
1571
|
);
|
|
1002
1572
|
}
|