@goodbones/cli 0.1.0-beta.10 → 0.1.0-beta.12
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.map +1 -1
- package/build/dts/run.d.ts +18 -17
- package/build/dts/run.d.ts.map +1 -1
- package/build/esm/config-loader.js +4 -4
- package/build/esm/config-loader.js.map +1 -1
- package/build/esm/run.js +389 -358
- package/build/esm/run.js.map +1 -1
- package/package.json +5 -4
- package/src/config-loader.ts +7 -5
- package/src/run.ts +521 -441
package/src/run.ts
CHANGED
|
@@ -1,15 +1,39 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
2
1
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { existsSync,
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
3
|
import * as path from "node:path";
|
|
5
4
|
|
|
6
5
|
import {
|
|
7
|
-
|
|
6
|
+
attest,
|
|
7
|
+
authorOf,
|
|
8
|
+
baseSideAt,
|
|
9
|
+
type CampaignEvaluation,
|
|
10
|
+
campaignFailuresOf,
|
|
11
|
+
type CampaignReport,
|
|
12
|
+
campaignReportsOf,
|
|
13
|
+
campaignsOf,
|
|
14
|
+
campaignsSelecting,
|
|
15
|
+
clear,
|
|
16
|
+
concede,
|
|
17
|
+
evaluateCampaigns,
|
|
18
|
+
explainCampaignLines,
|
|
19
|
+
explainObjective,
|
|
20
|
+
historyOf,
|
|
21
|
+
hitsInWindow,
|
|
22
|
+
ledgeredFilter,
|
|
23
|
+
note,
|
|
24
|
+
nudgeOf,
|
|
25
|
+
readDiff,
|
|
26
|
+
renderCampaignReports,
|
|
27
|
+
renderCampaignRows,
|
|
28
|
+
renderHistory,
|
|
29
|
+
renderNudge,
|
|
30
|
+
reportSpecsOf,
|
|
31
|
+
snapshotCampaignsOf,
|
|
32
|
+
widenedExtensions,
|
|
33
|
+
} from "@goodbones/campaigns";
|
|
34
|
+
import {
|
|
8
35
|
type Baseline,
|
|
9
36
|
baselineOf,
|
|
10
|
-
type CampaignHit,
|
|
11
|
-
campaignsSelecting,
|
|
12
|
-
type CompiledCampaign,
|
|
13
37
|
CONFORMANCE_MEASURES,
|
|
14
38
|
type ConformanceMeasure,
|
|
15
39
|
type CoverageFamily,
|
|
@@ -18,15 +42,12 @@ import {
|
|
|
18
42
|
decodeBaseline,
|
|
19
43
|
decodeManifest,
|
|
20
44
|
EMPTY_BASELINE,
|
|
21
|
-
entryOf,
|
|
22
|
-
evaluateCampaigns,
|
|
23
45
|
evaluateGraph,
|
|
24
46
|
evaluateMemberSite,
|
|
25
47
|
evaluateResolvedEdge,
|
|
26
48
|
evaluateSelectedBindings,
|
|
27
49
|
evaluateStructure,
|
|
28
50
|
evaluateSurface,
|
|
29
|
-
explainCampaign,
|
|
30
51
|
exportRulesSelecting,
|
|
31
52
|
findManifestFile,
|
|
32
53
|
fingerprintOf,
|
|
@@ -36,32 +57,20 @@ import {
|
|
|
36
57
|
type Graph,
|
|
37
58
|
hasGraphRules,
|
|
38
59
|
heightOf,
|
|
39
|
-
isComplete,
|
|
40
|
-
isStalled,
|
|
41
|
-
leafTermsOf,
|
|
42
|
-
type Ledger,
|
|
43
|
-
ledgerArithmeticHolds,
|
|
44
|
-
ledgerOf,
|
|
45
60
|
listSourceFiles,
|
|
46
61
|
makeBaselineFilter,
|
|
47
62
|
MANIFEST_FILENAMES,
|
|
48
63
|
MANIFEST_SCHEMA_ID,
|
|
49
64
|
memberRulesSelecting,
|
|
50
65
|
type ObservedEdge,
|
|
51
|
-
progressOf,
|
|
52
|
-
pruned,
|
|
53
66
|
readManifestFile,
|
|
54
|
-
reconcile,
|
|
55
|
-
reportSpecsOf,
|
|
56
67
|
requiredSiblingsOf,
|
|
57
68
|
residueOf,
|
|
58
69
|
rulesSelecting,
|
|
59
70
|
serializeBaseline,
|
|
60
|
-
serializeLedger,
|
|
61
71
|
slackOf,
|
|
62
72
|
type Snapshot,
|
|
63
73
|
SNAPSHOT_VERSION,
|
|
64
|
-
type SnapshotCampaign,
|
|
65
74
|
type SourceFacts,
|
|
66
75
|
staleEntriesOf,
|
|
67
76
|
surfaceRulesSelecting,
|
|
@@ -102,10 +111,11 @@ export type UnresolvedEdge = {
|
|
|
102
111
|
|
|
103
112
|
export type Findings = {
|
|
104
113
|
readonly violations: ReadonlyArray<Violation>;
|
|
105
|
-
// Every campaign
|
|
106
|
-
// hit
|
|
107
|
-
//
|
|
108
|
-
|
|
114
|
+
// Every campaign, evaluated over the files it sees: its sectors, every
|
|
115
|
+
// hit placed in one, each sector's phase. Kept apart from the violations:
|
|
116
|
+
// a hit is debt a campaign is paying down, judged against its ledger
|
|
117
|
+
// rather than the baseline.
|
|
118
|
+
readonly campaigns: ReadonlyArray<CampaignEvaluation>;
|
|
109
119
|
readonly unresolved: ReadonlyArray<UnresolvedEdge>;
|
|
110
120
|
readonly files: number;
|
|
111
121
|
// Every edge resolved from a file under an import rule — what the slack
|
|
@@ -127,9 +137,17 @@ export const collectFindings = (
|
|
|
127
137
|
roots: ReadonlyArray<string>,
|
|
128
138
|
options: CollectOptions = {},
|
|
129
139
|
): Findings => {
|
|
130
|
-
|
|
140
|
+
// A campaign may widen the walk past the packs' extensions; a file only a
|
|
141
|
+
// campaign asked for is seen by the campaigns and by no other family.
|
|
142
|
+
const walked = listSourceFiles(
|
|
143
|
+
policy.repoRoot,
|
|
144
|
+
roots,
|
|
145
|
+
policy.languages,
|
|
146
|
+
widenedExtensions(policy),
|
|
147
|
+
);
|
|
148
|
+
const known = new Set(policy.languages.flatMap((one) => one.extensions));
|
|
149
|
+
const files = walked.filter((file) => known.has(path.extname(file)));
|
|
131
150
|
const violations: Array<Violation> = [];
|
|
132
|
-
const campaigns: Array<CampaignHit> = [];
|
|
133
151
|
const unresolved: Array<UnresolvedEdge> = [];
|
|
134
152
|
const edges: Array<ObservedEdge> = [];
|
|
135
153
|
|
|
@@ -162,36 +180,14 @@ export const collectFindings = (
|
|
|
162
180
|
for (const violation of evaluateGraph(policy.graph, graph)) violations.push(violation);
|
|
163
181
|
}
|
|
164
182
|
|
|
183
|
+
// The campaigns, over every walked file, through the same caches.
|
|
184
|
+
const campaigns = evaluateCampaigns(policy, roots, walked, { textOf, factsOf });
|
|
185
|
+
|
|
165
186
|
for (const file of files) {
|
|
166
187
|
for (const violation of evaluateStructure(policy.structure, policy.fileSystem, file)) {
|
|
167
188
|
violations.push(violation);
|
|
168
189
|
}
|
|
169
190
|
|
|
170
|
-
// A campaign selects by its scope. The file is parsed by the scope's
|
|
171
|
-
// matcher once, and only when a term of some selected campaign reads the
|
|
172
|
-
// syntax tree.
|
|
173
|
-
const selectedCampaigns = campaignsSelecting(policy.campaignRules, file);
|
|
174
|
-
if (selectedCampaigns.length > 0) {
|
|
175
|
-
const text = textOf(file);
|
|
176
|
-
const needsSyntax = selectedCampaigns.some((rule) =>
|
|
177
|
-
leafTermsOf(rule.detect).some(
|
|
178
|
-
(leaf) => leaf === "syntax" || leaf === "report" || leaf === "fn",
|
|
179
|
-
),
|
|
180
|
-
);
|
|
181
|
-
for (const hit of evaluateCampaigns(selectedCampaigns, {
|
|
182
|
-
file,
|
|
183
|
-
text,
|
|
184
|
-
facts: factsOf(file),
|
|
185
|
-
resolver: policy.resolver,
|
|
186
|
-
fileSystem: policy.fileSystem,
|
|
187
|
-
syntax: needsSyntax ? policy.syntax.parse(file, text) : null,
|
|
188
|
-
functions: policy.functions,
|
|
189
|
-
reports: policy.reports,
|
|
190
|
-
})) {
|
|
191
|
-
campaigns.push(hit);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
191
|
const selectedImports = rulesSelecting(policy.importRules, file);
|
|
196
192
|
const selectedExports = exportRulesSelecting(policy.exportRules, file);
|
|
197
193
|
const selectedMembers = memberRulesSelecting(policy.memberRules, file);
|
|
@@ -278,29 +274,14 @@ const describe = (violation: Violation): string =>
|
|
|
278
274
|
export type ReportedViolation = Violation & {
|
|
279
275
|
readonly fingerprint: string;
|
|
280
276
|
readonly baselined: boolean;
|
|
281
|
-
// For a campaign hit: carried by the
|
|
277
|
+
// For a campaign hit: carried by the objective's ledger, so `check` does
|
|
282
278
|
// not fail on it. The campaign analogue of `baselined`.
|
|
283
279
|
readonly ledgered: boolean;
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
readonly id: string;
|
|
290
|
-
readonly count: number;
|
|
291
|
-
// Hits the ledger does not carry — unrecorded growth, as entries.
|
|
292
|
-
readonly new: ReadonlyArray<string>;
|
|
293
|
-
// Ledger entries no hit produces — fixed, and waiting to be pruned.
|
|
294
|
-
readonly stale: ReadonlyArray<string>;
|
|
295
|
-
// Entries whose hash moved under a still-present anchor.
|
|
296
|
-
readonly drifted: number;
|
|
297
|
-
// No ledger file: `campaigns init` has not been run.
|
|
298
|
-
readonly missingLedger: boolean;
|
|
299
|
-
// `entries.length === initial + Σ delta − fixed`.
|
|
300
|
-
readonly arithmetic: boolean;
|
|
301
|
-
readonly complete: boolean;
|
|
302
|
-
readonly stalled: boolean;
|
|
303
|
-
readonly onComplete: "keep" | "remove";
|
|
280
|
+
// For a campaign hit: which objective, in which sector, and the ledger
|
|
281
|
+
// entry it is keyed by there.
|
|
282
|
+
readonly objective?: string;
|
|
283
|
+
readonly sector?: string;
|
|
284
|
+
readonly entry?: string;
|
|
304
285
|
};
|
|
305
286
|
|
|
306
287
|
export type CoverageReport = Readonly<
|
|
@@ -339,74 +320,6 @@ export type CheckReport = {
|
|
|
339
320
|
readonly campaigns: ReadonlyArray<CampaignReport>;
|
|
340
321
|
};
|
|
341
322
|
|
|
342
|
-
// Each campaign's hits against its ledger. A campaign with no ledger has
|
|
343
|
-
// every hit new; one with no hits and no ledger has nothing to say.
|
|
344
|
-
const campaignReportsOf = (
|
|
345
|
-
policy: LoadedPolicy,
|
|
346
|
-
hits: ReadonlyArray<CampaignHit>,
|
|
347
|
-
): ReadonlyArray<CampaignReport> =>
|
|
348
|
-
policy.campaignRules.map((rule) => {
|
|
349
|
-
const own = hits.filter((hit) => hit.campaign === rule.id).map((hit) => hit.violation);
|
|
350
|
-
const ledger = policy.ledgers.get(rule.id);
|
|
351
|
-
if (ledger === undefined) {
|
|
352
|
-
return {
|
|
353
|
-
id: rule.id,
|
|
354
|
-
count: own.length,
|
|
355
|
-
new: [...new Set(own.map(entryOf))].sort(),
|
|
356
|
-
stale: [],
|
|
357
|
-
drifted: 0,
|
|
358
|
-
missingLedger: true,
|
|
359
|
-
arithmetic: true,
|
|
360
|
-
complete: own.length === 0,
|
|
361
|
-
stalled: false,
|
|
362
|
-
onComplete: rule.onComplete,
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
const state = reconcile(ledger, own, rule.unit);
|
|
366
|
-
return {
|
|
367
|
-
id: rule.id,
|
|
368
|
-
count: own.length,
|
|
369
|
-
new: [...new Set(state.unrecorded.map(entryOf))].sort(),
|
|
370
|
-
stale: state.stale,
|
|
371
|
-
drifted: state.drifted.length,
|
|
372
|
-
missingLedger: false,
|
|
373
|
-
arithmetic: ledgerArithmeticHolds(ledger),
|
|
374
|
-
complete: isComplete(ledger) && own.length === 0,
|
|
375
|
-
stalled: isStalled(rule, ledger, policy.now),
|
|
376
|
-
onComplete: rule.onComplete,
|
|
377
|
-
};
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
// Whether a campaign hit is carried by its ledger — exactly, or by anchor.
|
|
381
|
-
const ledgeredFilter = (
|
|
382
|
-
policy: LoadedPolicy,
|
|
383
|
-
hits: ReadonlyArray<CampaignHit>,
|
|
384
|
-
): ((hit: CampaignHit) => boolean) => {
|
|
385
|
-
const carried = new Set<Violation>();
|
|
386
|
-
for (const rule of policy.campaignRules) {
|
|
387
|
-
const ledger = policy.ledgers.get(rule.id);
|
|
388
|
-
if (ledger === undefined) continue;
|
|
389
|
-
const own = hits.filter((hit) => hit.campaign === rule.id).map((hit) => hit.violation);
|
|
390
|
-
for (const one of reconcile(ledger, own, rule.unit).ledgered) carried.add(one);
|
|
391
|
-
}
|
|
392
|
-
return (hit) => carried.has(hit.violation);
|
|
393
|
-
};
|
|
394
|
-
|
|
395
|
-
// Why a campaign report is not ok, in the order `check` explains it.
|
|
396
|
-
const campaignFailuresOf = (campaigns: ReadonlyArray<CampaignReport>): ReadonlyArray<string> => [
|
|
397
|
-
...campaigns.filter((one) => one.stale.length > 0).map(() => "stale ledger entries"),
|
|
398
|
-
...campaigns.filter((one) => !one.arithmetic).map(() => "ledger arithmetic does not hold"),
|
|
399
|
-
...campaigns
|
|
400
|
-
.filter((one) => one.missingLedger && one.count > 0)
|
|
401
|
-
.map((one) => `campaign ${one.id} has no ledger`),
|
|
402
|
-
...campaigns
|
|
403
|
-
.filter((one) => !one.missingLedger && one.new.length > 0)
|
|
404
|
-
.map(() => "unrecorded campaign growth"),
|
|
405
|
-
...campaigns
|
|
406
|
-
.filter((one) => one.complete && !one.missingLedger && one.onComplete === "remove")
|
|
407
|
-
.map((one) => `campaign ${one.id} is complete and declared onComplete: remove`),
|
|
408
|
-
];
|
|
409
|
-
|
|
410
323
|
const COVERAGE_FAMILIES: ReadonlyArray<CoverageFamily> = [
|
|
411
324
|
"imports",
|
|
412
325
|
"structure",
|
|
@@ -482,19 +395,26 @@ const reportOf = (
|
|
|
482
395
|
const stale = staleEntriesOf(baseline, findings.violations);
|
|
483
396
|
const { isBaselined } = makeBaselineFilter(baseline);
|
|
484
397
|
const isLedgered = ledgeredFilter(policy, findings.campaigns);
|
|
485
|
-
const violations = [
|
|
398
|
+
const violations: Array<ReportedViolation> = [
|
|
486
399
|
...findings.violations.map((violation) => ({
|
|
487
400
|
...violation,
|
|
488
401
|
fingerprint: fingerprintOf(violation),
|
|
489
402
|
baselined: isBaselined(violation),
|
|
490
403
|
ledgered: false,
|
|
491
404
|
})),
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
405
|
+
// The hits that count: those whose objective is in window for the
|
|
406
|
+
// sector they fall in.
|
|
407
|
+
...findings.campaigns.flatMap((evaluation) =>
|
|
408
|
+
hitsInWindow(evaluation).map((hit) => ({
|
|
409
|
+
...hit.violation,
|
|
410
|
+
fingerprint: fingerprintOf(hit.violation),
|
|
411
|
+
baselined: false,
|
|
412
|
+
ledgered: isLedgered(hit),
|
|
413
|
+
objective: hit.objective,
|
|
414
|
+
sector: hit.sector,
|
|
415
|
+
entry: hit.entry,
|
|
416
|
+
})),
|
|
417
|
+
),
|
|
498
418
|
];
|
|
499
419
|
const campaigns = campaignReportsOf(policy, findings.campaigns);
|
|
500
420
|
|
|
@@ -622,68 +542,26 @@ const describeExcess = (one: Excess): string => {
|
|
|
622
542
|
return ` ${one.measure}: ${count(one.count, singular, plural)}, ceiling ${String(one.ceiling)}`;
|
|
623
543
|
};
|
|
624
544
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
const fresh = new Set(campaign.new);
|
|
634
|
-
const own = hits.filter((one) => one.ruleName === rule && fresh.has(entryOf(one)));
|
|
635
|
-
if (campaign.missingLedger && campaign.count > 0) {
|
|
636
|
-
return [
|
|
637
|
-
"",
|
|
638
|
-
`campaign ${campaign.id}: ${count(campaign.count, "hit")} and no ledger. Record them before they count as growth:`,
|
|
639
|
-
"",
|
|
640
|
-
` architecture campaigns init ${campaign.id}`,
|
|
641
|
-
];
|
|
642
|
-
}
|
|
643
|
-
const complete = campaign.complete && !campaign.missingLedger;
|
|
644
|
-
return [
|
|
645
|
-
...(campaign.new.length === 0
|
|
646
|
-
? []
|
|
647
|
-
: [
|
|
648
|
-
"",
|
|
649
|
-
`campaign ${campaign.id}: ${count(campaign.new.length, "new hit")} the ledger does not carry. Fix them, or record why the count may rise:`,
|
|
650
|
-
...own.map(describeHit),
|
|
651
|
-
"",
|
|
652
|
-
` architecture campaigns allow ${campaign.id} --reason "<why>"`,
|
|
653
|
-
]),
|
|
654
|
-
...(campaign.stale.length === 0
|
|
655
|
-
? []
|
|
656
|
-
: [
|
|
657
|
-
"",
|
|
658
|
-
`campaign ${campaign.id}: ${count(campaign.stale.length, "ledger entry", "ledger entries")} no longer fire. The code was fixed; prune them:`,
|
|
659
|
-
...campaign.stale.map((entry) => ` ${entry}`),
|
|
660
|
-
"",
|
|
661
|
-
` architecture campaigns prune ${campaign.id}`,
|
|
662
|
-
]),
|
|
663
|
-
...(campaign.arithmetic
|
|
664
|
-
? []
|
|
665
|
-
: [
|
|
666
|
-
"",
|
|
667
|
-
`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\`.`,
|
|
668
|
-
]),
|
|
669
|
-
...(complete && campaign.onComplete === "remove"
|
|
670
|
-
? [
|
|
671
|
-
"",
|
|
672
|
-
`campaign ${campaign.id} is complete and declares onComplete: remove. Delete it from the manifest, and its ledger.`,
|
|
673
|
-
]
|
|
674
|
-
: []),
|
|
675
|
-
...(campaign.stalled
|
|
545
|
+
const renderCampaigns = (report: CheckReport): ReadonlyArray<string> =>
|
|
546
|
+
renderCampaignReports(
|
|
547
|
+
report.campaigns,
|
|
548
|
+
report.violations.flatMap((one) =>
|
|
549
|
+
one.kind === "campaign" &&
|
|
550
|
+
one.objective !== undefined &&
|
|
551
|
+
one.sector !== undefined &&
|
|
552
|
+
one.entry !== undefined
|
|
676
553
|
? [
|
|
677
|
-
|
|
678
|
-
|
|
554
|
+
{
|
|
555
|
+
violation: one,
|
|
556
|
+
objective: one.objective,
|
|
557
|
+
sector: one.sector,
|
|
558
|
+
entry: one.entry,
|
|
559
|
+
ledgered: one.ledgered,
|
|
560
|
+
},
|
|
679
561
|
]
|
|
680
|
-
: []
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
: []),
|
|
684
|
-
];
|
|
685
|
-
});
|
|
686
|
-
};
|
|
562
|
+
: [],
|
|
563
|
+
),
|
|
564
|
+
);
|
|
687
565
|
|
|
688
566
|
const renderText = (report: CheckReport): ReadonlyArray<string> => {
|
|
689
567
|
const reportable = report.violations.filter((one) => one.kind !== "campaign" && !one.baselined);
|
|
@@ -787,40 +665,7 @@ export const snapshotOf = (
|
|
|
787
665
|
return byHeight !== 0 ? byHeight : left.fingerprint.localeCompare(right.fingerprint);
|
|
788
666
|
});
|
|
789
667
|
|
|
790
|
-
const campaigns
|
|
791
|
-
const ledger = policy.ledgers.get(rule.id);
|
|
792
|
-
const own = findings.campaigns.filter((hit) => hit.campaign === rule.id).length;
|
|
793
|
-
const base: SnapshotCampaign = {
|
|
794
|
-
id: rule.id,
|
|
795
|
-
...(rule.title === null ? {} : { title: rule.title }),
|
|
796
|
-
...(rule.owner === null ? {} : { owner: rule.owner }),
|
|
797
|
-
initial: own,
|
|
798
|
-
allowed: 0,
|
|
799
|
-
count: own,
|
|
800
|
-
fixed: 0,
|
|
801
|
-
progress: 0,
|
|
802
|
-
lastProgress: new Date(policy.now).toISOString(),
|
|
803
|
-
regressions: 0,
|
|
804
|
-
stalled: false,
|
|
805
|
-
complete: own === 0,
|
|
806
|
-
onComplete: rule.onComplete,
|
|
807
|
-
ledgered: false,
|
|
808
|
-
};
|
|
809
|
-
if (ledger === undefined) return base;
|
|
810
|
-
return {
|
|
811
|
-
...base,
|
|
812
|
-
initial: ledger.initial,
|
|
813
|
-
allowed: ledger.regressions.reduce((sum, one) => sum + one.delta, 0),
|
|
814
|
-
count: ledger.entries.length,
|
|
815
|
-
fixed: ledger.fixed,
|
|
816
|
-
progress: progressOf(ledger),
|
|
817
|
-
lastProgress: ledger.lastProgress,
|
|
818
|
-
regressions: ledger.regressions.length,
|
|
819
|
-
stalled: isStalled(rule, ledger, policy.now),
|
|
820
|
-
complete: isComplete(ledger),
|
|
821
|
-
ledgered: true,
|
|
822
|
-
};
|
|
823
|
-
});
|
|
668
|
+
const campaigns = snapshotCampaignsOf(policy, findings.campaigns);
|
|
824
669
|
|
|
825
670
|
return {
|
|
826
671
|
version: SNAPSHOT_VERSION,
|
|
@@ -844,26 +689,6 @@ export const snapshotOf = (
|
|
|
844
689
|
};
|
|
845
690
|
};
|
|
846
691
|
|
|
847
|
-
// The campaigns, stalled and complete first, then by progress.
|
|
848
|
-
const renderCampaignRows = (campaigns: ReadonlyArray<SnapshotCampaign>): ReadonlyArray<string> => {
|
|
849
|
-
const width = Math.max(0, ...campaigns.map((one) => one.id.length));
|
|
850
|
-
const state = (one: SnapshotCampaign): string =>
|
|
851
|
-
!one.ledgered ? "no ledger" : one.complete ? "complete" : one.stalled ? "stalled" : "";
|
|
852
|
-
const ordered = [...campaigns].sort((left, right) => {
|
|
853
|
-
const rank = (one: SnapshotCampaign): number =>
|
|
854
|
-
one.stalled ? 0 : one.complete && one.ledgered ? 1 : 2;
|
|
855
|
-
const byRank = rank(left) - rank(right);
|
|
856
|
-
return byRank !== 0 ? byRank : left.progress - right.progress;
|
|
857
|
-
});
|
|
858
|
-
return ordered.map(
|
|
859
|
-
(one) =>
|
|
860
|
-
` ${one.id.padEnd(width)} ${percent(one.progress).padStart(4)} ${String(one.count).padStart(5)} left` +
|
|
861
|
-
` ${String(one.fixed)} fixed ${String(one.allowed)} allowed` +
|
|
862
|
-
(one.owner === undefined ? "" : ` ${one.owner}`) +
|
|
863
|
-
(state(one) === "" ? "" : ` ${state(one)}`),
|
|
864
|
-
);
|
|
865
|
-
};
|
|
866
|
-
|
|
867
692
|
const renderSnapshot = (snapshot: Snapshot): ReadonlyArray<string> => {
|
|
868
693
|
const reportable = snapshot.violations.filter((one) => !one.baselined && !one.ledgered);
|
|
869
694
|
const carried = snapshot.violations.length - reportable.length;
|
|
@@ -1058,7 +883,11 @@ export const writeBaseline = (
|
|
|
1058
883
|
|
|
1059
884
|
// The question a tree config makes harder to answer than a flat one: given a
|
|
1060
885
|
// file, what governs it? A flat config you grep; a tree you have to walk.
|
|
1061
|
-
export const explain = (
|
|
886
|
+
export const explain = (
|
|
887
|
+
policy: LoadedPolicy,
|
|
888
|
+
file: string,
|
|
889
|
+
roots: ReadonlyArray<string> = ["packages"],
|
|
890
|
+
): Effect.Effect<void, CliFailure> =>
|
|
1062
891
|
Effect.gen(function* () {
|
|
1063
892
|
const relative = path.relative(policy.repoRoot, path.resolve(policy.repoRoot, file));
|
|
1064
893
|
const selected = rulesSelecting(policy.importRules, relative);
|
|
@@ -1113,10 +942,16 @@ export const explain = (policy: LoadedPolicy, file: string): Effect.Effect<void,
|
|
|
1113
942
|
const section = (title: string, lines: ReadonlyArray<string>): ReadonlyArray<string> =>
|
|
1114
943
|
lines.length === 0 ? [] : ["", title, ...lines];
|
|
1115
944
|
|
|
1116
|
-
// Each campaign selecting the file
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
1119
|
-
|
|
945
|
+
// Each campaign selecting the file: the sector the file is in, its
|
|
946
|
+
// phase and definedness, the objectives in window that fire on it and
|
|
947
|
+
// the nearest remaining holdouts — which needs the campaign evaluated
|
|
948
|
+
// over its files, since a sector's phase is derived from all of them —
|
|
949
|
+
// then each objective's truth table: one line per leaf term and what
|
|
950
|
+
// it answered here, so a detector that "should fire" and does not shows
|
|
951
|
+
// which term is not saying what its author thinks.
|
|
952
|
+
const selectedCampaigns = campaignsSelecting(campaignsOf(policy).campaignRules, relative);
|
|
953
|
+
const evaluations =
|
|
954
|
+
selectedCampaigns.length === 0 ? [] : collectFindings(policy, roots).campaigns;
|
|
1120
955
|
const campaignLines = selectedCampaigns.flatMap((rule) => {
|
|
1121
956
|
const at = path.join(policy.repoRoot, relative);
|
|
1122
957
|
const text = existsSync(at) ? readFileSync(at, "utf8") : "";
|
|
@@ -1127,16 +962,24 @@ export const explain = (policy: LoadedPolicy, file: string): Effect.Effect<void,
|
|
|
1127
962
|
resolver: policy.resolver,
|
|
1128
963
|
fileSystem: policy.fileSystem,
|
|
1129
964
|
syntax: policy.syntax.parse(relative, text),
|
|
1130
|
-
functions: policy.functions,
|
|
1131
|
-
reports: policy.reports,
|
|
965
|
+
functions: campaignsOf(policy).functions,
|
|
966
|
+
reports: campaignsOf(policy).reports,
|
|
1132
967
|
};
|
|
1133
|
-
const
|
|
968
|
+
const evaluation = evaluations.find((one) => one.rule.id === rule.id);
|
|
1134
969
|
return [
|
|
1135
|
-
|
|
1136
|
-
...
|
|
1137
|
-
(
|
|
1138
|
-
|
|
1139
|
-
|
|
970
|
+
...(evaluation === undefined ? [] : explainCampaignLines(policy, evaluation, relative)),
|
|
971
|
+
...rule.objectives.flatMap((objective) => {
|
|
972
|
+
const table = explainObjective(objective, input);
|
|
973
|
+
if (table.length === 0) return [];
|
|
974
|
+
const fired = table.length > 0 && table.every((line) => line.answer);
|
|
975
|
+
return [
|
|
976
|
+
` ${objective.name} — ${firstSentence(objective.why ?? objective.message)} (${objective.holdout}; ${fired ? "fires here" : "no hit"})`,
|
|
977
|
+
...table.map(
|
|
978
|
+
(line) =>
|
|
979
|
+
` ${line.answer ? "✓" : "✗"} ${line.term}${line.count === undefined ? "" : ` (${String(line.count)})`}`,
|
|
980
|
+
),
|
|
981
|
+
];
|
|
982
|
+
}),
|
|
1140
983
|
];
|
|
1141
984
|
});
|
|
1142
985
|
|
|
@@ -1278,18 +1121,23 @@ limits:
|
|
|
1278
1121
|
unrestricted: 0
|
|
1279
1122
|
partial: 0
|
|
1280
1123
|
|
|
1281
|
-
#
|
|
1282
|
-
#
|
|
1283
|
-
#
|
|
1124
|
+
# Refactors the repository is running, each an object: objectives (a
|
|
1125
|
+
# detector with a ledger under \`.architecture-campaigns/\` of every place the
|
|
1126
|
+
# pattern still occurs), over sectors the code births through a perimeter,
|
|
1127
|
+
# through phases toward an end. Fill one in, then
|
|
1128
|
+
# \`architecture objectives clear <id>\` to write its ledgers.
|
|
1284
1129
|
# https://dataquail.github.io/goodbones/architecture-rules/manifest/campaigns/
|
|
1285
1130
|
# campaigns:
|
|
1286
|
-
#
|
|
1131
|
+
# js-to-ts:
|
|
1287
1132
|
# why: The strict tsconfig cannot land while any src file is JavaScript.
|
|
1288
1133
|
# how: Rename to .ts, add types at the module boundary, leave the body alone.
|
|
1289
|
-
# scope:
|
|
1290
|
-
#
|
|
1291
|
-
#
|
|
1292
|
-
#
|
|
1134
|
+
# scope: { path: "src/**", extensions: [.js, .jsx] }
|
|
1135
|
+
# perimeter: file
|
|
1136
|
+
# objectives:
|
|
1137
|
+
# is-ts:
|
|
1138
|
+
# holdout: file
|
|
1139
|
+
# match: { path: { file: "\\\\.(js|jsx)$" } }
|
|
1140
|
+
# probes: { fires: [{ path: src/legacy/util.js }], ignores: [{ path: src/util.ts }] }
|
|
1293
1141
|
# staleAfter: 14d
|
|
1294
1142
|
# onComplete: remove
|
|
1295
1143
|
|
|
@@ -1381,38 +1229,12 @@ export const migrate = (
|
|
|
1381
1229
|
]);
|
|
1382
1230
|
});
|
|
1383
1231
|
|
|
1384
|
-
// The
|
|
1385
|
-
//
|
|
1386
|
-
//
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
const writeLedger = (policy: LoadedPolicy, ledger: Ledger): void => {
|
|
1391
|
-
const at = ledgerPathOf(policy, ledger.id);
|
|
1392
|
-
mkdirSync(path.dirname(at), { recursive: true });
|
|
1393
|
-
writeFileSync(at, serializeLedger(ledger));
|
|
1394
|
-
};
|
|
1395
|
-
|
|
1396
|
-
const campaignNamed = (policy: LoadedPolicy, id: string): CompiledCampaign | null =>
|
|
1397
|
-
policy.campaignRules.find((rule) => rule.id === id) ?? null;
|
|
1398
|
-
|
|
1399
|
-
// The author of a regression: `--by`, else git's user.email, else the
|
|
1400
|
-
// GIT_AUTHOR_EMAIL the environment carries. Without one the record is refused
|
|
1401
|
-
// rather than written blank, since the record is the point.
|
|
1402
|
-
const authorOf = (given: string | undefined): string | null => {
|
|
1403
|
-
if (given !== undefined && given !== "") return given;
|
|
1404
|
-
try {
|
|
1405
|
-
const email = execFileSync("git", ["config", "user.email"], {
|
|
1406
|
-
encoding: "utf8",
|
|
1407
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
1408
|
-
}).trim();
|
|
1409
|
-
if (email !== "") return email;
|
|
1410
|
-
} catch {
|
|
1411
|
-
// git absent, or no email configured
|
|
1412
|
-
}
|
|
1413
|
-
const fromEnvironment = process.env.GIT_AUTHOR_EMAIL;
|
|
1414
|
-
return fromEnvironment === undefined || fromEnvironment === "" ? null : fromEnvironment;
|
|
1415
|
-
};
|
|
1232
|
+
// The campaign commands. `campaigns` alone is the status table; `status
|
|
1233
|
+
// --changed` is the nudge; `attest` and `note` write a sector's record;
|
|
1234
|
+
// `history` replays the ledgers' git history. The ledgers themselves are
|
|
1235
|
+
// written by `objectives clear` (the ledger reconciled with the code
|
|
1236
|
+
// wherever that is not a regression) and `objectives concede` (the one way
|
|
1237
|
+
// a holdout is added by hand, with a reason).
|
|
1416
1238
|
|
|
1417
1239
|
const flagOf = (argv: ReadonlyArray<string>, flag: string): string | undefined => {
|
|
1418
1240
|
const at = argv.indexOf(flag);
|
|
@@ -1420,188 +1242,443 @@ const flagOf = (argv: ReadonlyArray<string>, flag: string): string | undefined =
|
|
|
1420
1242
|
return value === undefined || value.startsWith("--") ? undefined : value;
|
|
1421
1243
|
};
|
|
1422
1244
|
|
|
1423
|
-
const CAMPAIGN_SUBCOMMANDS = ["
|
|
1424
|
-
const
|
|
1245
|
+
const CAMPAIGN_SUBCOMMANDS = ["status", "attest", "note", "history", "clear", "concede"] as const;
|
|
1246
|
+
const OBJECTIVE_SUBCOMMANDS = ["clear", "concede"] as const;
|
|
1247
|
+
const VALUE_FLAGS = [
|
|
1248
|
+
"--reason",
|
|
1249
|
+
"--by",
|
|
1250
|
+
"--holdouts",
|
|
1251
|
+
"--entries",
|
|
1252
|
+
"--sector",
|
|
1253
|
+
"--campaign",
|
|
1254
|
+
"--evidence",
|
|
1255
|
+
"--base",
|
|
1256
|
+
"--since",
|
|
1257
|
+
"--hotfix",
|
|
1258
|
+
] as const;
|
|
1259
|
+
|
|
1260
|
+
// The verbs the family shipped with, refused by name: each has a new name
|
|
1261
|
+
// or no place.
|
|
1262
|
+
const RETIRED: Readonly<Record<string, string>> = {
|
|
1263
|
+
init: "`campaigns init` is gone: `objectives clear <campaign>` writes a first ledger, recording each sector's initial.",
|
|
1264
|
+
prune: "`campaigns prune` is now `objectives clear`.",
|
|
1265
|
+
allow: "`campaigns allow` is now `objectives concede`.",
|
|
1266
|
+
};
|
|
1267
|
+
|
|
1268
|
+
// The positionals after the subcommand, with the value flags and their
|
|
1269
|
+
// values stepped over: whatever is left names the roots to walk, as it
|
|
1270
|
+
// does for every other command.
|
|
1271
|
+
const positionalsOf = (argv: ReadonlyArray<string>): ReadonlyArray<string> => {
|
|
1272
|
+
const positional: Array<string> = [];
|
|
1273
|
+
for (let at = 0; at < argv.length; at += 1) {
|
|
1274
|
+
const one = argv[at] ?? "";
|
|
1275
|
+
if ((VALUE_FLAGS as ReadonlyArray<string>).includes(one)) {
|
|
1276
|
+
at += 1;
|
|
1277
|
+
continue;
|
|
1278
|
+
}
|
|
1279
|
+
if (!one.startsWith("--")) positional.push(one);
|
|
1280
|
+
}
|
|
1281
|
+
return positional;
|
|
1282
|
+
};
|
|
1425
1283
|
|
|
1426
|
-
// `campaigns [
|
|
1427
|
-
// the subcommand and its
|
|
1428
|
-
//
|
|
1284
|
+
// `campaigns [status | attest <sector> <phase> | note <sector> "<text>" |
|
|
1285
|
+
// history [<campaign>]] [roots…]`: the subcommand and its arguments come
|
|
1286
|
+
// first.
|
|
1429
1287
|
export const campaignArgsOf = (
|
|
1430
1288
|
argv: ReadonlyArray<string>,
|
|
1431
1289
|
ids: ReadonlyArray<string>,
|
|
1432
1290
|
): {
|
|
1433
1291
|
readonly subcommand: string | undefined;
|
|
1434
|
-
readonly
|
|
1292
|
+
readonly args: ReadonlyArray<string>;
|
|
1435
1293
|
readonly roots: ReadonlyArray<string>;
|
|
1436
1294
|
} => {
|
|
1437
|
-
const positional
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1295
|
+
const positional = positionalsOf(argv);
|
|
1296
|
+
const [first, ...rest] = positional;
|
|
1297
|
+
if (first === undefined) return { subcommand: undefined, args: [], roots: [] };
|
|
1298
|
+
if (first in RETIRED) return { subcommand: first, args: [], roots: rest };
|
|
1299
|
+
if (!(CAMPAIGN_SUBCOMMANDS as ReadonlyArray<string>).includes(first)) {
|
|
1300
|
+
return { subcommand: undefined, args: [], roots: positional };
|
|
1301
|
+
}
|
|
1302
|
+
switch (first) {
|
|
1303
|
+
case "attest":
|
|
1304
|
+
return { subcommand: first, args: rest.slice(0, 2), roots: rest.slice(2) };
|
|
1305
|
+
case "note":
|
|
1306
|
+
return { subcommand: first, args: rest.slice(0, 2), roots: rest.slice(2) };
|
|
1307
|
+
case "history": {
|
|
1308
|
+
const [second] = rest;
|
|
1309
|
+
return second !== undefined && ids.includes(second)
|
|
1310
|
+
? { subcommand: first, args: [second], roots: rest.slice(1) }
|
|
1311
|
+
: { subcommand: first, args: [], roots: rest };
|
|
1443
1312
|
}
|
|
1444
|
-
|
|
1313
|
+
case "clear":
|
|
1314
|
+
case "concede": {
|
|
1315
|
+
const [second] = rest;
|
|
1316
|
+
return second !== undefined && ids.includes(second.split("/")[0] ?? "")
|
|
1317
|
+
? { subcommand: first, args: [second], roots: rest.slice(1) }
|
|
1318
|
+
: { subcommand: first, args: [], roots: rest };
|
|
1319
|
+
}
|
|
1320
|
+
default:
|
|
1321
|
+
return { subcommand: first, args: [], roots: rest };
|
|
1445
1322
|
}
|
|
1323
|
+
};
|
|
1324
|
+
|
|
1325
|
+
// `objectives [clear [<campaign>[/<objective>]] | concede <campaign>[/<objective>]
|
|
1326
|
+
// --reason <text>] [roots…]`.
|
|
1327
|
+
export const objectiveArgsOf = (
|
|
1328
|
+
argv: ReadonlyArray<string>,
|
|
1329
|
+
ids: ReadonlyArray<string>,
|
|
1330
|
+
): {
|
|
1331
|
+
readonly subcommand: string | undefined;
|
|
1332
|
+
readonly target: { campaign: string; objective: string | null } | null;
|
|
1333
|
+
readonly roots: ReadonlyArray<string>;
|
|
1334
|
+
} => {
|
|
1335
|
+
const positional = positionalsOf(argv);
|
|
1446
1336
|
const [first, second, ...rest] = positional;
|
|
1447
|
-
if (first === undefined || !(
|
|
1448
|
-
return { subcommand:
|
|
1337
|
+
if (first === undefined || !(OBJECTIVE_SUBCOMMANDS as ReadonlyArray<string>).includes(first)) {
|
|
1338
|
+
return { subcommand: first, target: null, roots: positional.slice(1) };
|
|
1339
|
+
}
|
|
1340
|
+
const [campaign = "", objective] = (second ?? "").split("/");
|
|
1341
|
+
const named = second !== undefined && ids.includes(campaign);
|
|
1342
|
+
return {
|
|
1343
|
+
subcommand: first,
|
|
1344
|
+
target: named ? { campaign, objective: objective ?? null } : null,
|
|
1345
|
+
roots: named ? rest : second === undefined ? [] : [second, ...rest],
|
|
1346
|
+
};
|
|
1347
|
+
};
|
|
1348
|
+
|
|
1349
|
+
// The one campaign, when there is one, else the one `--campaign` names.
|
|
1350
|
+
const campaignFor = (
|
|
1351
|
+
policy: LoadedPolicy,
|
|
1352
|
+
argv: ReadonlyArray<string>,
|
|
1353
|
+
): Result.Result<CampaignEvaluation["rule"], string> => {
|
|
1354
|
+
const named = flagOf(argv, "--campaign");
|
|
1355
|
+
if (named !== undefined) {
|
|
1356
|
+
const found = campaignsOf(policy).campaignRules.find((rule) => rule.id === named);
|
|
1357
|
+
return found === undefined
|
|
1358
|
+
? Result.fail(`no campaign is named "${named}"`)
|
|
1359
|
+
: Result.succeed(found);
|
|
1360
|
+
}
|
|
1361
|
+
const [only] = campaignsOf(policy).campaignRules;
|
|
1362
|
+
if (campaignsOf(policy).campaignRules.length === 1 && only !== undefined) return Result.succeed(only);
|
|
1363
|
+
return Result.fail(
|
|
1364
|
+
`this policy declares ${String(campaignsOf(policy).campaignRules.length)} campaigns; say which with --campaign <id>.`,
|
|
1365
|
+
);
|
|
1366
|
+
};
|
|
1367
|
+
|
|
1368
|
+
const objectiveFor = (
|
|
1369
|
+
rule: CampaignEvaluation["rule"],
|
|
1370
|
+
objective: string | null,
|
|
1371
|
+
): Result.Result<string, string> => {
|
|
1372
|
+
if (objective !== null) {
|
|
1373
|
+
return rule.objectives.some((one) => one.id === objective)
|
|
1374
|
+
? Result.succeed(objective)
|
|
1375
|
+
: Result.fail(`no objective of ${rule.id} is named "${objective}"`);
|
|
1449
1376
|
}
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
: { subcommand: first, id: undefined, roots: second === undefined ? [] : [second, ...rest] };
|
|
1377
|
+
const [only] = rule.objectives;
|
|
1378
|
+
if (rule.objectives.length === 1 && only !== undefined) return Result.succeed(only.id);
|
|
1379
|
+
return Result.fail(
|
|
1380
|
+
`campaign ${rule.id} declares ${String(rule.objectives.length)} objectives; say which as ${rule.id}/<objective>.`,
|
|
1381
|
+
);
|
|
1456
1382
|
};
|
|
1457
1383
|
|
|
1458
|
-
export const
|
|
1384
|
+
export const objectives = (
|
|
1459
1385
|
policy: LoadedPolicy,
|
|
1460
1386
|
defaultRoots: ReadonlyArray<string>,
|
|
1461
1387
|
argv: ReadonlyArray<string>,
|
|
1462
1388
|
): Effect.Effect<void, CliFailure> =>
|
|
1463
1389
|
Effect.gen(function* () {
|
|
1464
|
-
const parsed =
|
|
1390
|
+
const parsed = objectiveArgsOf(
|
|
1465
1391
|
argv,
|
|
1466
|
-
policy.campaignRules.map((rule) => rule.id),
|
|
1392
|
+
campaignsOf(policy).campaignRules.map((rule) => rule.id),
|
|
1467
1393
|
);
|
|
1468
|
-
const { id, subcommand } = parsed;
|
|
1469
1394
|
const roots = parsed.roots.length > 0 ? parsed.roots : defaultRoots;
|
|
1470
|
-
if (policy.campaignRules.length === 0) {
|
|
1395
|
+
if (campaignsOf(policy).campaignRules.length === 0) {
|
|
1471
1396
|
return yield* report(["this policy declares no campaigns."]);
|
|
1472
1397
|
}
|
|
1473
|
-
const
|
|
1474
|
-
|
|
1475
|
-
hits.filter((hit) => hit.campaign === campaign).map((hit) => hit.violation);
|
|
1398
|
+
const evaluations = (): ReadonlyArray<CampaignEvaluation> =>
|
|
1399
|
+
collectFindings(policy, roots).campaigns;
|
|
1476
1400
|
|
|
1477
|
-
switch (subcommand) {
|
|
1478
|
-
case
|
|
1479
|
-
const snapshot = snapshotOf(policy, roots, manifestPathOf(policy.repoRoot));
|
|
1480
|
-
return yield* report([
|
|
1481
|
-
`${count(snapshot.campaigns.length, "campaign")} under ${roots.join(", ")}`,
|
|
1482
|
-
"",
|
|
1483
|
-
...renderCampaignRows(snapshot.campaigns),
|
|
1484
|
-
"",
|
|
1485
|
-
" architecture campaigns init <id> # write a ledger from what fires today",
|
|
1486
|
-
" architecture campaigns prune [<id>] # drop entries that no longer fire",
|
|
1487
|
-
' architecture campaigns allow <id> --reason "<why>" # record why the count may rise',
|
|
1488
|
-
]);
|
|
1489
|
-
}
|
|
1490
|
-
case "init": {
|
|
1491
|
-
if (id === undefined) return yield* Effect.fail(fail("campaigns init needs a campaign id"));
|
|
1492
|
-
const rule = campaignNamed(policy, id);
|
|
1493
|
-
if (rule === null) return yield* Effect.fail(fail(`no campaign is named "${id}"`));
|
|
1494
|
-
if (policy.ledgers.has(id)) {
|
|
1495
|
-
return yield* Effect.fail(
|
|
1496
|
-
fail(
|
|
1497
|
-
`${path.relative(policy.repoRoot, ledgerPathOf(policy, id))} already exists. \`init\` ` +
|
|
1498
|
-
`writes a campaign's first ledger and does not overwrite one; \`prune\` and \`allow\` ` +
|
|
1499
|
-
`are how it changes.`,
|
|
1500
|
-
),
|
|
1501
|
-
);
|
|
1502
|
-
}
|
|
1503
|
-
const ledger = ledgerOf(id, own(hitsOf(), id), policy.now);
|
|
1504
|
-
yield* Effect.sync(() => {
|
|
1505
|
-
writeLedger(policy, ledger);
|
|
1506
|
-
});
|
|
1507
|
-
return yield* report([
|
|
1508
|
-
`${count(ledger.entries.length, "hit")} recorded in ${path.relative(policy.repoRoot, ledgerPathOf(policy, id))}.`,
|
|
1509
|
-
"Each one is a place the campaign has yet to reach. Fixing one means pruning its line.",
|
|
1510
|
-
]);
|
|
1511
|
-
}
|
|
1512
|
-
case "prune": {
|
|
1401
|
+
switch (parsed.subcommand) {
|
|
1402
|
+
case "clear": {
|
|
1513
1403
|
const targets =
|
|
1514
|
-
|
|
1515
|
-
? policy.campaignRules
|
|
1516
|
-
:
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
}
|
|
1520
|
-
const hits = hitsOf();
|
|
1404
|
+
parsed.target === null
|
|
1405
|
+
? campaignsOf(policy).campaignRules
|
|
1406
|
+
: campaignsOf(policy).campaignRules.filter((rule) => rule.id === parsed.target?.campaign);
|
|
1407
|
+
const by = authorOf(flagOf(argv, "--by")) ?? "unknown";
|
|
1408
|
+
const all = evaluations();
|
|
1521
1409
|
const lines: Array<string> = [];
|
|
1522
1410
|
for (const rule of targets) {
|
|
1523
|
-
const
|
|
1524
|
-
if (
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
const next = pruned(ledger, own(hits, rule.id), rule.unit, policy.now);
|
|
1529
|
-
const removed = ledger.entries.length - next.entries.length;
|
|
1530
|
-
const rewritten = next.entries.filter((entry) => !ledger.entries.includes(entry)).length;
|
|
1531
|
-
if (removed === 0 && rewritten === 0) {
|
|
1532
|
-
lines.push(`${rule.id}: nothing to prune.`);
|
|
1533
|
-
continue;
|
|
1411
|
+
const evaluation = all.find((one) => one.rule.id === rule.id);
|
|
1412
|
+
if (evaluation === undefined) continue;
|
|
1413
|
+
const only = parsed.target?.objective ?? null;
|
|
1414
|
+
if (only !== null && !rule.objectives.some((one) => one.id === only)) {
|
|
1415
|
+
return yield* Effect.fail(fail(`no objective of ${rule.id} is named "${only}"`));
|
|
1534
1416
|
}
|
|
1535
|
-
yield* Effect.
|
|
1536
|
-
|
|
1417
|
+
const outcomes = yield* Effect.try({
|
|
1418
|
+
try: () => clear(policy, evaluation, only, by),
|
|
1419
|
+
catch: (cause) => fail(String(cause)),
|
|
1537
1420
|
});
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
(
|
|
1541
|
-
|
|
1542
|
-
|
|
1421
|
+
for (const outcome of outcomes) {
|
|
1422
|
+
const parts = [
|
|
1423
|
+
...(outcome.entered.length > 0
|
|
1424
|
+
? [
|
|
1425
|
+
`${count(outcome.entered.length, "sector")} entered (${outcome.entered.join(", ")})`,
|
|
1426
|
+
]
|
|
1427
|
+
: []),
|
|
1428
|
+
...(outcome.cleared > 0 ? [`${count(outcome.cleared, "holdout")} cleared`] : []),
|
|
1429
|
+
...(outcome.rewritten > 0
|
|
1430
|
+
? [`${count(outcome.rewritten, "holdout")} rewritten`]
|
|
1431
|
+
: []),
|
|
1432
|
+
...(outcome.closed > 0 ? [`${count(outcome.closed, "holdout")} closed`] : []),
|
|
1433
|
+
...(outcome.rebaselined.length > 0
|
|
1434
|
+
? [
|
|
1435
|
+
`${count(outcome.rebaselined.length, "sector")} re-baselined (${outcome.rebaselined.join(", ")})`,
|
|
1436
|
+
]
|
|
1437
|
+
: []),
|
|
1438
|
+
];
|
|
1439
|
+
lines.push(
|
|
1440
|
+
`${outcome.campaign}/${outcome.objective}: ${parts.length === 0 ? "nothing to clear" : parts.join(", ")}; ${count(outcome.left, "holdout")} left.`,
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1543
1443
|
}
|
|
1544
1444
|
return yield* report(lines);
|
|
1545
1445
|
}
|
|
1546
|
-
case "
|
|
1547
|
-
if (
|
|
1548
|
-
return yield* Effect.fail(fail("campaigns allow needs a campaign id"));
|
|
1549
|
-
const rule = campaignNamed(policy, id);
|
|
1550
|
-
if (rule === null) return yield* Effect.fail(fail(`no campaign is named "${id}"`));
|
|
1551
|
-
const ledger = policy.ledgers.get(id);
|
|
1552
|
-
if (ledger === undefined) {
|
|
1446
|
+
case "concede": {
|
|
1447
|
+
if (parsed.target === null) {
|
|
1553
1448
|
return yield* Effect.fail(
|
|
1554
|
-
fail(
|
|
1449
|
+
fail(
|
|
1450
|
+
"objectives concede needs a campaign: `objectives concede <campaign>[/<objective>] --reason <text>`",
|
|
1451
|
+
),
|
|
1555
1452
|
);
|
|
1556
1453
|
}
|
|
1454
|
+
const rule = campaignsOf(policy).campaignRules.find((one) => one.id === parsed.target?.campaign);
|
|
1455
|
+
if (rule === undefined)
|
|
1456
|
+
return yield* Effect.fail(fail(`no campaign is named "${parsed.target.campaign}"`));
|
|
1457
|
+
const objective = objectiveFor(rule, parsed.target.objective);
|
|
1458
|
+
if (Result.isFailure(objective)) return yield* Effect.fail(fail(objective.failure));
|
|
1557
1459
|
const reason = flagOf(argv, "--reason");
|
|
1558
1460
|
if (reason === undefined) {
|
|
1559
1461
|
return yield* Effect.fail(
|
|
1560
1462
|
fail(
|
|
1561
|
-
"
|
|
1463
|
+
"objectives concede needs --reason <text>: growth is recorded with why, or not at all.",
|
|
1562
1464
|
),
|
|
1563
1465
|
);
|
|
1564
1466
|
}
|
|
1565
1467
|
const by = authorOf(flagOf(argv, "--by"));
|
|
1566
1468
|
if (by === null) {
|
|
1567
1469
|
return yield* Effect.fail(
|
|
1568
|
-
fail("
|
|
1470
|
+
fail("objectives concede needs an author: pass --by <email>, or set git's user.email."),
|
|
1569
1471
|
);
|
|
1570
1472
|
}
|
|
1571
|
-
const
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
//
|
|
1473
|
+
const evaluation = evaluations().find((one) => one.rule.id === rule.id);
|
|
1474
|
+
if (evaluation === undefined)
|
|
1475
|
+
return yield* Effect.fail(fail(`campaign ${rule.id} was not evaluated`));
|
|
1476
|
+
// `--holdouts` concedes a subset and refuses the rest: a pull
|
|
1477
|
+
// request that legitimately adds one hit while another is an
|
|
1478
|
+
// accident.
|
|
1575
1479
|
const chosen =
|
|
1576
|
-
flagOf(argv, "--entries")
|
|
1480
|
+
(flagOf(argv, "--holdouts") ?? flagOf(argv, "--entries"))
|
|
1577
1481
|
?.split(",")
|
|
1578
|
-
.map((one) => one.trim()) ??
|
|
1579
|
-
const
|
|
1580
|
-
|
|
1482
|
+
.map((one) => one.trim()) ?? null;
|
|
1483
|
+
const outcome = concede(
|
|
1484
|
+
policy,
|
|
1485
|
+
evaluation,
|
|
1486
|
+
objective.success,
|
|
1487
|
+
chosen,
|
|
1488
|
+
flagOf(argv, "--sector") ?? null,
|
|
1489
|
+
{
|
|
1490
|
+
at: policy.now,
|
|
1491
|
+
by,
|
|
1492
|
+
reason,
|
|
1493
|
+
},
|
|
1494
|
+
);
|
|
1495
|
+
if (Result.isFailure(outcome)) return yield* Effect.fail(fail(outcome.failure));
|
|
1496
|
+
if (outcome.success.conceded.length === 0) {
|
|
1497
|
+
return yield* report([
|
|
1498
|
+
`${rule.id}/${objective.success}: nothing to concede; every hit is in the ledger.`,
|
|
1499
|
+
]);
|
|
1500
|
+
}
|
|
1501
|
+
return yield* report([
|
|
1502
|
+
`${rule.id}/${objective.success}: ${count(outcome.success.conceded.length, "holdout")} conceded, recorded by ${by}.`,
|
|
1503
|
+
...outcome.success.conceded.map((one) => ` ${one.sector} · ${one.entry}`),
|
|
1504
|
+
...(outcome.success.left.length === 0
|
|
1505
|
+
? []
|
|
1506
|
+
: [
|
|
1507
|
+
"",
|
|
1508
|
+
`${count(outcome.success.left.length, "hit")} left unrecorded; check still fails on them.`,
|
|
1509
|
+
]),
|
|
1510
|
+
]);
|
|
1511
|
+
}
|
|
1512
|
+
default:
|
|
1513
|
+
return yield* Effect.fail(
|
|
1514
|
+
fail(
|
|
1515
|
+
`unknown objectives subcommand "${parsed.subcommand ?? ""}". Try: objectives clear [<campaign>[/<objective>]] | objectives concede <campaign>[/<objective>] --reason <text> [--by <email>] [--sector <name>] [--holdouts a,b]`,
|
|
1516
|
+
),
|
|
1517
|
+
);
|
|
1518
|
+
}
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
export const campaigns = (
|
|
1522
|
+
policy: LoadedPolicy,
|
|
1523
|
+
defaultRoots: ReadonlyArray<string>,
|
|
1524
|
+
argv: ReadonlyArray<string>,
|
|
1525
|
+
configFilename?: string,
|
|
1526
|
+
): Effect.Effect<void, CliFailure> =>
|
|
1527
|
+
Effect.gen(function* () {
|
|
1528
|
+
const parsed = campaignArgsOf(
|
|
1529
|
+
argv,
|
|
1530
|
+
campaignsOf(policy).campaignRules.map((rule) => rule.id),
|
|
1531
|
+
);
|
|
1532
|
+
const roots = parsed.roots.length > 0 ? parsed.roots : defaultRoots;
|
|
1533
|
+
if (campaignsOf(policy).campaignRules.length === 0) {
|
|
1534
|
+
return yield* report(["this policy declares no campaigns."]);
|
|
1535
|
+
}
|
|
1536
|
+
const json = argv.includes("--json");
|
|
1537
|
+
const retired = parsed.subcommand === undefined ? undefined : RETIRED[parsed.subcommand];
|
|
1538
|
+
if (retired !== undefined) return yield* Effect.fail(fail(retired));
|
|
1539
|
+
|
|
1540
|
+
switch (parsed.subcommand) {
|
|
1541
|
+
case undefined: {
|
|
1542
|
+
const snapshot = snapshotOf(policy, roots, manifestPathOf(policy.repoRoot, configFilename));
|
|
1543
|
+
return yield* report([
|
|
1544
|
+
`${count(snapshot.campaigns.length, "campaign")} under ${roots.join(", ")}`,
|
|
1545
|
+
"",
|
|
1546
|
+
...renderCampaignRows(snapshot.campaigns),
|
|
1547
|
+
"",
|
|
1548
|
+
" architecture campaigns status --changed [--base <ref>] [--json] # what a diff touches, and what to do",
|
|
1549
|
+
" architecture objectives clear [<campaign>[/<objective>]] # reconcile the ledgers with the code",
|
|
1550
|
+
' architecture objectives concede <campaign>[/<objective>] --reason "<why>" # record why a count may rise',
|
|
1551
|
+
' architecture campaigns attest <sector> <phase> --reason "<why>" [--evidence <url>]',
|
|
1552
|
+
' architecture campaigns note <sector> "<text>"',
|
|
1553
|
+
" architecture campaigns history [<campaign>] [--since <ref>]",
|
|
1554
|
+
]);
|
|
1555
|
+
}
|
|
1556
|
+
case "status": {
|
|
1557
|
+
if (!argv.includes("--changed")) {
|
|
1581
1558
|
return yield* Effect.fail(
|
|
1582
|
-
fail(
|
|
1559
|
+
fail("campaigns status takes --changed: the nudge is scoped to a diff."),
|
|
1583
1560
|
);
|
|
1584
1561
|
}
|
|
1585
|
-
|
|
1586
|
-
|
|
1562
|
+
const base = flagOf(argv, "--base") ?? null;
|
|
1563
|
+
const diff = yield* Effect.try({
|
|
1564
|
+
try: () => readDiff(policy.repoRoot, base),
|
|
1565
|
+
catch: (cause) => fail(`could not read the diff: ${String(cause)}`),
|
|
1566
|
+
});
|
|
1567
|
+
const current = collectFindings(policy, roots).campaigns;
|
|
1568
|
+
const baseSide =
|
|
1569
|
+
base === null
|
|
1570
|
+
? null
|
|
1571
|
+
: yield* Effect.tryPromise({
|
|
1572
|
+
try: () =>
|
|
1573
|
+
baseSideAt(policy, base, roots, (repoRoot) =>
|
|
1574
|
+
loadPolicyFromFile(repoRoot, configFilename),
|
|
1575
|
+
),
|
|
1576
|
+
catch: (cause) =>
|
|
1577
|
+
fail(`could not evaluate the base tree at ${base}: ${String(cause)}`),
|
|
1578
|
+
});
|
|
1579
|
+
const hotfix = flagOf(argv, "--hotfix") ?? null;
|
|
1580
|
+
const nudge = nudgeOf(
|
|
1581
|
+
policy,
|
|
1582
|
+
current,
|
|
1583
|
+
diff,
|
|
1584
|
+
baseSide,
|
|
1585
|
+
hotfix,
|
|
1586
|
+
hotfix === null ? null : authorOf(flagOf(argv, "--by")),
|
|
1587
|
+
);
|
|
1588
|
+
yield* report(json ? [JSON.stringify(nudge, null, 2)] : renderNudge(nudge, policy.now));
|
|
1589
|
+
if (!nudge.ok)
|
|
1590
|
+
return yield* Effect.fail(fail("the diff sends a sector back under its onTouch"));
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
case "attest": {
|
|
1594
|
+
const [sector, phase] = parsed.args;
|
|
1595
|
+
if (sector === undefined || phase === undefined) {
|
|
1596
|
+
return yield* Effect.fail(
|
|
1597
|
+
fail(
|
|
1598
|
+
'campaigns attest needs a sector and a phase: `campaigns attest <sector> <phase> --reason "<why>"`',
|
|
1599
|
+
),
|
|
1600
|
+
);
|
|
1587
1601
|
}
|
|
1588
|
-
const
|
|
1589
|
-
|
|
1590
|
-
|
|
1602
|
+
const reason = flagOf(argv, "--reason");
|
|
1603
|
+
if (reason === undefined)
|
|
1604
|
+
return yield* Effect.fail(fail("campaigns attest needs --reason <text>."));
|
|
1605
|
+
const by = authorOf(flagOf(argv, "--by"));
|
|
1606
|
+
if (by === null)
|
|
1607
|
+
return yield* Effect.fail(
|
|
1608
|
+
fail("campaigns attest needs an author: pass --by <email>, or set git's user.email."),
|
|
1609
|
+
);
|
|
1610
|
+
const rule = campaignFor(policy, argv);
|
|
1611
|
+
if (Result.isFailure(rule)) return yield* Effect.fail(fail(rule.failure));
|
|
1612
|
+
const evaluation = collectFindings(policy, roots).campaigns.find(
|
|
1613
|
+
(one) => one.rule.id === rule.success.id,
|
|
1614
|
+
);
|
|
1615
|
+
if (evaluation === undefined)
|
|
1616
|
+
return yield* Effect.fail(fail(`campaign ${rule.success.id} was not evaluated`));
|
|
1617
|
+
const written = attest(policy, evaluation, sector, phase, {
|
|
1618
|
+
reason,
|
|
1619
|
+
evidence: flagOf(argv, "--evidence"),
|
|
1620
|
+
by,
|
|
1591
1621
|
});
|
|
1592
|
-
|
|
1622
|
+
if (Result.isFailure(written)) return yield* Effect.fail(fail(written.failure));
|
|
1593
1623
|
return yield* report([
|
|
1594
|
-
`${id}: ${
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1624
|
+
`${rule.success.id}: sector ${sector} attested at ${phase} by ${by}, in ${written.success}.`,
|
|
1625
|
+
"Run `objectives clear` to move it on.",
|
|
1626
|
+
]);
|
|
1627
|
+
}
|
|
1628
|
+
case "note": {
|
|
1629
|
+
const [sector, text] = parsed.args;
|
|
1630
|
+
if (sector === undefined || text === undefined) {
|
|
1631
|
+
return yield* Effect.fail(
|
|
1632
|
+
fail('campaigns note needs a sector and a text: `campaigns note <sector> "<text>"`'),
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
const by = authorOf(flagOf(argv, "--by"));
|
|
1636
|
+
if (by === null)
|
|
1637
|
+
return yield* Effect.fail(
|
|
1638
|
+
fail("campaigns note needs an author: pass --by <email>, or set git's user.email."),
|
|
1639
|
+
);
|
|
1640
|
+
const rule = campaignFor(policy, argv);
|
|
1641
|
+
if (Result.isFailure(rule)) return yield* Effect.fail(fail(rule.failure));
|
|
1642
|
+
const evaluation = collectFindings(policy, roots).campaigns.find(
|
|
1643
|
+
(one) => one.rule.id === rule.success.id,
|
|
1644
|
+
);
|
|
1645
|
+
if (evaluation === undefined)
|
|
1646
|
+
return yield* Effect.fail(fail(`campaign ${rule.success.id} was not evaluated`));
|
|
1647
|
+
const written = note(policy, evaluation, sector, text, by);
|
|
1648
|
+
if (Result.isFailure(written)) return yield* Effect.fail(fail(written.failure));
|
|
1649
|
+
return yield* report([
|
|
1650
|
+
`${rule.success.id}: note left on ${sector}, in ${written.success}.`,
|
|
1599
1651
|
]);
|
|
1600
1652
|
}
|
|
1653
|
+
case "history": {
|
|
1654
|
+
const [named] = parsed.args;
|
|
1655
|
+
const targets =
|
|
1656
|
+
named === undefined
|
|
1657
|
+
? campaignsOf(policy).campaignRules
|
|
1658
|
+
: campaignsOf(policy).campaignRules.filter((rule) => rule.id === named);
|
|
1659
|
+
const manifestPath = path
|
|
1660
|
+
.relative(policy.repoRoot, manifestPathOf(policy.repoRoot, configFilename))
|
|
1661
|
+
.replaceAll(path.sep, "/");
|
|
1662
|
+
const lines: Array<string> = [];
|
|
1663
|
+
for (const rule of targets) {
|
|
1664
|
+
const rows = historyOf(policy, rule, flagOf(argv, "--since") ?? null, [manifestPath]);
|
|
1665
|
+
if (json) {
|
|
1666
|
+
lines.push(JSON.stringify({ campaign: rule.id, rows }, null, 2));
|
|
1667
|
+
continue;
|
|
1668
|
+
}
|
|
1669
|
+
if (lines.length > 0) lines.push("");
|
|
1670
|
+
for (const line of renderHistory(rule, rows)) lines.push(line);
|
|
1671
|
+
}
|
|
1672
|
+
return yield* report(lines);
|
|
1673
|
+
}
|
|
1674
|
+
case "clear":
|
|
1675
|
+
case "concede":
|
|
1676
|
+
// The ledger verbs answer under `objectives`; accepted here too.
|
|
1677
|
+
return yield* objectives(policy, defaultRoots, argv);
|
|
1601
1678
|
default:
|
|
1602
1679
|
return yield* Effect.fail(
|
|
1603
1680
|
fail(
|
|
1604
|
-
`unknown campaigns subcommand "${subcommand}". Try: campaigns | campaigns
|
|
1681
|
+
`unknown campaigns subcommand "${parsed.subcommand}". Try: campaigns | campaigns status --changed | campaigns attest <sector> <phase> --reason <text> | campaigns note <sector> "<text>" | campaigns history [<campaign>]`,
|
|
1605
1682
|
),
|
|
1606
1683
|
);
|
|
1607
1684
|
}
|
|
@@ -1613,6 +1690,7 @@ const READS_REPORTS: ReadonlySet<string> = new Set([
|
|
|
1613
1690
|
"conformance",
|
|
1614
1691
|
"baseline",
|
|
1615
1692
|
"campaigns",
|
|
1693
|
+
"objectives",
|
|
1616
1694
|
"explain",
|
|
1617
1695
|
]);
|
|
1618
1696
|
|
|
@@ -1665,7 +1743,7 @@ export const run = (
|
|
|
1665
1743
|
yield* Effect.tryPromise({
|
|
1666
1744
|
try: () =>
|
|
1667
1745
|
Promise.all(
|
|
1668
|
-
reportSpecsOf(policy.campaignRules).map((spec) => policy.reports.read?.(spec)),
|
|
1746
|
+
reportSpecsOf(campaignsOf(policy).campaignRules).map((spec) => campaignsOf(policy).reports.read?.(spec)),
|
|
1669
1747
|
),
|
|
1670
1748
|
catch: (cause) => fail(String(cause)),
|
|
1671
1749
|
});
|
|
@@ -1682,7 +1760,9 @@ export const run = (
|
|
|
1682
1760
|
|
|
1683
1761
|
switch (command) {
|
|
1684
1762
|
case "campaigns":
|
|
1685
|
-
return yield* campaigns(policy, ["packages"], rest);
|
|
1763
|
+
return yield* campaigns(policy, ["packages"], rest, configFilename);
|
|
1764
|
+
case "objectives":
|
|
1765
|
+
return yield* objectives(policy, ["packages"], rest);
|
|
1686
1766
|
case "check":
|
|
1687
1767
|
return yield* check(policy, roots, {
|
|
1688
1768
|
format: json ? "json" : "text",
|
|
@@ -1696,9 +1776,9 @@ export const run = (
|
|
|
1696
1776
|
case "baseline":
|
|
1697
1777
|
return yield* writeBaseline(policy, roots);
|
|
1698
1778
|
case "explain": {
|
|
1699
|
-
const [file] =
|
|
1779
|
+
const [file, ...explainRoots] = positional;
|
|
1700
1780
|
if (file === undefined) return yield* Effect.fail(fail("explain needs a file path"));
|
|
1701
|
-
return yield* explain(policy, file);
|
|
1781
|
+
return yield* explain(policy, file, explainRoots.length > 0 ? explainRoots : ["packages"]);
|
|
1702
1782
|
}
|
|
1703
1783
|
case "coverage":
|
|
1704
1784
|
return yield* coverage(policy, roots);
|
|
@@ -1710,7 +1790,7 @@ export const run = (
|
|
|
1710
1790
|
default:
|
|
1711
1791
|
return yield* Effect.fail(
|
|
1712
1792
|
fail(
|
|
1713
|
-
`unknown command "${command}". Try: check [--json] | conformance [--json] [--against <manifest>] | baseline | campaigns [
|
|
1793
|
+
`unknown command "${command}". Try: check [--json] | conformance [--json] [--against <manifest>] | baseline | campaigns [status --changed | attest | note | history] | objectives [clear | concede] | coverage | explain <file> | facts <file> [--json] | init | infer | migrate`,
|
|
1714
1794
|
),
|
|
1715
1795
|
);
|
|
1716
1796
|
}
|