@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/build/esm/run.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
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
|
-
import {
|
|
4
|
+
import { attest, authorOf, baseSideAt, campaignFailuresOf, campaignReportsOf, campaignsOf, campaignsSelecting, clear, concede, evaluateCampaigns, explainCampaignLines, explainObjective, historyOf, hitsInWindow, ledgeredFilter, note, nudgeOf, readDiff, renderCampaignReports, renderCampaignRows, renderHistory, renderNudge, reportSpecsOf, snapshotCampaignsOf, widenedExtensions, } from "@goodbones/campaigns";
|
|
5
|
+
import { baselineOf, CONFORMANCE_MEASURES, coverageOf, cyclesIn, decodeBaseline, decodeManifest, EMPTY_BASELINE, evaluateGraph, evaluateMemberSite, evaluateResolvedEdge, evaluateSelectedBindings, evaluateStructure, evaluateSurface, exportRulesSelecting, findManifestFile, fingerprintOf, formatManifestYaml, formatMessage, fractionsOf, hasGraphRules, heightOf, listSourceFiles, makeBaselineFilter, MANIFEST_FILENAMES, MANIFEST_SCHEMA_ID, memberRulesSelecting, readManifestFile, requiredSiblingsOf, residueOf, rulesSelecting, serializeBaseline, slackOf, SNAPSHOT_VERSION, staleEntriesOf, surfaceRulesSelecting, vacancyOf, } from "@goodbones/core";
|
|
6
6
|
import * as Effect from "effect/Effect";
|
|
7
7
|
import * as Result from "effect/Result";
|
|
8
8
|
import { loadPolicyFromFile, manifestPathOf } from "./config-loader.js";
|
|
@@ -11,9 +11,12 @@ import { infer } from "./infer.js";
|
|
|
11
11
|
import { sourceFactsOf } from "./source-facts.js";
|
|
12
12
|
const fail = (message) => ({ _tag: "CliFailure", message });
|
|
13
13
|
export const collectFindings = (policy, roots, options = {}) => {
|
|
14
|
-
|
|
14
|
+
// A campaign may widen the walk past the packs' extensions; a file only a
|
|
15
|
+
// campaign asked for is seen by the campaigns and by no other family.
|
|
16
|
+
const walked = listSourceFiles(policy.repoRoot, roots, policy.languages, widenedExtensions(policy));
|
|
17
|
+
const known = new Set(policy.languages.flatMap((one) => one.extensions));
|
|
18
|
+
const files = walked.filter((file) => known.has(path.extname(file)));
|
|
15
19
|
const violations = [];
|
|
16
|
-
const campaigns = [];
|
|
17
20
|
const unresolved = [];
|
|
18
21
|
const edges = [];
|
|
19
22
|
// Each file is read and parsed at most once, whether the per-file
|
|
@@ -45,30 +48,12 @@ export const collectFindings = (policy, roots, options = {}) => {
|
|
|
45
48
|
for (const violation of evaluateGraph(policy.graph, graph))
|
|
46
49
|
violations.push(violation);
|
|
47
50
|
}
|
|
51
|
+
// The campaigns, over every walked file, through the same caches.
|
|
52
|
+
const campaigns = evaluateCampaigns(policy, roots, walked, { textOf, factsOf });
|
|
48
53
|
for (const file of files) {
|
|
49
54
|
for (const violation of evaluateStructure(policy.structure, policy.fileSystem, file)) {
|
|
50
55
|
violations.push(violation);
|
|
51
56
|
}
|
|
52
|
-
// A campaign selects by its scope. The file is parsed by the scope's
|
|
53
|
-
// matcher once, and only when a term of some selected campaign reads the
|
|
54
|
-
// syntax tree.
|
|
55
|
-
const selectedCampaigns = campaignsSelecting(policy.campaignRules, file);
|
|
56
|
-
if (selectedCampaigns.length > 0) {
|
|
57
|
-
const text = textOf(file);
|
|
58
|
-
const needsSyntax = selectedCampaigns.some((rule) => leafTermsOf(rule.detect).some((leaf) => leaf === "syntax" || leaf === "report" || leaf === "fn"));
|
|
59
|
-
for (const hit of evaluateCampaigns(selectedCampaigns, {
|
|
60
|
-
file,
|
|
61
|
-
text,
|
|
62
|
-
facts: factsOf(file),
|
|
63
|
-
resolver: policy.resolver,
|
|
64
|
-
fileSystem: policy.fileSystem,
|
|
65
|
-
syntax: needsSyntax ? policy.syntax.parse(file, text) : null,
|
|
66
|
-
functions: policy.functions,
|
|
67
|
-
reports: policy.reports,
|
|
68
|
-
})) {
|
|
69
|
-
campaigns.push(hit);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
57
|
const selectedImports = rulesSelecting(policy.importRules, file);
|
|
73
58
|
const selectedExports = exportRulesSelecting(policy.exportRules, file);
|
|
74
59
|
const selectedMembers = memberRulesSelecting(policy.memberRules, file);
|
|
@@ -139,66 +124,6 @@ const report = (lines) => Effect.sync(() => {
|
|
|
139
124
|
process.stdout.write(`${line}\n`);
|
|
140
125
|
});
|
|
141
126
|
const describe = (violation) => ` ${violation.file}\n ${formatMessage(violation)}`;
|
|
142
|
-
// Each campaign's hits against its ledger. A campaign with no ledger has
|
|
143
|
-
// every hit new; one with no hits and no ledger has nothing to say.
|
|
144
|
-
const campaignReportsOf = (policy, hits) => policy.campaignRules.map((rule) => {
|
|
145
|
-
const own = hits.filter((hit) => hit.campaign === rule.id).map((hit) => hit.violation);
|
|
146
|
-
const ledger = policy.ledgers.get(rule.id);
|
|
147
|
-
if (ledger === undefined) {
|
|
148
|
-
return {
|
|
149
|
-
id: rule.id,
|
|
150
|
-
count: own.length,
|
|
151
|
-
new: [...new Set(own.map(entryOf))].sort(),
|
|
152
|
-
stale: [],
|
|
153
|
-
drifted: 0,
|
|
154
|
-
missingLedger: true,
|
|
155
|
-
arithmetic: true,
|
|
156
|
-
complete: own.length === 0,
|
|
157
|
-
stalled: false,
|
|
158
|
-
onComplete: rule.onComplete,
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
const state = reconcile(ledger, own, rule.unit);
|
|
162
|
-
return {
|
|
163
|
-
id: rule.id,
|
|
164
|
-
count: own.length,
|
|
165
|
-
new: [...new Set(state.unrecorded.map(entryOf))].sort(),
|
|
166
|
-
stale: state.stale,
|
|
167
|
-
drifted: state.drifted.length,
|
|
168
|
-
missingLedger: false,
|
|
169
|
-
arithmetic: ledgerArithmeticHolds(ledger),
|
|
170
|
-
complete: isComplete(ledger) && own.length === 0,
|
|
171
|
-
stalled: isStalled(rule, ledger, policy.now),
|
|
172
|
-
onComplete: rule.onComplete,
|
|
173
|
-
};
|
|
174
|
-
});
|
|
175
|
-
// Whether a campaign hit is carried by its ledger — exactly, or by anchor.
|
|
176
|
-
const ledgeredFilter = (policy, hits) => {
|
|
177
|
-
const carried = new Set();
|
|
178
|
-
for (const rule of policy.campaignRules) {
|
|
179
|
-
const ledger = policy.ledgers.get(rule.id);
|
|
180
|
-
if (ledger === undefined)
|
|
181
|
-
continue;
|
|
182
|
-
const own = hits.filter((hit) => hit.campaign === rule.id).map((hit) => hit.violation);
|
|
183
|
-
for (const one of reconcile(ledger, own, rule.unit).ledgered)
|
|
184
|
-
carried.add(one);
|
|
185
|
-
}
|
|
186
|
-
return (hit) => carried.has(hit.violation);
|
|
187
|
-
};
|
|
188
|
-
// Why a campaign report is not ok, in the order `check` explains it.
|
|
189
|
-
const campaignFailuresOf = (campaigns) => [
|
|
190
|
-
...campaigns.filter((one) => one.stale.length > 0).map(() => "stale ledger entries"),
|
|
191
|
-
...campaigns.filter((one) => !one.arithmetic).map(() => "ledger arithmetic does not hold"),
|
|
192
|
-
...campaigns
|
|
193
|
-
.filter((one) => one.missingLedger && one.count > 0)
|
|
194
|
-
.map((one) => `campaign ${one.id} has no ledger`),
|
|
195
|
-
...campaigns
|
|
196
|
-
.filter((one) => !one.missingLedger && one.new.length > 0)
|
|
197
|
-
.map(() => "unrecorded campaign growth"),
|
|
198
|
-
...campaigns
|
|
199
|
-
.filter((one) => one.complete && !one.missingLedger && one.onComplete === "remove")
|
|
200
|
-
.map((one) => `campaign ${one.id} is complete and declared onComplete: remove`),
|
|
201
|
-
];
|
|
202
127
|
const COVERAGE_FAMILIES = [
|
|
203
128
|
"imports",
|
|
204
129
|
"structure",
|
|
@@ -248,12 +173,17 @@ const reportOf = (policy, roots, manifestPath, findings) => {
|
|
|
248
173
|
baselined: isBaselined(violation),
|
|
249
174
|
ledgered: false,
|
|
250
175
|
})),
|
|
251
|
-
|
|
176
|
+
// The hits that count: those whose objective is in window for the
|
|
177
|
+
// sector they fall in.
|
|
178
|
+
...findings.campaigns.flatMap((evaluation) => hitsInWindow(evaluation).map((hit) => ({
|
|
252
179
|
...hit.violation,
|
|
253
180
|
fingerprint: fingerprintOf(hit.violation),
|
|
254
181
|
baselined: false,
|
|
255
182
|
ledgered: isLedgered(hit),
|
|
256
|
-
|
|
183
|
+
objective: hit.objective,
|
|
184
|
+
sector: hit.sector,
|
|
185
|
+
entry: hit.entry,
|
|
186
|
+
}))),
|
|
257
187
|
];
|
|
258
188
|
const campaigns = campaignReportsOf(policy, findings.campaigns);
|
|
259
189
|
// The floors. A policy states how much of the tree it reaches, per
|
|
@@ -350,66 +280,20 @@ const describeExcess = (one) => {
|
|
|
350
280
|
const [singular, plural] = MEASURE_NOUNS[one.measure];
|
|
351
281
|
return ` ${one.measure}: ${count(one.count, singular, plural)}, ceiling ${String(one.ceiling)}`;
|
|
352
282
|
};
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
];
|
|
368
|
-
}
|
|
369
|
-
const complete = campaign.complete && !campaign.missingLedger;
|
|
370
|
-
return [
|
|
371
|
-
...(campaign.new.length === 0
|
|
372
|
-
? []
|
|
373
|
-
: [
|
|
374
|
-
"",
|
|
375
|
-
`campaign ${campaign.id}: ${count(campaign.new.length, "new hit")} the ledger does not carry. Fix them, or record why the count may rise:`,
|
|
376
|
-
...own.map(describeHit),
|
|
377
|
-
"",
|
|
378
|
-
` architecture campaigns allow ${campaign.id} --reason "<why>"`,
|
|
379
|
-
]),
|
|
380
|
-
...(campaign.stale.length === 0
|
|
381
|
-
? []
|
|
382
|
-
: [
|
|
383
|
-
"",
|
|
384
|
-
`campaign ${campaign.id}: ${count(campaign.stale.length, "ledger entry", "ledger entries")} no longer fire. The code was fixed; prune them:`,
|
|
385
|
-
...campaign.stale.map((entry) => ` ${entry}`),
|
|
386
|
-
"",
|
|
387
|
-
` architecture campaigns prune ${campaign.id}`,
|
|
388
|
-
]),
|
|
389
|
-
...(campaign.arithmetic
|
|
390
|
-
? []
|
|
391
|
-
: [
|
|
392
|
-
"",
|
|
393
|
-
`campaign ${campaign.id}: the ledger does not add up (entries ≠ initial + allowed − fixed). An entry was added by hand; remove it, or record it with \`campaigns allow\`.`,
|
|
394
|
-
]),
|
|
395
|
-
...(complete && campaign.onComplete === "remove"
|
|
396
|
-
? [
|
|
397
|
-
"",
|
|
398
|
-
`campaign ${campaign.id} is complete and declares onComplete: remove. Delete it from the manifest, and its ledger.`,
|
|
399
|
-
]
|
|
400
|
-
: []),
|
|
401
|
-
...(campaign.stalled
|
|
402
|
-
? [
|
|
403
|
-
"",
|
|
404
|
-
`notice: campaign ${campaign.id} has stalled — no entry has left its ledger within its staleAfter.`,
|
|
405
|
-
]
|
|
406
|
-
: []),
|
|
407
|
-
...(complete && campaign.onComplete === "keep"
|
|
408
|
-
? ["", `notice: campaign ${campaign.id} is complete, and stays as a guard.`]
|
|
409
|
-
: []),
|
|
410
|
-
];
|
|
411
|
-
});
|
|
412
|
-
};
|
|
283
|
+
const renderCampaigns = (report) => renderCampaignReports(report.campaigns, report.violations.flatMap((one) => one.kind === "campaign" &&
|
|
284
|
+
one.objective !== undefined &&
|
|
285
|
+
one.sector !== undefined &&
|
|
286
|
+
one.entry !== undefined
|
|
287
|
+
? [
|
|
288
|
+
{
|
|
289
|
+
violation: one,
|
|
290
|
+
objective: one.objective,
|
|
291
|
+
sector: one.sector,
|
|
292
|
+
entry: one.entry,
|
|
293
|
+
ledgered: one.ledgered,
|
|
294
|
+
},
|
|
295
|
+
]
|
|
296
|
+
: []));
|
|
413
297
|
const renderText = (report) => {
|
|
414
298
|
const reportable = report.violations.filter((one) => one.kind !== "campaign" && !one.baselined);
|
|
415
299
|
const carried = report.violations.filter((one) => one.kind !== "campaign").length - reportable.length;
|
|
@@ -484,41 +368,7 @@ export const snapshotOf = (policy, roots, manifestPath) => {
|
|
|
484
368
|
const byHeight = heightOfViolation(left) - heightOfViolation(right);
|
|
485
369
|
return byHeight !== 0 ? byHeight : left.fingerprint.localeCompare(right.fingerprint);
|
|
486
370
|
});
|
|
487
|
-
const campaigns = policy.
|
|
488
|
-
const ledger = policy.ledgers.get(rule.id);
|
|
489
|
-
const own = findings.campaigns.filter((hit) => hit.campaign === rule.id).length;
|
|
490
|
-
const base = {
|
|
491
|
-
id: rule.id,
|
|
492
|
-
...(rule.title === null ? {} : { title: rule.title }),
|
|
493
|
-
...(rule.owner === null ? {} : { owner: rule.owner }),
|
|
494
|
-
initial: own,
|
|
495
|
-
allowed: 0,
|
|
496
|
-
count: own,
|
|
497
|
-
fixed: 0,
|
|
498
|
-
progress: 0,
|
|
499
|
-
lastProgress: new Date(policy.now).toISOString(),
|
|
500
|
-
regressions: 0,
|
|
501
|
-
stalled: false,
|
|
502
|
-
complete: own === 0,
|
|
503
|
-
onComplete: rule.onComplete,
|
|
504
|
-
ledgered: false,
|
|
505
|
-
};
|
|
506
|
-
if (ledger === undefined)
|
|
507
|
-
return base;
|
|
508
|
-
return {
|
|
509
|
-
...base,
|
|
510
|
-
initial: ledger.initial,
|
|
511
|
-
allowed: ledger.regressions.reduce((sum, one) => sum + one.delta, 0),
|
|
512
|
-
count: ledger.entries.length,
|
|
513
|
-
fixed: ledger.fixed,
|
|
514
|
-
progress: progressOf(ledger),
|
|
515
|
-
lastProgress: ledger.lastProgress,
|
|
516
|
-
regressions: ledger.regressions.length,
|
|
517
|
-
stalled: isStalled(rule, ledger, policy.now),
|
|
518
|
-
complete: isComplete(ledger),
|
|
519
|
-
ledgered: true,
|
|
520
|
-
};
|
|
521
|
-
});
|
|
371
|
+
const campaigns = snapshotCampaignsOf(policy, findings.campaigns);
|
|
522
372
|
return {
|
|
523
373
|
version: SNAPSHOT_VERSION,
|
|
524
374
|
manifest: report_.manifest,
|
|
@@ -540,20 +390,6 @@ export const snapshotOf = (policy, roots, manifestPath) => {
|
|
|
540
390
|
campaigns,
|
|
541
391
|
};
|
|
542
392
|
};
|
|
543
|
-
// The campaigns, stalled and complete first, then by progress.
|
|
544
|
-
const renderCampaignRows = (campaigns) => {
|
|
545
|
-
const width = Math.max(0, ...campaigns.map((one) => one.id.length));
|
|
546
|
-
const state = (one) => !one.ledgered ? "no ledger" : one.complete ? "complete" : one.stalled ? "stalled" : "";
|
|
547
|
-
const ordered = [...campaigns].sort((left, right) => {
|
|
548
|
-
const rank = (one) => one.stalled ? 0 : one.complete && one.ledgered ? 1 : 2;
|
|
549
|
-
const byRank = rank(left) - rank(right);
|
|
550
|
-
return byRank !== 0 ? byRank : left.progress - right.progress;
|
|
551
|
-
});
|
|
552
|
-
return ordered.map((one) => ` ${one.id.padEnd(width)} ${percent(one.progress).padStart(4)} ${String(one.count).padStart(5)} left` +
|
|
553
|
-
` ${String(one.fixed)} fixed ${String(one.allowed)} allowed` +
|
|
554
|
-
(one.owner === undefined ? "" : ` ${one.owner}`) +
|
|
555
|
-
(state(one) === "" ? "" : ` ${state(one)}`));
|
|
556
|
-
};
|
|
557
393
|
const renderSnapshot = (snapshot) => {
|
|
558
394
|
const reportable = snapshot.violations.filter((one) => !one.baselined && !one.ledgered);
|
|
559
395
|
const carried = snapshot.violations.length - reportable.length;
|
|
@@ -687,7 +523,7 @@ export const writeBaseline = (policy, roots) => Effect.gen(function* () {
|
|
|
687
523
|
});
|
|
688
524
|
// The question a tree config makes harder to answer than a flat one: given a
|
|
689
525
|
// file, what governs it? A flat config you grep; a tree you have to walk.
|
|
690
|
-
export const explain = (policy, file) => Effect.gen(function* () {
|
|
526
|
+
export const explain = (policy, file, roots = ["packages"]) => Effect.gen(function* () {
|
|
691
527
|
const relative = path.relative(policy.repoRoot, path.resolve(policy.repoRoot, file));
|
|
692
528
|
const selected = rulesSelecting(policy.importRules, relative);
|
|
693
529
|
// An allowlist rule names no `to` — it fires when the target matches none of
|
|
@@ -719,10 +555,15 @@ export const explain = (policy, file) => Effect.gen(function* () {
|
|
|
719
555
|
.map((rule) => `${named(rule)} (reach)`),
|
|
720
556
|
];
|
|
721
557
|
const section = (title, lines) => lines.length === 0 ? [] : ["", title, ...lines];
|
|
722
|
-
// Each campaign selecting the file
|
|
723
|
-
//
|
|
724
|
-
//
|
|
725
|
-
|
|
558
|
+
// Each campaign selecting the file: the sector the file is in, its
|
|
559
|
+
// phase and definedness, the objectives in window that fire on it and
|
|
560
|
+
// the nearest remaining holdouts — which needs the campaign evaluated
|
|
561
|
+
// over its files, since a sector's phase is derived from all of them —
|
|
562
|
+
// then each objective's truth table: one line per leaf term and what
|
|
563
|
+
// it answered here, so a detector that "should fire" and does not shows
|
|
564
|
+
// which term is not saying what its author thinks.
|
|
565
|
+
const selectedCampaigns = campaignsSelecting(campaignsOf(policy).campaignRules, relative);
|
|
566
|
+
const evaluations = selectedCampaigns.length === 0 ? [] : collectFindings(policy, roots).campaigns;
|
|
726
567
|
const campaignLines = selectedCampaigns.flatMap((rule) => {
|
|
727
568
|
const at = path.join(policy.repoRoot, relative);
|
|
728
569
|
const text = existsSync(at) ? readFileSync(at, "utf8") : "";
|
|
@@ -733,13 +574,22 @@ export const explain = (policy, file) => Effect.gen(function* () {
|
|
|
733
574
|
resolver: policy.resolver,
|
|
734
575
|
fileSystem: policy.fileSystem,
|
|
735
576
|
syntax: policy.syntax.parse(relative, text),
|
|
736
|
-
functions: policy.functions,
|
|
737
|
-
reports: policy.reports,
|
|
577
|
+
functions: campaignsOf(policy).functions,
|
|
578
|
+
reports: campaignsOf(policy).reports,
|
|
738
579
|
};
|
|
739
|
-
const
|
|
580
|
+
const evaluation = evaluations.find((one) => one.rule.id === rule.id);
|
|
740
581
|
return [
|
|
741
|
-
|
|
742
|
-
...
|
|
582
|
+
...(evaluation === undefined ? [] : explainCampaignLines(policy, evaluation, relative)),
|
|
583
|
+
...rule.objectives.flatMap((objective) => {
|
|
584
|
+
const table = explainObjective(objective, input);
|
|
585
|
+
if (table.length === 0)
|
|
586
|
+
return [];
|
|
587
|
+
const fired = table.length > 0 && table.every((line) => line.answer);
|
|
588
|
+
return [
|
|
589
|
+
` ${objective.name} — ${firstSentence(objective.why ?? objective.message)} (${objective.holdout}; ${fired ? "fires here" : "no hit"})`,
|
|
590
|
+
...table.map((line) => ` ${line.answer ? "✓" : "✗"} ${line.term}${line.count === undefined ? "" : ` (${String(line.count)})`}`),
|
|
591
|
+
];
|
|
592
|
+
}),
|
|
743
593
|
];
|
|
744
594
|
});
|
|
745
595
|
yield* report([
|
|
@@ -858,18 +708,23 @@ limits:
|
|
|
858
708
|
unrestricted: 0
|
|
859
709
|
partial: 0
|
|
860
710
|
|
|
861
|
-
#
|
|
862
|
-
#
|
|
863
|
-
#
|
|
711
|
+
# Refactors the repository is running, each an object: objectives (a
|
|
712
|
+
# detector with a ledger under \`.architecture-campaigns/\` of every place the
|
|
713
|
+
# pattern still occurs), over sectors the code births through a perimeter,
|
|
714
|
+
# through phases toward an end. Fill one in, then
|
|
715
|
+
# \`architecture objectives clear <id>\` to write its ledgers.
|
|
864
716
|
# https://dataquail.github.io/goodbones/architecture-rules/manifest/campaigns/
|
|
865
717
|
# campaigns:
|
|
866
|
-
#
|
|
718
|
+
# js-to-ts:
|
|
867
719
|
# why: The strict tsconfig cannot land while any src file is JavaScript.
|
|
868
720
|
# how: Rename to .ts, add types at the module boundary, leave the body alone.
|
|
869
|
-
# scope:
|
|
870
|
-
#
|
|
871
|
-
#
|
|
872
|
-
#
|
|
721
|
+
# scope: { path: "src/**", extensions: [.js, .jsx] }
|
|
722
|
+
# perimeter: file
|
|
723
|
+
# objectives:
|
|
724
|
+
# is-ts:
|
|
725
|
+
# holdout: file
|
|
726
|
+
# match: { path: { file: "\\\\.(js|jsx)$" } }
|
|
727
|
+
# probes: { fires: [{ path: src/legacy/util.js }], ignores: [{ path: src/util.ts }] }
|
|
873
728
|
# staleAfter: 14d
|
|
874
729
|
# onComplete: remove
|
|
875
730
|
|
|
@@ -941,188 +796,361 @@ export const migrate = (repoRoot, configFilename) => Effect.gen(function* () {
|
|
|
941
796
|
`Then delete ${path.basename(from)}: a repository with two manifests is refused.`,
|
|
942
797
|
]);
|
|
943
798
|
});
|
|
944
|
-
// The
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
mkdirSync(path.dirname(at), { recursive: true });
|
|
951
|
-
writeFileSync(at, serializeLedger(ledger));
|
|
952
|
-
};
|
|
953
|
-
const campaignNamed = (policy, id) => policy.campaignRules.find((rule) => rule.id === id) ?? null;
|
|
954
|
-
// The author of a regression: `--by`, else git's user.email, else the
|
|
955
|
-
// GIT_AUTHOR_EMAIL the environment carries. Without one the record is refused
|
|
956
|
-
// rather than written blank, since the record is the point.
|
|
957
|
-
const authorOf = (given) => {
|
|
958
|
-
if (given !== undefined && given !== "")
|
|
959
|
-
return given;
|
|
960
|
-
try {
|
|
961
|
-
const email = execFileSync("git", ["config", "user.email"], {
|
|
962
|
-
encoding: "utf8",
|
|
963
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
964
|
-
}).trim();
|
|
965
|
-
if (email !== "")
|
|
966
|
-
return email;
|
|
967
|
-
}
|
|
968
|
-
catch {
|
|
969
|
-
// git absent, or no email configured
|
|
970
|
-
}
|
|
971
|
-
const fromEnvironment = process.env.GIT_AUTHOR_EMAIL;
|
|
972
|
-
return fromEnvironment === undefined || fromEnvironment === "" ? null : fromEnvironment;
|
|
973
|
-
};
|
|
799
|
+
// The campaign commands. `campaigns` alone is the status table; `status
|
|
800
|
+
// --changed` is the nudge; `attest` and `note` write a sector's record;
|
|
801
|
+
// `history` replays the ledgers' git history. The ledgers themselves are
|
|
802
|
+
// written by `objectives clear` (the ledger reconciled with the code
|
|
803
|
+
// wherever that is not a regression) and `objectives concede` (the one way
|
|
804
|
+
// a holdout is added by hand, with a reason).
|
|
974
805
|
const flagOf = (argv, flag) => {
|
|
975
806
|
const at = argv.indexOf(flag);
|
|
976
807
|
const value = at === -1 ? undefined : argv[at + 1];
|
|
977
808
|
return value === undefined || value.startsWith("--") ? undefined : value;
|
|
978
809
|
};
|
|
979
|
-
const CAMPAIGN_SUBCOMMANDS = ["
|
|
980
|
-
const
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
810
|
+
const CAMPAIGN_SUBCOMMANDS = ["status", "attest", "note", "history", "clear", "concede"];
|
|
811
|
+
const OBJECTIVE_SUBCOMMANDS = ["clear", "concede"];
|
|
812
|
+
const VALUE_FLAGS = [
|
|
813
|
+
"--reason",
|
|
814
|
+
"--by",
|
|
815
|
+
"--holdouts",
|
|
816
|
+
"--entries",
|
|
817
|
+
"--sector",
|
|
818
|
+
"--campaign",
|
|
819
|
+
"--evidence",
|
|
820
|
+
"--base",
|
|
821
|
+
"--since",
|
|
822
|
+
"--hotfix",
|
|
823
|
+
];
|
|
824
|
+
// The verbs the family shipped with, refused by name: each has a new name
|
|
825
|
+
// or no place.
|
|
826
|
+
const RETIRED = {
|
|
827
|
+
init: "`campaigns init` is gone: `objectives clear <campaign>` writes a first ledger, recording each sector's initial.",
|
|
828
|
+
prune: "`campaigns prune` is now `objectives clear`.",
|
|
829
|
+
allow: "`campaigns allow` is now `objectives concede`.",
|
|
830
|
+
};
|
|
831
|
+
// The positionals after the subcommand, with the value flags and their
|
|
832
|
+
// values stepped over: whatever is left names the roots to walk, as it
|
|
833
|
+
// does for every other command.
|
|
834
|
+
const positionalsOf = (argv) => {
|
|
985
835
|
const positional = [];
|
|
986
836
|
for (let at = 0; at < argv.length; at += 1) {
|
|
987
837
|
const one = argv[at] ?? "";
|
|
988
|
-
if (
|
|
838
|
+
if (VALUE_FLAGS.includes(one)) {
|
|
989
839
|
at += 1;
|
|
990
840
|
continue;
|
|
991
841
|
}
|
|
992
842
|
if (!one.startsWith("--"))
|
|
993
843
|
positional.push(one);
|
|
994
844
|
}
|
|
845
|
+
return positional;
|
|
846
|
+
};
|
|
847
|
+
// `campaigns [status | attest <sector> <phase> | note <sector> "<text>" |
|
|
848
|
+
// history [<campaign>]] [roots…]`: the subcommand and its arguments come
|
|
849
|
+
// first.
|
|
850
|
+
export const campaignArgsOf = (argv, ids) => {
|
|
851
|
+
const positional = positionalsOf(argv);
|
|
852
|
+
const [first, ...rest] = positional;
|
|
853
|
+
if (first === undefined)
|
|
854
|
+
return { subcommand: undefined, args: [], roots: [] };
|
|
855
|
+
if (first in RETIRED)
|
|
856
|
+
return { subcommand: first, args: [], roots: rest };
|
|
857
|
+
if (!CAMPAIGN_SUBCOMMANDS.includes(first)) {
|
|
858
|
+
return { subcommand: undefined, args: [], roots: positional };
|
|
859
|
+
}
|
|
860
|
+
switch (first) {
|
|
861
|
+
case "attest":
|
|
862
|
+
return { subcommand: first, args: rest.slice(0, 2), roots: rest.slice(2) };
|
|
863
|
+
case "note":
|
|
864
|
+
return { subcommand: first, args: rest.slice(0, 2), roots: rest.slice(2) };
|
|
865
|
+
case "history": {
|
|
866
|
+
const [second] = rest;
|
|
867
|
+
return second !== undefined && ids.includes(second)
|
|
868
|
+
? { subcommand: first, args: [second], roots: rest.slice(1) }
|
|
869
|
+
: { subcommand: first, args: [], roots: rest };
|
|
870
|
+
}
|
|
871
|
+
case "clear":
|
|
872
|
+
case "concede": {
|
|
873
|
+
const [second] = rest;
|
|
874
|
+
return second !== undefined && ids.includes(second.split("/")[0] ?? "")
|
|
875
|
+
? { subcommand: first, args: [second], roots: rest.slice(1) }
|
|
876
|
+
: { subcommand: first, args: [], roots: rest };
|
|
877
|
+
}
|
|
878
|
+
default:
|
|
879
|
+
return { subcommand: first, args: [], roots: rest };
|
|
880
|
+
}
|
|
881
|
+
};
|
|
882
|
+
// `objectives [clear [<campaign>[/<objective>]] | concede <campaign>[/<objective>]
|
|
883
|
+
// --reason <text>] [roots…]`.
|
|
884
|
+
export const objectiveArgsOf = (argv, ids) => {
|
|
885
|
+
const positional = positionalsOf(argv);
|
|
995
886
|
const [first, second, ...rest] = positional;
|
|
996
|
-
if (first === undefined || !
|
|
997
|
-
return { subcommand:
|
|
887
|
+
if (first === undefined || !OBJECTIVE_SUBCOMMANDS.includes(first)) {
|
|
888
|
+
return { subcommand: first, target: null, roots: positional.slice(1) };
|
|
998
889
|
}
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
? {
|
|
1004
|
-
:
|
|
890
|
+
const [campaign = "", objective] = (second ?? "").split("/");
|
|
891
|
+
const named = second !== undefined && ids.includes(campaign);
|
|
892
|
+
return {
|
|
893
|
+
subcommand: first,
|
|
894
|
+
target: named ? { campaign, objective: objective ?? null } : null,
|
|
895
|
+
roots: named ? rest : second === undefined ? [] : [second, ...rest],
|
|
896
|
+
};
|
|
1005
897
|
};
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
const
|
|
898
|
+
// The one campaign, when there is one, else the one `--campaign` names.
|
|
899
|
+
const campaignFor = (policy, argv) => {
|
|
900
|
+
const named = flagOf(argv, "--campaign");
|
|
901
|
+
if (named !== undefined) {
|
|
902
|
+
const found = campaignsOf(policy).campaignRules.find((rule) => rule.id === named);
|
|
903
|
+
return found === undefined
|
|
904
|
+
? Result.fail(`no campaign is named "${named}"`)
|
|
905
|
+
: Result.succeed(found);
|
|
906
|
+
}
|
|
907
|
+
const [only] = campaignsOf(policy).campaignRules;
|
|
908
|
+
if (campaignsOf(policy).campaignRules.length === 1 && only !== undefined)
|
|
909
|
+
return Result.succeed(only);
|
|
910
|
+
return Result.fail(`this policy declares ${String(campaignsOf(policy).campaignRules.length)} campaigns; say which with --campaign <id>.`);
|
|
911
|
+
};
|
|
912
|
+
const objectiveFor = (rule, objective) => {
|
|
913
|
+
if (objective !== null) {
|
|
914
|
+
return rule.objectives.some((one) => one.id === objective)
|
|
915
|
+
? Result.succeed(objective)
|
|
916
|
+
: Result.fail(`no objective of ${rule.id} is named "${objective}"`);
|
|
917
|
+
}
|
|
918
|
+
const [only] = rule.objectives;
|
|
919
|
+
if (rule.objectives.length === 1 && only !== undefined)
|
|
920
|
+
return Result.succeed(only.id);
|
|
921
|
+
return Result.fail(`campaign ${rule.id} declares ${String(rule.objectives.length)} objectives; say which as ${rule.id}/<objective>.`);
|
|
922
|
+
};
|
|
923
|
+
export const objectives = (policy, defaultRoots, argv) => Effect.gen(function* () {
|
|
924
|
+
const parsed = objectiveArgsOf(argv, campaignsOf(policy).campaignRules.map((rule) => rule.id));
|
|
1009
925
|
const roots = parsed.roots.length > 0 ? parsed.roots : defaultRoots;
|
|
1010
|
-
if (policy.campaignRules.length === 0) {
|
|
926
|
+
if (campaignsOf(policy).campaignRules.length === 0) {
|
|
1011
927
|
return yield* report(["this policy declares no campaigns."]);
|
|
1012
928
|
}
|
|
1013
|
-
const
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
...renderCampaignRows(snapshot.campaigns),
|
|
1022
|
-
"",
|
|
1023
|
-
" architecture campaigns init <id> # write a ledger from what fires today",
|
|
1024
|
-
" architecture campaigns prune [<id>] # drop entries that no longer fire",
|
|
1025
|
-
' architecture campaigns allow <id> --reason "<why>" # record why the count may rise',
|
|
1026
|
-
]);
|
|
1027
|
-
}
|
|
1028
|
-
case "init": {
|
|
1029
|
-
if (id === undefined)
|
|
1030
|
-
return yield* Effect.fail(fail("campaigns init needs a campaign id"));
|
|
1031
|
-
const rule = campaignNamed(policy, id);
|
|
1032
|
-
if (rule === null)
|
|
1033
|
-
return yield* Effect.fail(fail(`no campaign is named "${id}"`));
|
|
1034
|
-
if (policy.ledgers.has(id)) {
|
|
1035
|
-
return yield* Effect.fail(fail(`${path.relative(policy.repoRoot, ledgerPathOf(policy, id))} already exists. \`init\` ` +
|
|
1036
|
-
`writes a campaign's first ledger and does not overwrite one; \`prune\` and \`allow\` ` +
|
|
1037
|
-
`are how it changes.`));
|
|
1038
|
-
}
|
|
1039
|
-
const ledger = ledgerOf(id, own(hitsOf(), id), policy.now);
|
|
1040
|
-
yield* Effect.sync(() => {
|
|
1041
|
-
writeLedger(policy, ledger);
|
|
1042
|
-
});
|
|
1043
|
-
return yield* report([
|
|
1044
|
-
`${count(ledger.entries.length, "hit")} recorded in ${path.relative(policy.repoRoot, ledgerPathOf(policy, id))}.`,
|
|
1045
|
-
"Each one is a place the campaign has yet to reach. Fixing one means pruning its line.",
|
|
1046
|
-
]);
|
|
1047
|
-
}
|
|
1048
|
-
case "prune": {
|
|
1049
|
-
const targets = id === undefined
|
|
1050
|
-
? policy.campaignRules
|
|
1051
|
-
: [campaignNamed(policy, id)].filter((one) => one !== null);
|
|
1052
|
-
if (id !== undefined && targets.length === 0) {
|
|
1053
|
-
return yield* Effect.fail(fail(`no campaign is named "${id}"`));
|
|
1054
|
-
}
|
|
1055
|
-
const hits = hitsOf();
|
|
929
|
+
const evaluations = () => collectFindings(policy, roots).campaigns;
|
|
930
|
+
switch (parsed.subcommand) {
|
|
931
|
+
case "clear": {
|
|
932
|
+
const targets = parsed.target === null
|
|
933
|
+
? campaignsOf(policy).campaignRules
|
|
934
|
+
: campaignsOf(policy).campaignRules.filter((rule) => rule.id === parsed.target?.campaign);
|
|
935
|
+
const by = authorOf(flagOf(argv, "--by")) ?? "unknown";
|
|
936
|
+
const all = evaluations();
|
|
1056
937
|
const lines = [];
|
|
1057
938
|
for (const rule of targets) {
|
|
1058
|
-
const
|
|
1059
|
-
if (
|
|
1060
|
-
lines.push(`${rule.id}: no ledger to prune (run \`campaigns init ${rule.id}\`).`);
|
|
1061
|
-
continue;
|
|
1062
|
-
}
|
|
1063
|
-
const next = pruned(ledger, own(hits, rule.id), rule.unit, policy.now);
|
|
1064
|
-
const removed = ledger.entries.length - next.entries.length;
|
|
1065
|
-
const rewritten = next.entries.filter((entry) => !ledger.entries.includes(entry)).length;
|
|
1066
|
-
if (removed === 0 && rewritten === 0) {
|
|
1067
|
-
lines.push(`${rule.id}: nothing to prune.`);
|
|
939
|
+
const evaluation = all.find((one) => one.rule.id === rule.id);
|
|
940
|
+
if (evaluation === undefined)
|
|
1068
941
|
continue;
|
|
942
|
+
const only = parsed.target?.objective ?? null;
|
|
943
|
+
if (only !== null && !rule.objectives.some((one) => one.id === only)) {
|
|
944
|
+
return yield* Effect.fail(fail(`no objective of ${rule.id} is named "${only}"`));
|
|
1069
945
|
}
|
|
1070
|
-
yield* Effect.
|
|
1071
|
-
|
|
946
|
+
const outcomes = yield* Effect.try({
|
|
947
|
+
try: () => clear(policy, evaluation, only, by),
|
|
948
|
+
catch: (cause) => fail(String(cause)),
|
|
1072
949
|
});
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
950
|
+
for (const outcome of outcomes) {
|
|
951
|
+
const parts = [
|
|
952
|
+
...(outcome.entered.length > 0
|
|
953
|
+
? [
|
|
954
|
+
`${count(outcome.entered.length, "sector")} entered (${outcome.entered.join(", ")})`,
|
|
955
|
+
]
|
|
956
|
+
: []),
|
|
957
|
+
...(outcome.cleared > 0 ? [`${count(outcome.cleared, "holdout")} cleared`] : []),
|
|
958
|
+
...(outcome.rewritten > 0
|
|
959
|
+
? [`${count(outcome.rewritten, "holdout")} rewritten`]
|
|
960
|
+
: []),
|
|
961
|
+
...(outcome.closed > 0 ? [`${count(outcome.closed, "holdout")} closed`] : []),
|
|
962
|
+
...(outcome.rebaselined.length > 0
|
|
963
|
+
? [
|
|
964
|
+
`${count(outcome.rebaselined.length, "sector")} re-baselined (${outcome.rebaselined.join(", ")})`,
|
|
965
|
+
]
|
|
966
|
+
: []),
|
|
967
|
+
];
|
|
968
|
+
lines.push(`${outcome.campaign}/${outcome.objective}: ${parts.length === 0 ? "nothing to clear" : parts.join(", ")}; ${count(outcome.left, "holdout")} left.`);
|
|
969
|
+
}
|
|
1076
970
|
}
|
|
1077
971
|
return yield* report(lines);
|
|
1078
972
|
}
|
|
1079
|
-
case "
|
|
1080
|
-
if (
|
|
1081
|
-
return yield* Effect.fail(fail("
|
|
1082
|
-
const rule = campaignNamed(policy, id);
|
|
1083
|
-
if (rule === null)
|
|
1084
|
-
return yield* Effect.fail(fail(`no campaign is named "${id}"`));
|
|
1085
|
-
const ledger = policy.ledgers.get(id);
|
|
1086
|
-
if (ledger === undefined) {
|
|
1087
|
-
return yield* Effect.fail(fail(`campaign ${id} has no ledger yet; run \`campaigns init ${id}\` first.`));
|
|
973
|
+
case "concede": {
|
|
974
|
+
if (parsed.target === null) {
|
|
975
|
+
return yield* Effect.fail(fail("objectives concede needs a campaign: `objectives concede <campaign>[/<objective>] --reason <text>`"));
|
|
1088
976
|
}
|
|
977
|
+
const rule = campaignsOf(policy).campaignRules.find((one) => one.id === parsed.target?.campaign);
|
|
978
|
+
if (rule === undefined)
|
|
979
|
+
return yield* Effect.fail(fail(`no campaign is named "${parsed.target.campaign}"`));
|
|
980
|
+
const objective = objectiveFor(rule, parsed.target.objective);
|
|
981
|
+
if (Result.isFailure(objective))
|
|
982
|
+
return yield* Effect.fail(fail(objective.failure));
|
|
1089
983
|
const reason = flagOf(argv, "--reason");
|
|
1090
984
|
if (reason === undefined) {
|
|
1091
|
-
return yield* Effect.fail(fail("
|
|
985
|
+
return yield* Effect.fail(fail("objectives concede needs --reason <text>: growth is recorded with why, or not at all."));
|
|
1092
986
|
}
|
|
1093
987
|
const by = authorOf(flagOf(argv, "--by"));
|
|
1094
988
|
if (by === null) {
|
|
1095
|
-
return yield* Effect.fail(fail("
|
|
989
|
+
return yield* Effect.fail(fail("objectives concede needs an author: pass --by <email>, or set git's user.email."));
|
|
1096
990
|
}
|
|
1097
|
-
const
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
//
|
|
1101
|
-
|
|
991
|
+
const evaluation = evaluations().find((one) => one.rule.id === rule.id);
|
|
992
|
+
if (evaluation === undefined)
|
|
993
|
+
return yield* Effect.fail(fail(`campaign ${rule.id} was not evaluated`));
|
|
994
|
+
// `--holdouts` concedes a subset and refuses the rest: a pull
|
|
995
|
+
// request that legitimately adds one hit while another is an
|
|
996
|
+
// accident.
|
|
997
|
+
const chosen = (flagOf(argv, "--holdouts") ?? flagOf(argv, "--entries"))
|
|
1102
998
|
?.split(",")
|
|
1103
|
-
.map((one) => one.trim()) ??
|
|
1104
|
-
const
|
|
1105
|
-
|
|
1106
|
-
|
|
999
|
+
.map((one) => one.trim()) ?? null;
|
|
1000
|
+
const outcome = concede(policy, evaluation, objective.success, chosen, flagOf(argv, "--sector") ?? null, {
|
|
1001
|
+
at: policy.now,
|
|
1002
|
+
by,
|
|
1003
|
+
reason,
|
|
1004
|
+
});
|
|
1005
|
+
if (Result.isFailure(outcome))
|
|
1006
|
+
return yield* Effect.fail(fail(outcome.failure));
|
|
1007
|
+
if (outcome.success.conceded.length === 0) {
|
|
1008
|
+
return yield* report([
|
|
1009
|
+
`${rule.id}/${objective.success}: nothing to concede; every hit is in the ledger.`,
|
|
1010
|
+
]);
|
|
1011
|
+
}
|
|
1012
|
+
return yield* report([
|
|
1013
|
+
`${rule.id}/${objective.success}: ${count(outcome.success.conceded.length, "holdout")} conceded, recorded by ${by}.`,
|
|
1014
|
+
...outcome.success.conceded.map((one) => ` ${one.sector} · ${one.entry}`),
|
|
1015
|
+
...(outcome.success.left.length === 0
|
|
1016
|
+
? []
|
|
1017
|
+
: [
|
|
1018
|
+
"",
|
|
1019
|
+
`${count(outcome.success.left.length, "hit")} left unrecorded; check still fails on them.`,
|
|
1020
|
+
]),
|
|
1021
|
+
]);
|
|
1022
|
+
}
|
|
1023
|
+
default:
|
|
1024
|
+
return yield* Effect.fail(fail(`unknown objectives subcommand "${parsed.subcommand ?? ""}". Try: objectives clear [<campaign>[/<objective>]] | objectives concede <campaign>[/<objective>] --reason <text> [--by <email>] [--sector <name>] [--holdouts a,b]`));
|
|
1025
|
+
}
|
|
1026
|
+
});
|
|
1027
|
+
export const campaigns = (policy, defaultRoots, argv, configFilename) => Effect.gen(function* () {
|
|
1028
|
+
const parsed = campaignArgsOf(argv, campaignsOf(policy).campaignRules.map((rule) => rule.id));
|
|
1029
|
+
const roots = parsed.roots.length > 0 ? parsed.roots : defaultRoots;
|
|
1030
|
+
if (campaignsOf(policy).campaignRules.length === 0) {
|
|
1031
|
+
return yield* report(["this policy declares no campaigns."]);
|
|
1032
|
+
}
|
|
1033
|
+
const json = argv.includes("--json");
|
|
1034
|
+
const retired = parsed.subcommand === undefined ? undefined : RETIRED[parsed.subcommand];
|
|
1035
|
+
if (retired !== undefined)
|
|
1036
|
+
return yield* Effect.fail(fail(retired));
|
|
1037
|
+
switch (parsed.subcommand) {
|
|
1038
|
+
case undefined: {
|
|
1039
|
+
const snapshot = snapshotOf(policy, roots, manifestPathOf(policy.repoRoot, configFilename));
|
|
1040
|
+
return yield* report([
|
|
1041
|
+
`${count(snapshot.campaigns.length, "campaign")} under ${roots.join(", ")}`,
|
|
1042
|
+
"",
|
|
1043
|
+
...renderCampaignRows(snapshot.campaigns),
|
|
1044
|
+
"",
|
|
1045
|
+
" architecture campaigns status --changed [--base <ref>] [--json] # what a diff touches, and what to do",
|
|
1046
|
+
" architecture objectives clear [<campaign>[/<objective>]] # reconcile the ledgers with the code",
|
|
1047
|
+
' architecture objectives concede <campaign>[/<objective>] --reason "<why>" # record why a count may rise',
|
|
1048
|
+
' architecture campaigns attest <sector> <phase> --reason "<why>" [--evidence <url>]',
|
|
1049
|
+
' architecture campaigns note <sector> "<text>"',
|
|
1050
|
+
" architecture campaigns history [<campaign>] [--since <ref>]",
|
|
1051
|
+
]);
|
|
1052
|
+
}
|
|
1053
|
+
case "status": {
|
|
1054
|
+
if (!argv.includes("--changed")) {
|
|
1055
|
+
return yield* Effect.fail(fail("campaigns status takes --changed: the nudge is scoped to a diff."));
|
|
1107
1056
|
}
|
|
1108
|
-
|
|
1109
|
-
|
|
1057
|
+
const base = flagOf(argv, "--base") ?? null;
|
|
1058
|
+
const diff = yield* Effect.try({
|
|
1059
|
+
try: () => readDiff(policy.repoRoot, base),
|
|
1060
|
+
catch: (cause) => fail(`could not read the diff: ${String(cause)}`),
|
|
1061
|
+
});
|
|
1062
|
+
const current = collectFindings(policy, roots).campaigns;
|
|
1063
|
+
const baseSide = base === null
|
|
1064
|
+
? null
|
|
1065
|
+
: yield* Effect.tryPromise({
|
|
1066
|
+
try: () => baseSideAt(policy, base, roots, (repoRoot) => loadPolicyFromFile(repoRoot, configFilename)),
|
|
1067
|
+
catch: (cause) => fail(`could not evaluate the base tree at ${base}: ${String(cause)}`),
|
|
1068
|
+
});
|
|
1069
|
+
const hotfix = flagOf(argv, "--hotfix") ?? null;
|
|
1070
|
+
const nudge = nudgeOf(policy, current, diff, baseSide, hotfix, hotfix === null ? null : authorOf(flagOf(argv, "--by")));
|
|
1071
|
+
yield* report(json ? [JSON.stringify(nudge, null, 2)] : renderNudge(nudge, policy.now));
|
|
1072
|
+
if (!nudge.ok)
|
|
1073
|
+
return yield* Effect.fail(fail("the diff sends a sector back under its onTouch"));
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
case "attest": {
|
|
1077
|
+
const [sector, phase] = parsed.args;
|
|
1078
|
+
if (sector === undefined || phase === undefined) {
|
|
1079
|
+
return yield* Effect.fail(fail('campaigns attest needs a sector and a phase: `campaigns attest <sector> <phase> --reason "<why>"`'));
|
|
1110
1080
|
}
|
|
1111
|
-
const
|
|
1112
|
-
|
|
1113
|
-
|
|
1081
|
+
const reason = flagOf(argv, "--reason");
|
|
1082
|
+
if (reason === undefined)
|
|
1083
|
+
return yield* Effect.fail(fail("campaigns attest needs --reason <text>."));
|
|
1084
|
+
const by = authorOf(flagOf(argv, "--by"));
|
|
1085
|
+
if (by === null)
|
|
1086
|
+
return yield* Effect.fail(fail("campaigns attest needs an author: pass --by <email>, or set git's user.email."));
|
|
1087
|
+
const rule = campaignFor(policy, argv);
|
|
1088
|
+
if (Result.isFailure(rule))
|
|
1089
|
+
return yield* Effect.fail(fail(rule.failure));
|
|
1090
|
+
const evaluation = collectFindings(policy, roots).campaigns.find((one) => one.rule.id === rule.success.id);
|
|
1091
|
+
if (evaluation === undefined)
|
|
1092
|
+
return yield* Effect.fail(fail(`campaign ${rule.success.id} was not evaluated`));
|
|
1093
|
+
const written = attest(policy, evaluation, sector, phase, {
|
|
1094
|
+
reason,
|
|
1095
|
+
evidence: flagOf(argv, "--evidence"),
|
|
1096
|
+
by,
|
|
1114
1097
|
});
|
|
1115
|
-
|
|
1098
|
+
if (Result.isFailure(written))
|
|
1099
|
+
return yield* Effect.fail(fail(written.failure));
|
|
1116
1100
|
return yield* report([
|
|
1117
|
-
`${id}: ${
|
|
1118
|
-
|
|
1119
|
-
...(left.length === 0
|
|
1120
|
-
? []
|
|
1121
|
-
: ["", `${count(left.length, "hit")} left unrecorded; check still fails on them.`]),
|
|
1101
|
+
`${rule.success.id}: sector ${sector} attested at ${phase} by ${by}, in ${written.success}.`,
|
|
1102
|
+
"Run `objectives clear` to move it on.",
|
|
1122
1103
|
]);
|
|
1123
1104
|
}
|
|
1105
|
+
case "note": {
|
|
1106
|
+
const [sector, text] = parsed.args;
|
|
1107
|
+
if (sector === undefined || text === undefined) {
|
|
1108
|
+
return yield* Effect.fail(fail('campaigns note needs a sector and a text: `campaigns note <sector> "<text>"`'));
|
|
1109
|
+
}
|
|
1110
|
+
const by = authorOf(flagOf(argv, "--by"));
|
|
1111
|
+
if (by === null)
|
|
1112
|
+
return yield* Effect.fail(fail("campaigns note needs an author: pass --by <email>, or set git's user.email."));
|
|
1113
|
+
const rule = campaignFor(policy, argv);
|
|
1114
|
+
if (Result.isFailure(rule))
|
|
1115
|
+
return yield* Effect.fail(fail(rule.failure));
|
|
1116
|
+
const evaluation = collectFindings(policy, roots).campaigns.find((one) => one.rule.id === rule.success.id);
|
|
1117
|
+
if (evaluation === undefined)
|
|
1118
|
+
return yield* Effect.fail(fail(`campaign ${rule.success.id} was not evaluated`));
|
|
1119
|
+
const written = note(policy, evaluation, sector, text, by);
|
|
1120
|
+
if (Result.isFailure(written))
|
|
1121
|
+
return yield* Effect.fail(fail(written.failure));
|
|
1122
|
+
return yield* report([
|
|
1123
|
+
`${rule.success.id}: note left on ${sector}, in ${written.success}.`,
|
|
1124
|
+
]);
|
|
1125
|
+
}
|
|
1126
|
+
case "history": {
|
|
1127
|
+
const [named] = parsed.args;
|
|
1128
|
+
const targets = named === undefined
|
|
1129
|
+
? campaignsOf(policy).campaignRules
|
|
1130
|
+
: campaignsOf(policy).campaignRules.filter((rule) => rule.id === named);
|
|
1131
|
+
const manifestPath = path
|
|
1132
|
+
.relative(policy.repoRoot, manifestPathOf(policy.repoRoot, configFilename))
|
|
1133
|
+
.replaceAll(path.sep, "/");
|
|
1134
|
+
const lines = [];
|
|
1135
|
+
for (const rule of targets) {
|
|
1136
|
+
const rows = historyOf(policy, rule, flagOf(argv, "--since") ?? null, [manifestPath]);
|
|
1137
|
+
if (json) {
|
|
1138
|
+
lines.push(JSON.stringify({ campaign: rule.id, rows }, null, 2));
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
if (lines.length > 0)
|
|
1142
|
+
lines.push("");
|
|
1143
|
+
for (const line of renderHistory(rule, rows))
|
|
1144
|
+
lines.push(line);
|
|
1145
|
+
}
|
|
1146
|
+
return yield* report(lines);
|
|
1147
|
+
}
|
|
1148
|
+
case "clear":
|
|
1149
|
+
case "concede":
|
|
1150
|
+
// The ledger verbs answer under `objectives`; accepted here too.
|
|
1151
|
+
return yield* objectives(policy, defaultRoots, argv);
|
|
1124
1152
|
default:
|
|
1125
|
-
return yield* Effect.fail(fail(`unknown campaigns subcommand "${subcommand}". Try: campaigns | campaigns
|
|
1153
|
+
return yield* Effect.fail(fail(`unknown campaigns subcommand "${parsed.subcommand}". Try: campaigns | campaigns status --changed | campaigns attest <sector> <phase> --reason <text> | campaigns note <sector> "<text>" | campaigns history [<campaign>]`));
|
|
1126
1154
|
}
|
|
1127
1155
|
});
|
|
1128
1156
|
// The commands that judge a campaign, and so ask its report source.
|
|
@@ -1131,6 +1159,7 @@ const READS_REPORTS = new Set([
|
|
|
1131
1159
|
"conformance",
|
|
1132
1160
|
"baseline",
|
|
1133
1161
|
"campaigns",
|
|
1162
|
+
"objectives",
|
|
1134
1163
|
"explain",
|
|
1135
1164
|
]);
|
|
1136
1165
|
export const run = (repoRoot, argv,
|
|
@@ -1173,7 +1202,7 @@ configFilename) => Effect.gen(function* () {
|
|
|
1173
1202
|
// for the campaign to report; a report that does not parse is refused.
|
|
1174
1203
|
if (READS_REPORTS.has(command)) {
|
|
1175
1204
|
yield* Effect.tryPromise({
|
|
1176
|
-
try: () => Promise.all(reportSpecsOf(policy.campaignRules).map((spec) => policy.reports.read?.(spec))),
|
|
1205
|
+
try: () => Promise.all(reportSpecsOf(campaignsOf(policy).campaignRules).map((spec) => campaignsOf(policy).reports.read?.(spec))),
|
|
1177
1206
|
catch: (cause) => fail(String(cause)),
|
|
1178
1207
|
});
|
|
1179
1208
|
}
|
|
@@ -1184,7 +1213,9 @@ configFilename) => Effect.gen(function* () {
|
|
|
1184
1213
|
const roots = positional.length > 0 ? positional : ["packages"];
|
|
1185
1214
|
switch (command) {
|
|
1186
1215
|
case "campaigns":
|
|
1187
|
-
return yield* campaigns(policy, ["packages"], rest);
|
|
1216
|
+
return yield* campaigns(policy, ["packages"], rest, configFilename);
|
|
1217
|
+
case "objectives":
|
|
1218
|
+
return yield* objectives(policy, ["packages"], rest);
|
|
1188
1219
|
case "check":
|
|
1189
1220
|
return yield* check(policy, roots, {
|
|
1190
1221
|
format: json ? "json" : "text",
|
|
@@ -1198,10 +1229,10 @@ configFilename) => Effect.gen(function* () {
|
|
|
1198
1229
|
case "baseline":
|
|
1199
1230
|
return yield* writeBaseline(policy, roots);
|
|
1200
1231
|
case "explain": {
|
|
1201
|
-
const [file] =
|
|
1232
|
+
const [file, ...explainRoots] = positional;
|
|
1202
1233
|
if (file === undefined)
|
|
1203
1234
|
return yield* Effect.fail(fail("explain needs a file path"));
|
|
1204
|
-
return yield* explain(policy, file);
|
|
1235
|
+
return yield* explain(policy, file, explainRoots.length > 0 ? explainRoots : ["packages"]);
|
|
1205
1236
|
}
|
|
1206
1237
|
case "coverage":
|
|
1207
1238
|
return yield* coverage(policy, roots);
|
|
@@ -1212,7 +1243,7 @@ configFilename) => Effect.gen(function* () {
|
|
|
1212
1243
|
return yield* facts(policy, file, json ? "json" : "text");
|
|
1213
1244
|
}
|
|
1214
1245
|
default:
|
|
1215
|
-
return yield* Effect.fail(fail(`unknown command "${command}". Try: check [--json] | conformance [--json] [--against <manifest>] | baseline | campaigns [
|
|
1246
|
+
return yield* Effect.fail(fail(`unknown command "${command}". Try: check [--json] | conformance [--json] [--against <manifest>] | baseline | campaigns [status --changed | attest | note | history] | objectives [clear | concede] | coverage | explain <file> | facts <file> [--json] | init | infer | migrate`));
|
|
1216
1247
|
}
|
|
1217
1248
|
});
|
|
1218
1249
|
export const fingerprint = fingerprintOf;
|