@goodbones/campaigns 0.1.0
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/LICENSE +21 -0
- package/build/dts/core/campaign-state.d.ts +38 -0
- package/build/dts/core/campaign-state.d.ts.map +1 -0
- package/build/dts/core/campaigns.d.ts +196 -0
- package/build/dts/core/campaigns.d.ts.map +1 -0
- package/build/dts/core/ledger.d.ts +173 -0
- package/build/dts/core/ledger.d.ts.map +1 -0
- package/build/dts/core/phases.d.ts +26 -0
- package/build/dts/core/phases.d.ts.map +1 -0
- package/build/dts/core/sectors.d.ts +52 -0
- package/build/dts/core/sectors.d.ts.map +1 -0
- package/build/dts/domain/config.d.ts +985 -0
- package/build/dts/domain/config.d.ts.map +1 -0
- package/build/dts/domain/report.d.ts +16 -0
- package/build/dts/domain/report.d.ts.map +1 -0
- package/build/dts/host/campaigns.d.ts +195 -0
- package/build/dts/host/campaigns.d.ts.map +1 -0
- package/build/dts/host/diff.d.ts +31 -0
- package/build/dts/host/diff.d.ts.map +1 -0
- package/build/dts/index.d.ts +17 -0
- package/build/dts/index.d.ts.map +1 -0
- package/build/dts/infrastructure/campaign-functions.d.ts +7 -0
- package/build/dts/infrastructure/campaign-functions.d.ts.map +1 -0
- package/build/dts/infrastructure/report-source-fake.d.ts +4 -0
- package/build/dts/infrastructure/report-source-fake.d.ts.map +1 -0
- package/build/dts/infrastructure/report-source-live.d.ts +3 -0
- package/build/dts/infrastructure/report-source-live.d.ts.map +1 -0
- package/build/dts/load/decode.d.ts +7 -0
- package/build/dts/load/decode.d.ts.map +1 -0
- package/build/dts/load/extension.d.ts +25 -0
- package/build/dts/load/extension.d.ts.map +1 -0
- package/build/dts/manifest/lower.d.ts +14 -0
- package/build/dts/manifest/lower.d.ts.map +1 -0
- package/build/dts/manifest/spec.d.ts +515 -0
- package/build/dts/manifest/spec.d.ts.map +1 -0
- package/build/dts/ports/campaign-predicate.d.ts +23 -0
- package/build/dts/ports/campaign-predicate.d.ts.map +1 -0
- package/build/dts/ports/report-source.d.ts +14 -0
- package/build/dts/ports/report-source.d.ts.map +1 -0
- package/build/dts/testing.d.ts +2 -0
- package/build/dts/testing.d.ts.map +1 -0
- package/build/esm/core/campaign-state.js +140 -0
- package/build/esm/core/campaign-state.js.map +1 -0
- package/build/esm/core/campaigns.js +954 -0
- package/build/esm/core/campaigns.js.map +1 -0
- package/build/esm/core/ledger.js +519 -0
- package/build/esm/core/ledger.js.map +1 -0
- package/build/esm/core/phases.js +87 -0
- package/build/esm/core/phases.js.map +1 -0
- package/build/esm/core/sectors.js +313 -0
- package/build/esm/core/sectors.js.map +1 -0
- package/build/esm/domain/config.js +245 -0
- package/build/esm/domain/config.js.map +1 -0
- package/build/esm/domain/report.js +168 -0
- package/build/esm/domain/report.js.map +1 -0
- package/build/esm/host/campaigns.js +1159 -0
- package/build/esm/host/campaigns.js.map +1 -0
- package/build/esm/host/diff.js +156 -0
- package/build/esm/host/diff.js.map +1 -0
- package/build/esm/index.js +27 -0
- package/build/esm/index.js.map +1 -0
- package/build/esm/infrastructure/campaign-functions.js +67 -0
- package/build/esm/infrastructure/campaign-functions.js.map +1 -0
- package/build/esm/infrastructure/report-source-fake.js +6 -0
- package/build/esm/infrastructure/report-source-fake.js.map +1 -0
- package/build/esm/infrastructure/report-source-live.js +165 -0
- package/build/esm/infrastructure/report-source-live.js.map +1 -0
- package/build/esm/load/decode.js +60 -0
- package/build/esm/load/decode.js.map +1 -0
- package/build/esm/load/extension.js +326 -0
- package/build/esm/load/extension.js.map +1 -0
- package/build/esm/manifest/lower.js +488 -0
- package/build/esm/manifest/lower.js.map +1 -0
- package/build/esm/manifest/spec.js +296 -0
- package/build/esm/manifest/spec.js.map +1 -0
- package/build/esm/ports/campaign-predicate.js +2 -0
- package/build/esm/ports/campaign-predicate.js.map +1 -0
- package/build/esm/ports/report-source.js +7 -0
- package/build/esm/ports/report-source.js.map +1 -0
- package/build/esm/testing.js +5 -0
- package/build/esm/testing.js.map +1 -0
- package/package.json +65 -0
- package/src/core/campaign-state.ts +227 -0
- package/src/core/campaigns.ts +1319 -0
- package/src/core/ledger.ts +675 -0
- package/src/core/phases.ts +138 -0
- package/src/core/sectors.ts +399 -0
- package/src/domain/config.ts +314 -0
- package/src/domain/report.ts +203 -0
- package/src/host/campaigns.ts +1664 -0
- package/src/host/diff.ts +203 -0
- package/src/index.ts +256 -0
- package/src/infrastructure/campaign-functions.ts +99 -0
- package/src/infrastructure/report-source-fake.ts +10 -0
- package/src/infrastructure/report-source-live.ts +193 -0
- package/src/load/decode.ts +81 -0
- package/src/load/extension.ts +466 -0
- package/src/manifest/lower.ts +615 -0
- package/src/manifest/spec.ts +364 -0
- package/src/ports/campaign-predicate.ts +29 -0
- package/src/ports/report-source.ts +35 -0
- package/src/testing.ts +4 -0
|
@@ -0,0 +1,1664 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
compileExportRules,
|
|
7
|
+
compileImportRules,
|
|
8
|
+
compileMemberRules,
|
|
9
|
+
compileStructure,
|
|
10
|
+
compileSurfaceRules,
|
|
11
|
+
evaluateMemberSite,
|
|
12
|
+
evaluateResolvedEdge,
|
|
13
|
+
evaluateSelectedBindings,
|
|
14
|
+
evaluateStructure,
|
|
15
|
+
evaluateSurface,
|
|
16
|
+
exportRulesSelecting,
|
|
17
|
+
globToRegExp,
|
|
18
|
+
listSourceFiles,
|
|
19
|
+
listWorkspaceProjects,
|
|
20
|
+
type LoadedPolicy,
|
|
21
|
+
memberRulesSelecting,
|
|
22
|
+
rulesSelecting,
|
|
23
|
+
type SnapshotCampaign,
|
|
24
|
+
type SourceFacts,
|
|
25
|
+
surfaceRulesSelecting,
|
|
26
|
+
type Violation,
|
|
27
|
+
} from "@goodbones/core";
|
|
28
|
+
import * as Result from "effect/Result";
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
type CampaignEvaluation,
|
|
32
|
+
evaluateCampaign,
|
|
33
|
+
hitsInWindow,
|
|
34
|
+
type ObjectiveHit,
|
|
35
|
+
towardNextOf,
|
|
36
|
+
} from "../core/campaign-state.js";
|
|
37
|
+
import {
|
|
38
|
+
type CampaignInput,
|
|
39
|
+
type CompiledCampaign,
|
|
40
|
+
type CompiledObjective,
|
|
41
|
+
detectorOf,
|
|
42
|
+
needsSyntax,
|
|
43
|
+
} from "../core/campaigns.js";
|
|
44
|
+
import {
|
|
45
|
+
attestedRecord,
|
|
46
|
+
clearedSector,
|
|
47
|
+
concededSector,
|
|
48
|
+
EMPTY_LEDGER,
|
|
49
|
+
EMPTY_SECTOR_RECORD,
|
|
50
|
+
isComplete,
|
|
51
|
+
isStalled,
|
|
52
|
+
lastClearedOf,
|
|
53
|
+
type Ledger,
|
|
54
|
+
ledgerArithmeticHolds,
|
|
55
|
+
ledgerPathOf,
|
|
56
|
+
notedRecord,
|
|
57
|
+
planDiffOf,
|
|
58
|
+
planOf,
|
|
59
|
+
planPathOf,
|
|
60
|
+
progressOf,
|
|
61
|
+
reachedRecord,
|
|
62
|
+
rebaselinedSector,
|
|
63
|
+
reconcileSector,
|
|
64
|
+
sectorArithmeticHolds,
|
|
65
|
+
sectorClockOf,
|
|
66
|
+
type SectorRecord,
|
|
67
|
+
sectorRecordPathOf,
|
|
68
|
+
serializeLedger,
|
|
69
|
+
serializePlanRecord,
|
|
70
|
+
serializeSectorRecord,
|
|
71
|
+
} from "../core/ledger.js";
|
|
72
|
+
import {
|
|
73
|
+
compareResidue,
|
|
74
|
+
type Direction,
|
|
75
|
+
isDefinedPhase,
|
|
76
|
+
isOpenPhase,
|
|
77
|
+
onTouchOf,
|
|
78
|
+
type Residue as ResidueVector,
|
|
79
|
+
worsened,
|
|
80
|
+
} from "../core/phases.js";
|
|
81
|
+
import { LEGACY_SECTOR, parseSectorMarker, type Sector } from "../core/sectors.js";
|
|
82
|
+
import type { OnTouch, PhaseRule } from "../domain/config.js";
|
|
83
|
+
import { campaignsOf, ledgerKeyOf } from "../load/extension.js";
|
|
84
|
+
import { lowerEndState } from "../manifest/lower.js";
|
|
85
|
+
import {
|
|
86
|
+
type Commit,
|
|
87
|
+
commitOf,
|
|
88
|
+
commitsTouching,
|
|
89
|
+
type Diff,
|
|
90
|
+
distanceToHunks,
|
|
91
|
+
materializeTree,
|
|
92
|
+
readDiff,
|
|
93
|
+
textAt,
|
|
94
|
+
} from "./diff.js";
|
|
95
|
+
|
|
96
|
+
// The campaigns family, as the CLI runs it: every campaign evaluated over
|
|
97
|
+
// the files it sees, each objective's hits judged against its ledger per
|
|
98
|
+
// sector, the ledgers written by `objectives clear` and `objectives
|
|
99
|
+
// concede`, the per-sector record by `campaigns attest` and `campaigns
|
|
100
|
+
// note`, and — the thing the rest exists to serve — the nudge,
|
|
101
|
+
// `campaigns status --changed`, which tells whoever touched a sector which
|
|
102
|
+
// campaign it is in, what phase it is at, and what small thing in the files
|
|
103
|
+
// they touched would move it on.
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Evaluation
|
|
107
|
+
|
|
108
|
+
export type Readers = {
|
|
109
|
+
readonly textOf: (file: string) => string;
|
|
110
|
+
readonly factsOf: (file: string) => SourceFacts;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// The files a campaign sees: those in its scope with an extension a pack
|
|
114
|
+
// walks, or one the campaign's scope widens the walk to.
|
|
115
|
+
const filesFor = (
|
|
116
|
+
policy: LoadedPolicy,
|
|
117
|
+
rule: CompiledCampaign,
|
|
118
|
+
files: ReadonlyArray<string>,
|
|
119
|
+
): ReadonlyArray<string> => {
|
|
120
|
+
const known = new Set(policy.languages.flatMap((one) => one.extensions));
|
|
121
|
+
return files.filter(
|
|
122
|
+
(file) =>
|
|
123
|
+
rule.scope.some((pattern) => pattern.test(file)) &&
|
|
124
|
+
(known.has(path.extname(file)) || rule.extensions.includes(path.extname(file))),
|
|
125
|
+
);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// The extensions every campaign widens the walk to, for the walker.
|
|
129
|
+
export const widenedExtensions = (policy: LoadedPolicy): ReadonlyArray<string> => [
|
|
130
|
+
...new Set(campaignsOf(policy).campaignRules.flatMap((rule) => rule.extensions)),
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
const readersOf = (policy: LoadedPolicy): Readers => {
|
|
134
|
+
const texts = new Map<string, string>();
|
|
135
|
+
const textOf = (file: string): string => {
|
|
136
|
+
const cached = texts.get(file);
|
|
137
|
+
if (cached !== undefined) return cached;
|
|
138
|
+
let text = "";
|
|
139
|
+
try {
|
|
140
|
+
text = readFileSync(path.join(policy.repoRoot, file), "utf8");
|
|
141
|
+
} catch {
|
|
142
|
+
text = "";
|
|
143
|
+
}
|
|
144
|
+
texts.set(file, text);
|
|
145
|
+
return text;
|
|
146
|
+
};
|
|
147
|
+
const parsed = new Map<string, SourceFacts>();
|
|
148
|
+
const factsOf = (file: string): SourceFacts => {
|
|
149
|
+
const cached = parsed.get(file);
|
|
150
|
+
if (cached !== undefined) return cached;
|
|
151
|
+
const facts = policy.extractor.factsOf(file, textOf(file));
|
|
152
|
+
parsed.set(file, facts);
|
|
153
|
+
return facts;
|
|
154
|
+
};
|
|
155
|
+
return { textOf, factsOf };
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// One family of an end state against one sector: the tree lowered with the
|
|
159
|
+
// sector's root, compiled, and evaluated over the sector's files the way
|
|
160
|
+
// the repository's own tree is.
|
|
161
|
+
const endStateViolations = (
|
|
162
|
+
policy: LoadedPolicy,
|
|
163
|
+
readers: Readers,
|
|
164
|
+
sectors: ReadonlyArray<Sector>,
|
|
165
|
+
sector: Sector,
|
|
166
|
+
phase: PhaseRule,
|
|
167
|
+
): Readonly<Record<string, ReadonlyArray<Violation>>> => {
|
|
168
|
+
const root = sector.roots[0] ?? "";
|
|
169
|
+
const lowered = lowerEndState(
|
|
170
|
+
phase.endState as Readonly<Record<string, unknown>>,
|
|
171
|
+
root,
|
|
172
|
+
sectors.map((one) => ({ name: one.name, root: one.roots[0] ?? "" })),
|
|
173
|
+
policy.config.resolve,
|
|
174
|
+
policy.languages,
|
|
175
|
+
policy.config.aliases ?? {},
|
|
176
|
+
);
|
|
177
|
+
const unwrap = <A>(compiled: Result.Result<A, { readonly message: string }>): A => {
|
|
178
|
+
if (Result.isFailure(compiled)) throw new Error(compiled.failure.message);
|
|
179
|
+
return compiled.success;
|
|
180
|
+
};
|
|
181
|
+
const imports = unwrap(compileImportRules(lowered.imports));
|
|
182
|
+
const exports = unwrap(compileExportRules(lowered.exports));
|
|
183
|
+
const members = unwrap(compileMemberRules(lowered.members));
|
|
184
|
+
const surface = unwrap(compileSurfaceRules(lowered.surface));
|
|
185
|
+
const structure = unwrap(compileStructure(lowered.structure));
|
|
186
|
+
const found: Record<string, Array<Violation>> = {
|
|
187
|
+
imports: [],
|
|
188
|
+
exports: [],
|
|
189
|
+
members: [],
|
|
190
|
+
surface: [],
|
|
191
|
+
structure: [],
|
|
192
|
+
};
|
|
193
|
+
for (const file of sector.files) {
|
|
194
|
+
for (const one of evaluateStructure(structure, policy.fileSystem, file)) {
|
|
195
|
+
found.structure?.push(one);
|
|
196
|
+
}
|
|
197
|
+
const selectedImports = rulesSelecting(imports, file);
|
|
198
|
+
const selectedExports = exportRulesSelecting(exports, file);
|
|
199
|
+
const selectedMembers = memberRulesSelecting(members, file);
|
|
200
|
+
const selectedSurface = surfaceRulesSelecting(surface, file);
|
|
201
|
+
if (
|
|
202
|
+
selectedImports.length +
|
|
203
|
+
selectedExports.length +
|
|
204
|
+
selectedMembers.length +
|
|
205
|
+
selectedSurface.length ===
|
|
206
|
+
0
|
|
207
|
+
) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const facts = readers.factsOf(file);
|
|
211
|
+
for (const one of evaluateSurface(selectedSurface, file, facts.exportSites)) {
|
|
212
|
+
found.surface?.push(one);
|
|
213
|
+
}
|
|
214
|
+
for (const site of facts.memberSites) {
|
|
215
|
+
for (const one of evaluateMemberSite(selectedMembers, site)) found.members?.push(one);
|
|
216
|
+
}
|
|
217
|
+
for (const specifier of facts.specifiers) {
|
|
218
|
+
if (selectedImports.length > 0) {
|
|
219
|
+
const resolved = policy.resolver.resolve(file, specifier);
|
|
220
|
+
if (Result.isSuccess(resolved)) {
|
|
221
|
+
for (const one of evaluateResolvedEdge(selectedImports, file, resolved.success)) {
|
|
222
|
+
found.imports?.push(one);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const bound = facts.bindings.get(specifier) ?? [];
|
|
227
|
+
const exported = evaluateSelectedBindings(selectedExports, policy.resolver, {
|
|
228
|
+
importer: file,
|
|
229
|
+
specifier,
|
|
230
|
+
bindings: bound,
|
|
231
|
+
});
|
|
232
|
+
if (Result.isSuccess(exported)) {
|
|
233
|
+
for (const { violation } of exported.success) found.exports?.push(violation);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return found;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// Every campaign, evaluated over the files under `roots`.
|
|
241
|
+
export const evaluateCampaigns = (
|
|
242
|
+
policy: LoadedPolicy,
|
|
243
|
+
roots: ReadonlyArray<string>,
|
|
244
|
+
files: ReadonlyArray<string>,
|
|
245
|
+
readers: Readers = readersOf(policy),
|
|
246
|
+
): ReadonlyArray<CampaignEvaluation> => {
|
|
247
|
+
const projects = campaignsOf(policy).campaignRules.some((rule) => rule.perimeter?.kind === "nx")
|
|
248
|
+
? listWorkspaceProjects(policy.repoRoot, roots)
|
|
249
|
+
: [];
|
|
250
|
+
return campaignsOf(policy).campaignRules.map((rule) => {
|
|
251
|
+
const detectors = [
|
|
252
|
+
...rule.objectives.flatMap((one) => {
|
|
253
|
+
const detect = detectorOf(one);
|
|
254
|
+
return detect === null ? [] : [detect];
|
|
255
|
+
}),
|
|
256
|
+
...(rule.perimeter?.kind === "match" ? [rule.perimeter.detect] : []),
|
|
257
|
+
];
|
|
258
|
+
const parses = needsSyntax(detectors);
|
|
259
|
+
const inputOf = (file: string): CampaignInput => {
|
|
260
|
+
const text = readers.textOf(file);
|
|
261
|
+
return {
|
|
262
|
+
file,
|
|
263
|
+
text,
|
|
264
|
+
facts: readers.factsOf(file),
|
|
265
|
+
resolver: policy.resolver,
|
|
266
|
+
fileSystem: policy.fileSystem,
|
|
267
|
+
syntax: parses ? policy.syntax.parse(file, text) : null,
|
|
268
|
+
functions: campaignsOf(policy).functions,
|
|
269
|
+
reports: campaignsOf(policy).reports,
|
|
270
|
+
};
|
|
271
|
+
};
|
|
272
|
+
const input = {
|
|
273
|
+
files: filesFor(policy, rule, files),
|
|
274
|
+
inputOf,
|
|
275
|
+
readText: (file: string) => readers.textOf(file),
|
|
276
|
+
globToRegExp,
|
|
277
|
+
projects,
|
|
278
|
+
recordOf: (sector: string) => campaignsOf(policy).sectorRecords.get(ledgerKeyOf(rule.id, sector)),
|
|
279
|
+
};
|
|
280
|
+
if (!rule.objectives.some((one) => one.endState !== null)) return evaluateCampaign(rule, input);
|
|
281
|
+
// An end state's `{ sector, via }` entries need every sector's root, so
|
|
282
|
+
// the sectors are discovered first and the tree is lowered per sector
|
|
283
|
+
// once they are known. One family's violations are computed with the
|
|
284
|
+
// rest of that sector's, and kept for the other families' asks.
|
|
285
|
+
const known = [...evaluateCampaign(rule, input).index.sectors.values()];
|
|
286
|
+
const endStates = new Map<string, Readonly<Record<string, ReadonlyArray<Violation>>>>();
|
|
287
|
+
const evaluation = evaluateCampaign(rule, {
|
|
288
|
+
...input,
|
|
289
|
+
endStateOf: (sector, phase, family) => {
|
|
290
|
+
const key = `${sector.name}\u0000${phase.id}`;
|
|
291
|
+
let found = endStates.get(key);
|
|
292
|
+
if (found === undefined) {
|
|
293
|
+
found = endStateViolations(policy, readers, known, sector, phase);
|
|
294
|
+
endStates.set(key, found);
|
|
295
|
+
}
|
|
296
|
+
return found[family] ?? [];
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
return evaluation;
|
|
300
|
+
});
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const ledgerOf = (policy: LoadedPolicy, rule: CompiledCampaign, objective: CompiledObjective) =>
|
|
304
|
+
campaignsOf(policy).ledgers.get(ledgerKeyOf(rule.id, objective.id));
|
|
305
|
+
|
|
306
|
+
const recordOf = (policy: LoadedPolicy, rule: CompiledCampaign, sector: string) =>
|
|
307
|
+
campaignsOf(policy).sectorRecords.get(ledgerKeyOf(rule.id, sector));
|
|
308
|
+
|
|
309
|
+
const phaseIdOf = (rule: CompiledCampaign, phase: number): string | null =>
|
|
310
|
+
rule.phases[phase]?.id ?? null;
|
|
311
|
+
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
// The report `check` reads
|
|
314
|
+
|
|
315
|
+
export type SectorObjectiveReport = {
|
|
316
|
+
readonly sector: string;
|
|
317
|
+
readonly count: number;
|
|
318
|
+
// Entries the ledger does not carry — unrecorded growth.
|
|
319
|
+
readonly new: ReadonlyArray<string>;
|
|
320
|
+
// Holdouts no entry produces — cleared, and waiting for `clear`.
|
|
321
|
+
readonly stale: ReadonlyArray<string>;
|
|
322
|
+
readonly drifted: number;
|
|
323
|
+
// The sector is in the objective's window and the ledger has not seen it.
|
|
324
|
+
readonly unrecorded: boolean;
|
|
325
|
+
readonly arithmetic: boolean;
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
export type ObjectiveReport = {
|
|
329
|
+
readonly id: string;
|
|
330
|
+
readonly count: number;
|
|
331
|
+
readonly ledgered: boolean;
|
|
332
|
+
readonly sectors: ReadonlyArray<SectorObjectiveReport>;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
export type SectorReport = {
|
|
336
|
+
readonly name: string;
|
|
337
|
+
readonly phase: string | null;
|
|
338
|
+
readonly reached: string | null;
|
|
339
|
+
readonly files: number;
|
|
340
|
+
readonly residue: ResidueVector;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
export type CampaignReport = {
|
|
344
|
+
readonly id: string;
|
|
345
|
+
// Holdouts in window, every objective and sector summed.
|
|
346
|
+
readonly count: number;
|
|
347
|
+
readonly new: ReadonlyArray<{ objective: string; sector: string; entry: string }>;
|
|
348
|
+
readonly stale: ReadonlyArray<{ objective: string; sector: string; entry: string }>;
|
|
349
|
+
readonly drifted: number;
|
|
350
|
+
// No ledger for an objective with hits, or a sector in window the ledger
|
|
351
|
+
// has not seen: `objectives clear` has not been run.
|
|
352
|
+
readonly missingLedger: boolean;
|
|
353
|
+
readonly arithmetic: boolean;
|
|
354
|
+
readonly complete: boolean;
|
|
355
|
+
readonly stalled: boolean;
|
|
356
|
+
readonly onComplete: "keep" | "remove";
|
|
357
|
+
readonly objectives: ReadonlyArray<ObjectiveReport>;
|
|
358
|
+
readonly sectors: ReadonlyArray<SectorReport>;
|
|
359
|
+
// Files two sectors claim.
|
|
360
|
+
readonly drift: ReadonlyArray<{ readonly file: string; readonly sectors: ReadonlyArray<string> }>;
|
|
361
|
+
readonly plan: {
|
|
362
|
+
refined: ReadonlyArray<string>;
|
|
363
|
+
changed: ReadonlyArray<string>;
|
|
364
|
+
unreceipted: ReadonlyArray<string>;
|
|
365
|
+
};
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const entriesOf = (
|
|
369
|
+
hits: ReadonlyArray<ObjectiveHit>,
|
|
370
|
+
objective: string,
|
|
371
|
+
sector: string,
|
|
372
|
+
): ReadonlyArray<string> =>
|
|
373
|
+
hits
|
|
374
|
+
.filter((hit) => hit.objective === objective && hit.sector === sector)
|
|
375
|
+
.map((hit) => hit.entry);
|
|
376
|
+
|
|
377
|
+
const sectorNames = (evaluation: CampaignEvaluation): ReadonlyArray<string> => [
|
|
378
|
+
...evaluation.sectors.keys(),
|
|
379
|
+
];
|
|
380
|
+
|
|
381
|
+
export const campaignReportsOf = (
|
|
382
|
+
policy: LoadedPolicy,
|
|
383
|
+
evaluations: ReadonlyArray<CampaignEvaluation>,
|
|
384
|
+
): ReadonlyArray<CampaignReport> =>
|
|
385
|
+
evaluations.map((evaluation) => {
|
|
386
|
+
const { rule } = evaluation;
|
|
387
|
+
const counted = hitsInWindow(evaluation);
|
|
388
|
+
let missingLedger = false;
|
|
389
|
+
const objectives: Array<ObjectiveReport> = rule.objectives.map((objective) => {
|
|
390
|
+
const ledger = ledgerOf(policy, rule, objective);
|
|
391
|
+
const sectors: Array<SectorObjectiveReport> = [];
|
|
392
|
+
for (const name of sectorNames(evaluation)) {
|
|
393
|
+
const state = evaluation.sectors.get(name);
|
|
394
|
+
if (state === undefined) continue;
|
|
395
|
+
const inWindow = state.inWindow.some((one) => one.id === objective.id);
|
|
396
|
+
const entries = entriesOf(counted, objective.id, name);
|
|
397
|
+
const recorded = ledger?.sectors[name] !== undefined;
|
|
398
|
+
if (!inWindow) {
|
|
399
|
+
// Past the window: what the ledger still carries is closed by
|
|
400
|
+
// `clear`, and nothing here counts.
|
|
401
|
+
const carried = ledger?.sectors[name]?.holdouts ?? [];
|
|
402
|
+
if (ledger !== undefined && carried.length > 0) {
|
|
403
|
+
sectors.push({
|
|
404
|
+
sector: name,
|
|
405
|
+
count: 0,
|
|
406
|
+
new: [],
|
|
407
|
+
stale: carried,
|
|
408
|
+
drifted: 0,
|
|
409
|
+
unrecorded: false,
|
|
410
|
+
arithmetic: sectorArithmeticHolds(ledger, name),
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (ledger === undefined || !recorded) {
|
|
416
|
+
if (entries.length > 0 || ledger !== undefined) missingLedger = true;
|
|
417
|
+
sectors.push({
|
|
418
|
+
sector: name,
|
|
419
|
+
count: entries.length,
|
|
420
|
+
new: [...new Set(entries)].sort(),
|
|
421
|
+
stale: [],
|
|
422
|
+
drifted: 0,
|
|
423
|
+
unrecorded: true,
|
|
424
|
+
arithmetic: true,
|
|
425
|
+
});
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
const state_ = reconcileSector(ledger, name, entries, objective.unit);
|
|
429
|
+
sectors.push({
|
|
430
|
+
sector: name,
|
|
431
|
+
count: entries.length,
|
|
432
|
+
new: state_.unrecorded,
|
|
433
|
+
stale: state_.stale,
|
|
434
|
+
drifted: state_.drifted.length,
|
|
435
|
+
unrecorded: false,
|
|
436
|
+
arithmetic: sectorArithmeticHolds(ledger, name),
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
// Sectors the ledger carries that the code no longer births: closed
|
|
440
|
+
// on the next `clear`, stale until then.
|
|
441
|
+
for (const name of Object.keys(ledger?.sectors ?? {})) {
|
|
442
|
+
if (evaluation.sectors.has(name)) continue;
|
|
443
|
+
const holdouts = ledger?.sectors[name]?.holdouts ?? [];
|
|
444
|
+
if (holdouts.length === 0) continue;
|
|
445
|
+
sectors.push({
|
|
446
|
+
sector: name,
|
|
447
|
+
count: 0,
|
|
448
|
+
new: [],
|
|
449
|
+
stale: holdouts,
|
|
450
|
+
drifted: 0,
|
|
451
|
+
unrecorded: false,
|
|
452
|
+
arithmetic: true,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
return {
|
|
456
|
+
id: objective.id,
|
|
457
|
+
count: sectors.reduce((sum, one) => sum + one.count, 0),
|
|
458
|
+
ledgered: ledger !== undefined,
|
|
459
|
+
sectors,
|
|
460
|
+
};
|
|
461
|
+
});
|
|
462
|
+
const flat = (pick: (one: SectorObjectiveReport) => ReadonlyArray<string>) =>
|
|
463
|
+
objectives.flatMap((objective) =>
|
|
464
|
+
objective.sectors.flatMap((one) =>
|
|
465
|
+
pick(one).map((entry) => ({ objective: objective.id, sector: one.sector, entry })),
|
|
466
|
+
),
|
|
467
|
+
);
|
|
468
|
+
const ledgers = rule.objectives.flatMap((objective) => {
|
|
469
|
+
const ledger = ledgerOf(policy, rule, objective);
|
|
470
|
+
return ledger === undefined ? [] : [ledger];
|
|
471
|
+
});
|
|
472
|
+
const sectorClock = [...evaluation.sectors.keys()]
|
|
473
|
+
.map((name) => recordOf(policy, rule, name))
|
|
474
|
+
.filter((one): one is SectorRecord => one !== undefined)
|
|
475
|
+
.map(sectorClockOf)
|
|
476
|
+
.reduce<string | null>((a, b) => (a === null || b > a ? b : a), null);
|
|
477
|
+
const count = objectives.reduce((sum, one) => sum + one.count, 0);
|
|
478
|
+
return {
|
|
479
|
+
id: rule.id,
|
|
480
|
+
count,
|
|
481
|
+
new: flat((one) => one.new),
|
|
482
|
+
stale: flat((one) => one.stale),
|
|
483
|
+
drifted: objectives.reduce(
|
|
484
|
+
(sum, one) => sum + one.sectors.reduce((inner, two) => inner + two.drifted, 0),
|
|
485
|
+
0,
|
|
486
|
+
),
|
|
487
|
+
missingLedger,
|
|
488
|
+
arithmetic: ledgers.every(ledgerArithmeticHolds),
|
|
489
|
+
complete: count === 0 && ledgers.every(isComplete),
|
|
490
|
+
stalled:
|
|
491
|
+
ledgers.length > 0 &&
|
|
492
|
+
ledgers.some((ledger) => isStalled(rule, ledger, policy.now, sectorClock)),
|
|
493
|
+
onComplete: rule.onComplete,
|
|
494
|
+
objectives,
|
|
495
|
+
sectors: [...evaluation.sectors.values()].map((state) => ({
|
|
496
|
+
name: state.name,
|
|
497
|
+
phase: phaseIdOf(rule, state.phase),
|
|
498
|
+
reached: recordOf(policy, rule, state.name)?.reached ?? null,
|
|
499
|
+
files: state.sector.files.length,
|
|
500
|
+
residue: state.residue,
|
|
501
|
+
})),
|
|
502
|
+
drift: evaluation.index.drift,
|
|
503
|
+
plan: planDiffOf(rule, campaignsOf(policy).plans.get(rule.id)),
|
|
504
|
+
};
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// Which hits in window are carried by a ledger, exactly or by anchor.
|
|
508
|
+
export const ledgeredFilter = (
|
|
509
|
+
policy: LoadedPolicy,
|
|
510
|
+
evaluations: ReadonlyArray<CampaignEvaluation>,
|
|
511
|
+
): ((hit: ObjectiveHit) => boolean) => {
|
|
512
|
+
const carried = new Set<ObjectiveHit>();
|
|
513
|
+
for (const evaluation of evaluations) {
|
|
514
|
+
const { rule } = evaluation;
|
|
515
|
+
for (const objective of rule.objectives) {
|
|
516
|
+
const ledger = ledgerOf(policy, rule, objective);
|
|
517
|
+
if (ledger === undefined) continue;
|
|
518
|
+
for (const name of sectorNames(evaluation)) {
|
|
519
|
+
const own = hitsInWindow(evaluation).filter(
|
|
520
|
+
(hit) => hit.objective === objective.id && hit.sector === name,
|
|
521
|
+
);
|
|
522
|
+
const { ledgered } = reconcileSector(
|
|
523
|
+
ledger,
|
|
524
|
+
name,
|
|
525
|
+
own.map((hit) => hit.entry),
|
|
526
|
+
objective.unit,
|
|
527
|
+
);
|
|
528
|
+
const known = new Set(ledgered);
|
|
529
|
+
for (const hit of own) if (known.has(hit.entry)) carried.add(hit);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return (hit) => carried.has(hit);
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
// Why a campaign report is not ok, in the order `check` explains it.
|
|
537
|
+
export const campaignFailuresOf = (
|
|
538
|
+
campaigns: ReadonlyArray<CampaignReport>,
|
|
539
|
+
): ReadonlyArray<string> => [
|
|
540
|
+
...campaigns
|
|
541
|
+
.filter((one) => one.drift.length > 0)
|
|
542
|
+
.map((one) => `campaign ${one.id}: a file is in two sectors`),
|
|
543
|
+
...campaigns
|
|
544
|
+
.filter((one) => one.plan.unreceipted.length > 0)
|
|
545
|
+
.map((one) => `campaign ${one.id}: a defined phase changed without a concession`),
|
|
546
|
+
...campaigns.filter((one) => one.stale.length > 0).map(() => "stale ledger entries"),
|
|
547
|
+
...campaigns.filter((one) => !one.arithmetic).map(() => "ledger arithmetic does not hold"),
|
|
548
|
+
...campaigns.filter((one) => one.missingLedger).map((one) => `campaign ${one.id} has no ledger`),
|
|
549
|
+
...campaigns
|
|
550
|
+
.filter((one) => !one.missingLedger && one.new.length > 0)
|
|
551
|
+
.map(() => "unrecorded campaign growth"),
|
|
552
|
+
...campaigns
|
|
553
|
+
.filter((one) => one.complete && !one.missingLedger && one.onComplete === "remove")
|
|
554
|
+
.map((one) => `campaign ${one.id} is complete and declared onComplete: remove`),
|
|
555
|
+
];
|
|
556
|
+
|
|
557
|
+
const count = (n: number, noun: string, plural = `${noun}s`): string =>
|
|
558
|
+
`${String(n)} ${n === 1 ? noun : plural}`;
|
|
559
|
+
|
|
560
|
+
// A campaign's failures, as `check` prints them, with the command that
|
|
561
|
+
// answers each.
|
|
562
|
+
export const renderCampaignReports = (
|
|
563
|
+
reports: ReadonlyArray<CampaignReport>,
|
|
564
|
+
hits: ReadonlyArray<{
|
|
565
|
+
readonly violation: Violation;
|
|
566
|
+
readonly objective: string;
|
|
567
|
+
readonly sector: string;
|
|
568
|
+
readonly entry: string;
|
|
569
|
+
readonly ledgered: boolean;
|
|
570
|
+
}>,
|
|
571
|
+
): ReadonlyArray<string> =>
|
|
572
|
+
reports.flatMap((campaign): ReadonlyArray<string> => {
|
|
573
|
+
const lines: Array<string> = [];
|
|
574
|
+
const say = (...more: ReadonlyArray<string>): void => {
|
|
575
|
+
for (const one of more) lines.push(one);
|
|
576
|
+
};
|
|
577
|
+
const at = (objective: string, sector: string, entry: string): string =>
|
|
578
|
+
` ${objective} · ${sector} · ${entry}`;
|
|
579
|
+
if (campaign.drift.length > 0) {
|
|
580
|
+
say(
|
|
581
|
+
"",
|
|
582
|
+
`campaign ${campaign.id}: ${count(campaign.drift.length, "file is", "files are")} in two sectors. A perimeter nests another; narrow one:`,
|
|
583
|
+
...campaign.drift.map((one) => ` ${one.file} (${one.sectors.join(", ")})`),
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
if (campaign.plan.unreceipted.length > 0) {
|
|
587
|
+
say(
|
|
588
|
+
"",
|
|
589
|
+
`campaign ${campaign.id}: ${count(campaign.plan.unreceipted.length, "defined phase")} changed since the last clear with no concession: ${campaign.plan.unreceipted.join(", ")}. Add a \`concessions\` entry to the phase with a reason and a date, then run \`objectives clear\`.`,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
if (campaign.missingLedger) {
|
|
593
|
+
const unrecorded = campaign.objectives.flatMap((objective) =>
|
|
594
|
+
objective.sectors
|
|
595
|
+
.filter((one) => one.unrecorded && (one.count > 0 || objective.ledgered))
|
|
596
|
+
.map((one) => ` ${objective.id} · ${one.sector} (${count(one.count, "hit")})`),
|
|
597
|
+
);
|
|
598
|
+
say(
|
|
599
|
+
"",
|
|
600
|
+
`campaign ${campaign.id}: ${count(unrecorded.length, "sector")} in an objective's window that no ledger has seen. Record them before they count as growth:`,
|
|
601
|
+
...unrecorded,
|
|
602
|
+
"",
|
|
603
|
+
` architecture objectives clear ${campaign.id}`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
if (campaign.new.length > 0 && !campaign.missingLedger) {
|
|
607
|
+
say(
|
|
608
|
+
"",
|
|
609
|
+
`campaign ${campaign.id}: ${count(campaign.new.length, "new hit")} the ledger does not carry. Fix them, or record why the count may rise:`,
|
|
610
|
+
...campaign.new.flatMap((one) => {
|
|
611
|
+
const hit = hits.find(
|
|
612
|
+
(two) =>
|
|
613
|
+
two.objective === one.objective &&
|
|
614
|
+
two.sector === one.sector &&
|
|
615
|
+
two.entry === one.entry,
|
|
616
|
+
);
|
|
617
|
+
return [
|
|
618
|
+
at(one.objective, one.sector, one.entry),
|
|
619
|
+
...(hit === undefined ? [] : [` ${hit.violation.file}: ${hit.violation.message}`]),
|
|
620
|
+
];
|
|
621
|
+
}),
|
|
622
|
+
"",
|
|
623
|
+
` architecture objectives concede ${campaign.id} --reason "<why>"`,
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
if (campaign.stale.length > 0) {
|
|
627
|
+
say(
|
|
628
|
+
"",
|
|
629
|
+
`campaign ${campaign.id}: ${count(campaign.stale.length, "ledger entry", "ledger entries")} no longer fire, or fire past their window. The code was fixed, or the sector moved on; clear them:`,
|
|
630
|
+
...campaign.stale.map((one) => at(one.objective, one.sector, one.entry)),
|
|
631
|
+
"",
|
|
632
|
+
` architecture objectives clear ${campaign.id}`,
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
if (!campaign.arithmetic) {
|
|
636
|
+
say(
|
|
637
|
+
"",
|
|
638
|
+
`campaign ${campaign.id}: a ledger does not add up (holdouts ≠ initial + conceded − cleared − closed). A holdout was added by hand; remove it, or record it with \`objectives concede\`.`,
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
const complete = campaign.complete && !campaign.missingLedger;
|
|
642
|
+
if (complete && campaign.onComplete === "remove") {
|
|
643
|
+
say(
|
|
644
|
+
"",
|
|
645
|
+
`campaign ${campaign.id} is complete and declares onComplete: remove. Delete it from the manifest, and its ledgers.`,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
if (campaign.stalled) {
|
|
649
|
+
say(
|
|
650
|
+
"",
|
|
651
|
+
`notice: campaign ${campaign.id} has stalled — nothing has left a ledger, and no sector has been attested or noted, within its staleAfter.`,
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
if (complete && campaign.onComplete === "keep") {
|
|
655
|
+
say("", `notice: campaign ${campaign.id} is complete, and stays as a guard.`);
|
|
656
|
+
}
|
|
657
|
+
if (campaign.plan.changed.length > 0 && campaign.plan.unreceipted.length === 0) {
|
|
658
|
+
say(
|
|
659
|
+
"",
|
|
660
|
+
`notice: campaign ${campaign.id}: plan changed — ${campaign.plan.changed.join(", ")} (receipted; the next clear re-baselines the sectors in window).`,
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
if (campaign.plan.refined.length > 0) {
|
|
664
|
+
say(
|
|
665
|
+
"",
|
|
666
|
+
`notice: campaign ${campaign.id}: plan refined — ${campaign.plan.refined.join(", ")}.`,
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
return lines;
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// ---------------------------------------------------------------------------
|
|
673
|
+
// The conformance snapshot's campaigns
|
|
674
|
+
|
|
675
|
+
export const snapshotCampaignsOf = (
|
|
676
|
+
policy: LoadedPolicy,
|
|
677
|
+
evaluations: ReadonlyArray<CampaignEvaluation>,
|
|
678
|
+
): ReadonlyArray<SnapshotCampaign> =>
|
|
679
|
+
campaignReportsOf(policy, evaluations).map((report, i) => {
|
|
680
|
+
const evaluation = evaluations[i];
|
|
681
|
+
if (evaluation === undefined) throw new Error("report without evaluation");
|
|
682
|
+
const { rule } = evaluation;
|
|
683
|
+
const ledgers = rule.objectives.map((objective) => ledgerOf(policy, rule, objective));
|
|
684
|
+
const objectives = rule.objectives.map((objective, j) => {
|
|
685
|
+
const ledger = ledgers[j];
|
|
686
|
+
const own = report.objectives[j];
|
|
687
|
+
const phase = rule.phases.find((one) => one.objectives.includes(objective.id))?.id ?? null;
|
|
688
|
+
if (ledger === undefined) {
|
|
689
|
+
return {
|
|
690
|
+
id: objective.id,
|
|
691
|
+
phase,
|
|
692
|
+
initial: own?.count ?? 0,
|
|
693
|
+
allowed: 0,
|
|
694
|
+
count: own?.count ?? 0,
|
|
695
|
+
cleared: 0,
|
|
696
|
+
closed: 0,
|
|
697
|
+
progress: 0,
|
|
698
|
+
lastCleared: null,
|
|
699
|
+
concessions: 0,
|
|
700
|
+
complete: (own?.count ?? 0) === 0,
|
|
701
|
+
ledgered: false,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
const sectors = Object.values(ledger.sectors);
|
|
705
|
+
return {
|
|
706
|
+
id: objective.id,
|
|
707
|
+
phase,
|
|
708
|
+
initial: sectors.reduce((sum, one) => sum + one.initial, 0),
|
|
709
|
+
allowed: ledger.concessions.reduce(
|
|
710
|
+
(sum, one) => sum + Math.max(0, "delta" in one ? one.delta : one.to - one.from),
|
|
711
|
+
0,
|
|
712
|
+
),
|
|
713
|
+
count: sectors.reduce((sum, one) => sum + one.holdouts.length, 0),
|
|
714
|
+
cleared: sectors.reduce((sum, one) => sum + one.cleared, 0),
|
|
715
|
+
closed: sectors.reduce((sum, one) => sum + one.closed, 0),
|
|
716
|
+
progress: progressOf(ledger),
|
|
717
|
+
lastCleared: lastClearedOf(ledger),
|
|
718
|
+
concessions: ledger.concessions.length,
|
|
719
|
+
complete: isComplete(ledger),
|
|
720
|
+
ledgered: true,
|
|
721
|
+
};
|
|
722
|
+
});
|
|
723
|
+
const totalInitial = objectives.reduce(
|
|
724
|
+
(sum, one) => sum + one.initial + one.allowed - one.closed,
|
|
725
|
+
0,
|
|
726
|
+
);
|
|
727
|
+
const totalCount = objectives.reduce((sum, one) => sum + one.count, 0);
|
|
728
|
+
const sectors = [...evaluation.sectors.values()].filter((one) => one.name !== LEGACY_SECTOR);
|
|
729
|
+
const legacy = evaluation.sectors.get(LEGACY_SECTOR);
|
|
730
|
+
return {
|
|
731
|
+
id: rule.id,
|
|
732
|
+
...(rule.title === null ? {} : { title: rule.title }),
|
|
733
|
+
...(rule.owner === null ? {} : { owner: rule.owner }),
|
|
734
|
+
count: totalCount,
|
|
735
|
+
progress: totalInitial <= 0 ? 1 : 1 - totalCount / totalInitial,
|
|
736
|
+
objectives,
|
|
737
|
+
phases: rule.phases.map((phase, index) => ({
|
|
738
|
+
id: phase.id,
|
|
739
|
+
defined: isDefinedPhase(phase),
|
|
740
|
+
sectors: sectors.filter((one) => one.phase === index).length,
|
|
741
|
+
})),
|
|
742
|
+
sectors: sectors.map((state) => {
|
|
743
|
+
const record = recordOf(policy, rule, state.name);
|
|
744
|
+
const residueTotal = Object.values(state.residue).reduce((sum, one) => sum + one, 0);
|
|
745
|
+
const clock = [
|
|
746
|
+
...ledgers.flatMap((ledger) => {
|
|
747
|
+
const own = ledger?.sectors[state.name];
|
|
748
|
+
return own === undefined ? [] : [own.lastCleared];
|
|
749
|
+
}),
|
|
750
|
+
...(record === undefined ? [] : [sectorClockOf(record)]),
|
|
751
|
+
].reduce<string | null>((a, b) => (a === null || b > a ? b : a), null);
|
|
752
|
+
return {
|
|
753
|
+
name: state.name,
|
|
754
|
+
phase: phaseIdOf(rule, state.phase),
|
|
755
|
+
reached: record?.reached ?? null,
|
|
756
|
+
files: state.sector.files.length,
|
|
757
|
+
residue: state.residue,
|
|
758
|
+
stalled:
|
|
759
|
+
rule.staleAfter !== null &&
|
|
760
|
+
residueTotal > 0 &&
|
|
761
|
+
clock !== null &&
|
|
762
|
+
policy.now - Date.parse(clock) > rule.staleAfter,
|
|
763
|
+
};
|
|
764
|
+
}),
|
|
765
|
+
legacy: {
|
|
766
|
+
files: evaluation.index.legacy.length,
|
|
767
|
+
holdouts:
|
|
768
|
+
legacy === undefined ? 0 : Object.values(legacy.residue).reduce((a, b) => a + b, 0),
|
|
769
|
+
},
|
|
770
|
+
plan: report.plan,
|
|
771
|
+
stalled: report.stalled,
|
|
772
|
+
complete: report.complete,
|
|
773
|
+
onComplete: rule.onComplete,
|
|
774
|
+
ledgered: ledgers.every((one) => one !== undefined),
|
|
775
|
+
};
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
const percent = (fraction: number): string => `${String(Math.round(fraction * 100))}%`;
|
|
779
|
+
|
|
780
|
+
// The status table: one row per campaign, its phase distribution beneath
|
|
781
|
+
// when it has phases, its objectives beneath that. Stalled and complete
|
|
782
|
+
// first, then by progress.
|
|
783
|
+
export const renderCampaignRows = (
|
|
784
|
+
campaigns: ReadonlyArray<SnapshotCampaign>,
|
|
785
|
+
): ReadonlyArray<string> => {
|
|
786
|
+
const state = (one: SnapshotCampaign): string =>
|
|
787
|
+
!one.ledgered ? "no ledger" : one.complete ? "complete" : one.stalled ? "stalled" : "";
|
|
788
|
+
const ordered = [...campaigns].sort((left, right) => {
|
|
789
|
+
const rank = (one: SnapshotCampaign): number =>
|
|
790
|
+
one.stalled ? 0 : one.complete && one.ledgered ? 1 : 2;
|
|
791
|
+
const byRank = rank(left) - rank(right);
|
|
792
|
+
return byRank !== 0 ? byRank : left.progress - right.progress;
|
|
793
|
+
});
|
|
794
|
+
return ordered.flatMap((one) => {
|
|
795
|
+
const width = Math.max(0, ...one.objectives.map((objective) => objective.id.length));
|
|
796
|
+
return [
|
|
797
|
+
` ${one.id} ${percent(one.progress).padStart(4)} ${String(one.count).padStart(5)} left` +
|
|
798
|
+
(one.owner === undefined ? "" : ` ${one.owner}`) +
|
|
799
|
+
(state(one) === "" ? "" : ` ${state(one)}`),
|
|
800
|
+
...(one.phases.length === 0
|
|
801
|
+
? []
|
|
802
|
+
: [
|
|
803
|
+
` phases: ${one.phases
|
|
804
|
+
.map(
|
|
805
|
+
(phase) => `${phase.id}${phase.defined ? "" : " (open)"} ${String(phase.sectors)}`,
|
|
806
|
+
)
|
|
807
|
+
.join(" → ")}` +
|
|
808
|
+
(one.legacy.files > 0 ? ` · legacy ${count(one.legacy.files, "file")}` : ""),
|
|
809
|
+
]),
|
|
810
|
+
...one.objectives.map(
|
|
811
|
+
(objective) =>
|
|
812
|
+
` ${objective.id.padEnd(width)} ${percent(objective.progress).padStart(4)} ${String(objective.count).padStart(5)} left` +
|
|
813
|
+
` ${String(objective.cleared)} cleared ${String(objective.allowed)} conceded` +
|
|
814
|
+
(objective.closed > 0 ? ` ${String(objective.closed)} closed` : "") +
|
|
815
|
+
(objective.ledgered ? "" : " no ledger"),
|
|
816
|
+
),
|
|
817
|
+
];
|
|
818
|
+
});
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
// ---------------------------------------------------------------------------
|
|
822
|
+
// Writing the ledgers
|
|
823
|
+
|
|
824
|
+
const writeJson = (repoRoot: string, at: string, text: string): void => {
|
|
825
|
+
const absolute = path.resolve(repoRoot, at);
|
|
826
|
+
mkdirSync(path.dirname(absolute), { recursive: true });
|
|
827
|
+
writeFileSync(absolute, text);
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
// The author of a concession: `--by`, else git's user.email, else the
|
|
831
|
+
// GIT_AUTHOR_EMAIL the environment carries. Without one the record is
|
|
832
|
+
// refused rather than written blank, since the record is the point.
|
|
833
|
+
export const authorOf = (given: string | undefined): string | null => {
|
|
834
|
+
if (given !== undefined && given !== "") return given;
|
|
835
|
+
try {
|
|
836
|
+
const email = execFileSync("git", ["config", "user.email"], {
|
|
837
|
+
encoding: "utf8",
|
|
838
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
839
|
+
}).trim();
|
|
840
|
+
if (email !== "") return email;
|
|
841
|
+
} catch {
|
|
842
|
+
// git absent, or no email configured
|
|
843
|
+
}
|
|
844
|
+
const fromEnvironment = process.env.GIT_AUTHOR_EMAIL;
|
|
845
|
+
return fromEnvironment === undefined || fromEnvironment === "" ? null : fromEnvironment;
|
|
846
|
+
};
|
|
847
|
+
|
|
848
|
+
export type ClearOutcome = {
|
|
849
|
+
readonly campaign: string;
|
|
850
|
+
readonly objective: string;
|
|
851
|
+
readonly cleared: number;
|
|
852
|
+
readonly rewritten: number;
|
|
853
|
+
readonly closed: number;
|
|
854
|
+
readonly entered: ReadonlyArray<string>;
|
|
855
|
+
readonly rebaselined: ReadonlyArray<string>;
|
|
856
|
+
readonly left: number;
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
// `clear`: the ledger reconciled with the code wherever that is not a
|
|
860
|
+
// regression. Stale holdouts leave and drifted ones are rewritten; a sector
|
|
861
|
+
// newly in an objective's window is recorded with its initial; a sector
|
|
862
|
+
// past a window has its holdouts closed; a phase concession authorizes a
|
|
863
|
+
// re-baseline of the sectors in that phase's window; the furthest phase
|
|
864
|
+
// each sector has reached is recorded, and so is the plan. Unrecorded
|
|
865
|
+
// growth is left where it is — `concede` is the one way it enters.
|
|
866
|
+
export const clear = (
|
|
867
|
+
policy: LoadedPolicy,
|
|
868
|
+
evaluation: CampaignEvaluation,
|
|
869
|
+
only: string | null,
|
|
870
|
+
by: string,
|
|
871
|
+
): ReadonlyArray<ClearOutcome> => {
|
|
872
|
+
const { rule } = evaluation;
|
|
873
|
+
const counted = hitsInWindow(evaluation);
|
|
874
|
+
const plan = planDiffOf(rule, campaignsOf(policy).plans.get(rule.id));
|
|
875
|
+
const outcomes: Array<ClearOutcome> = [];
|
|
876
|
+
for (const objective of rule.objectives) {
|
|
877
|
+
if (only !== null && objective.id !== only) continue;
|
|
878
|
+
const before =
|
|
879
|
+
ledgerOf(policy, rule, objective) ?? EMPTY_LEDGER(rule.id, objective.id, policy.now);
|
|
880
|
+
let ledger = before;
|
|
881
|
+
const entered: Array<string> = [];
|
|
882
|
+
const rebaselined: Array<string> = [];
|
|
883
|
+
const phase = rule.phases.find((one) => one.objectives.includes(objective.id));
|
|
884
|
+
// The phase naming the objective, when it changed with a receipt.
|
|
885
|
+
const receipted =
|
|
886
|
+
phase !== undefined && plan.changed.includes(phase.id) && !plan.unreceipted.includes(phase.id)
|
|
887
|
+
? phase
|
|
888
|
+
: null;
|
|
889
|
+
let rewritten = 0;
|
|
890
|
+
for (const [name, state] of evaluation.sectors) {
|
|
891
|
+
const inWindow = state.inWindow.some((one) => one.id === objective.id);
|
|
892
|
+
const entries = entriesOf(counted, objective.id, name);
|
|
893
|
+
if (!inWindow) {
|
|
894
|
+
ledger = clearedSector(ledger, name, entries, objective.unit, policy.now, "outside");
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
if (ledger.sectors[name] === undefined) entered.push(name);
|
|
898
|
+
else rewritten += reconcileSector(ledger, name, entries, objective.unit).drifted.length;
|
|
899
|
+
if (receipted !== null && ledger.sectors[name] !== undefined) {
|
|
900
|
+
const concession = receipted.concessions.at(-1);
|
|
901
|
+
const next = rebaselinedSector(ledger, name, entries, {
|
|
902
|
+
at: policy.now,
|
|
903
|
+
by,
|
|
904
|
+
reason: `phase ${receipted.id} changed: ${concession?.reason ?? ""}`,
|
|
905
|
+
});
|
|
906
|
+
if (next !== ledger) rebaselined.push(name);
|
|
907
|
+
ledger = next;
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
ledger = clearedSector(ledger, name, entries, objective.unit, policy.now, "inside");
|
|
911
|
+
}
|
|
912
|
+
// Sectors the code no longer births are past every window.
|
|
913
|
+
for (const name of Object.keys(ledger.sectors)) {
|
|
914
|
+
if (!evaluation.sectors.has(name)) {
|
|
915
|
+
ledger = clearedSector(ledger, name, [], objective.unit, policy.now, "outside");
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
const sum = (one: Ledger, pick: (sector: Ledger["sectors"][string]) => number): number =>
|
|
919
|
+
Object.values(one.sectors).reduce((total, sector) => total + pick(sector), 0);
|
|
920
|
+
outcomes.push({
|
|
921
|
+
campaign: rule.id,
|
|
922
|
+
objective: objective.id,
|
|
923
|
+
cleared: sum(ledger, (one) => one.cleared) - sum(before, (one) => one.cleared),
|
|
924
|
+
rewritten,
|
|
925
|
+
closed: sum(ledger, (one) => one.closed) - sum(before, (one) => one.closed),
|
|
926
|
+
entered,
|
|
927
|
+
rebaselined,
|
|
928
|
+
left: sum(ledger, (one) => one.holdouts.length),
|
|
929
|
+
});
|
|
930
|
+
if (ledger !== before || ledgerOf(policy, rule, objective) === undefined) {
|
|
931
|
+
writeJson(
|
|
932
|
+
policy.repoRoot,
|
|
933
|
+
ledgerPathOf(campaignsOf(policy).ledgerDir, rule.id, objective.id),
|
|
934
|
+
serializeLedger(ledger),
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
if (only === null) {
|
|
939
|
+
for (const [name, state] of evaluation.sectors) {
|
|
940
|
+
if (name === LEGACY_SECTOR) continue;
|
|
941
|
+
const before = recordOf(policy, rule, name) ?? EMPTY_SECTOR_RECORD(rule.id, name, policy.now);
|
|
942
|
+
const after = reachedRecord(before, rule, state.phase, policy.now);
|
|
943
|
+
if (after !== before || recordOf(policy, rule, name) === undefined) {
|
|
944
|
+
writeJson(
|
|
945
|
+
policy.repoRoot,
|
|
946
|
+
sectorRecordPathOf(campaignsOf(policy).ledgerDir, rule.id, name),
|
|
947
|
+
serializeSectorRecord(after),
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
writeJson(
|
|
952
|
+
policy.repoRoot,
|
|
953
|
+
planPathOf(campaignsOf(policy).ledgerDir, rule.id),
|
|
954
|
+
serializePlanRecord(planOf(rule)),
|
|
955
|
+
);
|
|
956
|
+
const legacy = campaignsOf(policy).legacyLedgers.get(rule.id);
|
|
957
|
+
if (legacy !== undefined) rmSync(path.resolve(policy.repoRoot, legacy), { force: true });
|
|
958
|
+
}
|
|
959
|
+
return outcomes;
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
export type ConcedeOutcome = {
|
|
963
|
+
readonly objective: string;
|
|
964
|
+
readonly conceded: ReadonlyArray<{ sector: string; entry: string }>;
|
|
965
|
+
readonly left: ReadonlyArray<{ sector: string; entry: string }>;
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
// `concede`: the unrecorded hits join the ledger, each sector's with a
|
|
969
|
+
// concession naming the reason and the author. `chosen` narrows to some.
|
|
970
|
+
export const concede = (
|
|
971
|
+
policy: LoadedPolicy,
|
|
972
|
+
evaluation: CampaignEvaluation,
|
|
973
|
+
objectiveId: string,
|
|
974
|
+
chosen: ReadonlyArray<string> | null,
|
|
975
|
+
sector: string | null,
|
|
976
|
+
record: { at: number; by: string; reason: string },
|
|
977
|
+
): Result.Result<ConcedeOutcome, string> => {
|
|
978
|
+
const { rule } = evaluation;
|
|
979
|
+
const objective = rule.objectives.find((one) => one.id === objectiveId);
|
|
980
|
+
if (objective === undefined)
|
|
981
|
+
return Result.fail(`no objective of ${rule.id} is named "${objectiveId}"`);
|
|
982
|
+
let ledger = ledgerOf(policy, rule, objective);
|
|
983
|
+
if (ledger === undefined) {
|
|
984
|
+
return Result.fail(
|
|
985
|
+
`objective ${rule.id}/${objectiveId} has no ledger yet; run \`objectives clear ${rule.id}\` first.`,
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
const counted = hitsInWindow(evaluation);
|
|
989
|
+
const unrecorded: Array<{ sector: string; entry: string }> = [];
|
|
990
|
+
for (const [name, state] of evaluation.sectors) {
|
|
991
|
+
if (!state.inWindow.some((one) => one.id === objective.id)) continue;
|
|
992
|
+
if (sector !== null && name !== sector) continue;
|
|
993
|
+
const entries = entriesOf(counted, objective.id, name);
|
|
994
|
+
for (const entry of reconcileSector(ledger, name, entries, objective.unit).unrecorded) {
|
|
995
|
+
unrecorded.push({ sector: name, entry });
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
const wanted =
|
|
999
|
+
chosen === null
|
|
1000
|
+
? unrecorded
|
|
1001
|
+
: unrecorded.filter(
|
|
1002
|
+
(one) => chosen.includes(one.entry) || chosen.includes(`${one.sector}:${one.entry}`),
|
|
1003
|
+
);
|
|
1004
|
+
if (chosen !== null) {
|
|
1005
|
+
const unknown = chosen.filter(
|
|
1006
|
+
(one) => !unrecorded.some((two) => two.entry === one || `${two.sector}:${two.entry}` === one),
|
|
1007
|
+
);
|
|
1008
|
+
if (unknown.length > 0) {
|
|
1009
|
+
return Result.fail(
|
|
1010
|
+
`these are not unrecorded hits of ${rule.id}/${objectiveId}: ${unknown.join(", ")}`,
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
const bySector = new Map<string, Array<string>>();
|
|
1015
|
+
for (const one of wanted)
|
|
1016
|
+
bySector.set(one.sector, [...(bySector.get(one.sector) ?? []), one.entry]);
|
|
1017
|
+
for (const [name, entries] of bySector) ledger = concededSector(ledger, name, entries, record);
|
|
1018
|
+
if (wanted.length > 0) {
|
|
1019
|
+
writeJson(
|
|
1020
|
+
policy.repoRoot,
|
|
1021
|
+
ledgerPathOf(campaignsOf(policy).ledgerDir, rule.id, objective.id),
|
|
1022
|
+
serializeLedger(ledger),
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
return Result.succeed({
|
|
1026
|
+
objective: objectiveId,
|
|
1027
|
+
conceded: wanted,
|
|
1028
|
+
left: unrecorded.filter((one) => !wanted.includes(one)),
|
|
1029
|
+
});
|
|
1030
|
+
};
|
|
1031
|
+
|
|
1032
|
+
// `attest`: a step no detector sees is recorded done for a sector — with a
|
|
1033
|
+
// reason and evidence — and only while the sector stands at that phase, so
|
|
1034
|
+
// it cannot be recorded ahead and passed through on arrival.
|
|
1035
|
+
export const attest = (
|
|
1036
|
+
policy: LoadedPolicy,
|
|
1037
|
+
evaluation: CampaignEvaluation,
|
|
1038
|
+
sector: string,
|
|
1039
|
+
phaseId: string,
|
|
1040
|
+
entry: { reason: string; evidence: string | undefined; by: string },
|
|
1041
|
+
): Result.Result<string, string> => {
|
|
1042
|
+
const { rule } = evaluation;
|
|
1043
|
+
const state = evaluation.sectors.get(sector);
|
|
1044
|
+
if (state === undefined)
|
|
1045
|
+
return Result.fail(`campaign ${rule.id} has no sector named "${sector}"`);
|
|
1046
|
+
const phase = rule.phases.find((one) => one.id === phaseId);
|
|
1047
|
+
if (phase === undefined) return Result.fail(`campaign ${rule.id} has no phase "${phaseId}"`);
|
|
1048
|
+
if (!phase.attested)
|
|
1049
|
+
return Result.fail(
|
|
1050
|
+
`phase ${phaseId} is not an attested phase; its objectives decide when a sector leaves it.`,
|
|
1051
|
+
);
|
|
1052
|
+
const current = phaseIdOf(rule, state.phase);
|
|
1053
|
+
if (current !== phaseId) {
|
|
1054
|
+
return Result.fail(
|
|
1055
|
+
`sector ${sector} stands at ${current ?? "the end"}, not ${phaseId}. An attestation is accepted only while the sector is at that phase.`,
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
const before = recordOf(policy, rule, sector) ?? EMPTY_SECTOR_RECORD(rule.id, sector, policy.now);
|
|
1059
|
+
const after = attestedRecord(before, {
|
|
1060
|
+
phase: phaseId,
|
|
1061
|
+
reason: entry.reason,
|
|
1062
|
+
...(entry.evidence === undefined ? {} : { evidence: entry.evidence }),
|
|
1063
|
+
at: policy.now,
|
|
1064
|
+
by: entry.by,
|
|
1065
|
+
});
|
|
1066
|
+
writeJson(
|
|
1067
|
+
policy.repoRoot,
|
|
1068
|
+
sectorRecordPathOf(campaignsOf(policy).ledgerDir, rule.id, sector),
|
|
1069
|
+
serializeSectorRecord(after),
|
|
1070
|
+
);
|
|
1071
|
+
return Result.succeed(sectorRecordPathOf(campaignsOf(policy).ledgerDir, rule.id, sector));
|
|
1072
|
+
};
|
|
1073
|
+
|
|
1074
|
+
// `note`: a dated remark for the next person or agent to touch the sector,
|
|
1075
|
+
// recorded under the phase it was left at.
|
|
1076
|
+
export const note = (
|
|
1077
|
+
policy: LoadedPolicy,
|
|
1078
|
+
evaluation: CampaignEvaluation,
|
|
1079
|
+
sector: string,
|
|
1080
|
+
text: string,
|
|
1081
|
+
by: string,
|
|
1082
|
+
): Result.Result<string, string> => {
|
|
1083
|
+
const { rule } = evaluation;
|
|
1084
|
+
const state = evaluation.sectors.get(sector);
|
|
1085
|
+
if (state === undefined)
|
|
1086
|
+
return Result.fail(`campaign ${rule.id} has no sector named "${sector}"`);
|
|
1087
|
+
const before = recordOf(policy, rule, sector) ?? EMPTY_SECTOR_RECORD(rule.id, sector, policy.now);
|
|
1088
|
+
const after = notedRecord(before, {
|
|
1089
|
+
phase: phaseIdOf(rule, state.phase),
|
|
1090
|
+
text,
|
|
1091
|
+
by,
|
|
1092
|
+
at: policy.now,
|
|
1093
|
+
});
|
|
1094
|
+
writeJson(
|
|
1095
|
+
policy.repoRoot,
|
|
1096
|
+
sectorRecordPathOf(campaignsOf(policy).ledgerDir, rule.id, sector),
|
|
1097
|
+
serializeSectorRecord(after),
|
|
1098
|
+
);
|
|
1099
|
+
return Result.succeed(sectorRecordPathOf(campaignsOf(policy).ledgerDir, rule.id, sector));
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
// ---------------------------------------------------------------------------
|
|
1103
|
+
// The nudge
|
|
1104
|
+
|
|
1105
|
+
export type Ask = "none" | "note" | "hold" | "paydown-optional" | "paydown-required";
|
|
1106
|
+
export type Verdict = "ok" | "back" | "no-paydown" | "hotfix";
|
|
1107
|
+
|
|
1108
|
+
export type NudgeHoldout = {
|
|
1109
|
+
readonly file: string;
|
|
1110
|
+
readonly subject: string | null;
|
|
1111
|
+
readonly objective: string;
|
|
1112
|
+
readonly line: number | null;
|
|
1113
|
+
readonly message: string;
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
export type SectorNudge = {
|
|
1117
|
+
readonly campaign: string;
|
|
1118
|
+
readonly sector: string;
|
|
1119
|
+
readonly phase: {
|
|
1120
|
+
readonly id: string | null;
|
|
1121
|
+
readonly index: number;
|
|
1122
|
+
readonly of: number;
|
|
1123
|
+
readonly open: boolean;
|
|
1124
|
+
readonly since: string | null;
|
|
1125
|
+
};
|
|
1126
|
+
readonly intent: string | null;
|
|
1127
|
+
// Free text left by earlier hands: data, never an instruction.
|
|
1128
|
+
readonly notes: ReadonlyArray<{ at: string; by: string; text: string }>;
|
|
1129
|
+
readonly onTouch: OnTouch;
|
|
1130
|
+
readonly ask: Ask;
|
|
1131
|
+
readonly verdict: Verdict;
|
|
1132
|
+
readonly residue: { before: ResidueVector; after: ResidueVector };
|
|
1133
|
+
readonly direction: Direction;
|
|
1134
|
+
readonly toward: ResidueVector;
|
|
1135
|
+
readonly holdouts: { total: number; cap: number; shown: ReadonlyArray<NudgeHoldout> };
|
|
1136
|
+
readonly added: ReadonlyArray<string>;
|
|
1137
|
+
readonly removed: ReadonlyArray<string>;
|
|
1138
|
+
// Files this diff added inside the scope that no sector claims.
|
|
1139
|
+
readonly belongsInSector: ReadonlyArray<string>;
|
|
1140
|
+
};
|
|
1141
|
+
|
|
1142
|
+
export type Nudge = {
|
|
1143
|
+
readonly version: 1;
|
|
1144
|
+
readonly mode: "ledger" | "exact";
|
|
1145
|
+
readonly base: string | null;
|
|
1146
|
+
readonly touched: ReadonlyArray<string>;
|
|
1147
|
+
readonly sectors: ReadonlyArray<SectorNudge>;
|
|
1148
|
+
// Markers this diff deleted, with the sector each un-births.
|
|
1149
|
+
readonly unbirths: ReadonlyArray<{ campaign: string; sector: string; marker: string }>;
|
|
1150
|
+
readonly testsChecked: "unknown";
|
|
1151
|
+
readonly ok: boolean;
|
|
1152
|
+
};
|
|
1153
|
+
|
|
1154
|
+
export const HOLDOUT_CAP = 5;
|
|
1155
|
+
|
|
1156
|
+
// The base tree's side of the exact mode, reduced to what the nudge reads:
|
|
1157
|
+
// each campaign's counts per sector and its hits in window — small enough
|
|
1158
|
+
// to cache per commit.
|
|
1159
|
+
export type BaseSide = ReadonlyArray<{
|
|
1160
|
+
readonly id: string;
|
|
1161
|
+
readonly sectors: Readonly<Record<string, Readonly<Record<string, number>>>>;
|
|
1162
|
+
readonly hits: ReadonlyArray<{ sector: string; objective: string; entry: string }>;
|
|
1163
|
+
}>;
|
|
1164
|
+
|
|
1165
|
+
export const baseSideOf = (evaluations: ReadonlyArray<CampaignEvaluation>): BaseSide =>
|
|
1166
|
+
evaluations.map((evaluation) => ({
|
|
1167
|
+
id: evaluation.rule.id,
|
|
1168
|
+
sectors: Object.fromEntries(
|
|
1169
|
+
[...evaluation.sectors.values()].map((state) => [state.name, state.counts]),
|
|
1170
|
+
),
|
|
1171
|
+
hits: hitsInWindow(evaluation).map((hit) => ({
|
|
1172
|
+
sector: hit.sector,
|
|
1173
|
+
objective: hit.objective,
|
|
1174
|
+
entry: hit.entry,
|
|
1175
|
+
})),
|
|
1176
|
+
}));
|
|
1177
|
+
|
|
1178
|
+
// The nudge for one diff. The "before" side is the ledger, or the base tree
|
|
1179
|
+
// evaluated whole in the exact mode; the "after" side is the tree now.
|
|
1180
|
+
export const nudgeOf = (
|
|
1181
|
+
policy: LoadedPolicy,
|
|
1182
|
+
evaluations: ReadonlyArray<CampaignEvaluation>,
|
|
1183
|
+
diff: Diff,
|
|
1184
|
+
base: BaseSide | null,
|
|
1185
|
+
hotfix: string | null,
|
|
1186
|
+
by: string | null,
|
|
1187
|
+
): Nudge => {
|
|
1188
|
+
const touched = [...diff.touched.keys()].sort();
|
|
1189
|
+
const sectors: Array<SectorNudge> = [];
|
|
1190
|
+
const unbirths: Array<{ campaign: string; sector: string; marker: string }> = [];
|
|
1191
|
+
let ok = true;
|
|
1192
|
+
for (const evaluation of evaluations) {
|
|
1193
|
+
const { rule } = evaluation;
|
|
1194
|
+
const baseEvaluation = base?.find((one) => one.id === rule.id) ?? null;
|
|
1195
|
+
const counted = hitsInWindow(evaluation);
|
|
1196
|
+
const perimeter = rule.perimeter;
|
|
1197
|
+
if (perimeter?.kind === "marker") {
|
|
1198
|
+
for (const file of diff.deleted) {
|
|
1199
|
+
if (!perimeter.marker.some((one) => one.test(file))) continue;
|
|
1200
|
+
const text = textAt(policy.repoRoot, diff.base ?? "HEAD", file);
|
|
1201
|
+
let name = path.basename(path.dirname(file));
|
|
1202
|
+
try {
|
|
1203
|
+
name = parseSectorMarker(text ?? "")?.name ?? name;
|
|
1204
|
+
} catch {
|
|
1205
|
+
// unreadable: the folder's name stands
|
|
1206
|
+
}
|
|
1207
|
+
unbirths.push({ campaign: rule.id, sector: name, marker: file });
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
const touchedSectors = new Set<string>();
|
|
1211
|
+
const belongs: Array<string> = [];
|
|
1212
|
+
for (const file of touched) {
|
|
1213
|
+
const sector = evaluation.index.sectorOf(file);
|
|
1214
|
+
if (sector === null) continue;
|
|
1215
|
+
touchedSectors.add(sector);
|
|
1216
|
+
if (sector === LEGACY_SECTOR && diff.added.includes(file)) belongs.push(file);
|
|
1217
|
+
}
|
|
1218
|
+
for (const name of [...touchedSectors].sort()) {
|
|
1219
|
+
const state = evaluation.sectors.get(name);
|
|
1220
|
+
if (state === undefined) continue;
|
|
1221
|
+
const record = recordOf(policy, rule, name);
|
|
1222
|
+
const phase = rule.phases[state.phase];
|
|
1223
|
+
const open = phase !== undefined && isOpenPhase(phase);
|
|
1224
|
+
const onTouch = onTouchOf(rule, state.phase);
|
|
1225
|
+
// Before: the ledger's count per objective in window, or the base tree's.
|
|
1226
|
+
const before: Record<string, number> = {};
|
|
1227
|
+
for (const objective of state.inWindow) {
|
|
1228
|
+
if (baseEvaluation !== null) {
|
|
1229
|
+
before[objective.id] = baseEvaluation.sectors[name]?.[objective.id] ?? 0;
|
|
1230
|
+
} else {
|
|
1231
|
+
before[objective.id] =
|
|
1232
|
+
ledgerOf(policy, rule, objective)?.sectors[name]?.holdouts.length ?? 0;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
const after = state.residue;
|
|
1236
|
+
const direction = compareResidue(before, after);
|
|
1237
|
+
const own = counted.filter((hit) => hit.sector === name);
|
|
1238
|
+
const inTouched = own.filter((hit) => diff.touched.has(hit.violation.file));
|
|
1239
|
+
const ranked = inTouched
|
|
1240
|
+
.map((hit) => {
|
|
1241
|
+
const hunks = diff.touched.get(hit.violation.file) ?? [];
|
|
1242
|
+
const rank = hit.range === undefined ? (hit.entry === "~" ? 2 : 1) : 0;
|
|
1243
|
+
const distance =
|
|
1244
|
+
hit.range === undefined
|
|
1245
|
+
? Number.POSITIVE_INFINITY
|
|
1246
|
+
: distanceToHunks(hunks, hit.range.start.line + 1, hit.range.end.line + 1);
|
|
1247
|
+
return { hit, rank, distance };
|
|
1248
|
+
})
|
|
1249
|
+
.sort((a, b) =>
|
|
1250
|
+
a.rank !== b.rank
|
|
1251
|
+
? a.rank - b.rank
|
|
1252
|
+
: a.distance !== b.distance
|
|
1253
|
+
? a.distance - b.distance
|
|
1254
|
+
: a.hit.entry.localeCompare(b.hit.entry),
|
|
1255
|
+
);
|
|
1256
|
+
const sectorLevel = own.filter((hit) => hit.entry === "~");
|
|
1257
|
+
const shownSource = [
|
|
1258
|
+
...ranked.map((one) => one.hit),
|
|
1259
|
+
...sectorLevel.filter((hit) => !inTouched.includes(hit)),
|
|
1260
|
+
];
|
|
1261
|
+
const shown = shownSource.slice(0, HOLDOUT_CAP).map((hit) => ({
|
|
1262
|
+
file: hit.violation.file,
|
|
1263
|
+
subject: hit.violation.subject,
|
|
1264
|
+
objective: hit.objective,
|
|
1265
|
+
line: hit.range === undefined ? null : hit.range.start.line + 1,
|
|
1266
|
+
message: hit.violation.message,
|
|
1267
|
+
}));
|
|
1268
|
+
// What this diff added and removed, among the touched files.
|
|
1269
|
+
const added: Array<string> = [];
|
|
1270
|
+
const removed: Array<string> = [];
|
|
1271
|
+
let editsHoldout = false;
|
|
1272
|
+
for (const objective of state.inWindow) {
|
|
1273
|
+
const entries = own.filter((hit) => hit.objective === objective.id).map((hit) => hit.entry);
|
|
1274
|
+
const ledger = ledgerOf(policy, rule, objective);
|
|
1275
|
+
const knownBefore =
|
|
1276
|
+
baseEvaluation === null
|
|
1277
|
+
? new Set(ledger?.sectors[name]?.holdouts ?? [])
|
|
1278
|
+
: new Set(
|
|
1279
|
+
baseEvaluation.hits
|
|
1280
|
+
.filter((hit) => hit.sector === name && hit.objective === objective.id)
|
|
1281
|
+
.map((hit) => hit.entry),
|
|
1282
|
+
);
|
|
1283
|
+
const state_ =
|
|
1284
|
+
ledger === undefined && baseEvaluation === null
|
|
1285
|
+
? {
|
|
1286
|
+
unrecorded: entries,
|
|
1287
|
+
stale: [] as ReadonlyArray<string>,
|
|
1288
|
+
ledgered: [] as ReadonlyArray<string>,
|
|
1289
|
+
}
|
|
1290
|
+
: baseEvaluation === null && ledger !== undefined
|
|
1291
|
+
? reconcileSector(ledger, name, entries, objective.unit)
|
|
1292
|
+
: {
|
|
1293
|
+
unrecorded: entries.filter((entry) => !knownBefore.has(entry)),
|
|
1294
|
+
stale: [...knownBefore].filter((entry) => !entries.includes(entry)),
|
|
1295
|
+
ledgered: entries.filter((entry) => knownBefore.has(entry)),
|
|
1296
|
+
};
|
|
1297
|
+
for (const entry of state_.unrecorded) {
|
|
1298
|
+
const hit = own.find((one) => one.objective === objective.id && one.entry === entry);
|
|
1299
|
+
if (hit !== undefined && diff.touched.has(hit.violation.file))
|
|
1300
|
+
added.push(`${objective.id}: ${entry}`);
|
|
1301
|
+
}
|
|
1302
|
+
for (const entry of state_.stale) removed.push(`${objective.id}: ${entry}`);
|
|
1303
|
+
for (const hit of inTouched) {
|
|
1304
|
+
if (hit.objective !== objective.id || hit.range === undefined) continue;
|
|
1305
|
+
if (!knownBefore.has(hit.entry) && !state_.ledgered.includes(hit.entry)) continue;
|
|
1306
|
+
const hunks = diff.touched.get(hit.violation.file) ?? [];
|
|
1307
|
+
if (distanceToHunks(hunks, hit.range.start.line + 1, hit.range.end.line + 1) === 0)
|
|
1308
|
+
editsHoldout = true;
|
|
1309
|
+
}
|
|
1310
|
+
if (state_.stale.length > 0) editsHoldout = true;
|
|
1311
|
+
}
|
|
1312
|
+
const back = worsened(before, after);
|
|
1313
|
+
const totalBefore = Object.values(before).reduce((a, b) => a + b, 0);
|
|
1314
|
+
const totalAfter = Object.values(after).reduce((a, b) => a + b, 0);
|
|
1315
|
+
let ask: Ask;
|
|
1316
|
+
let verdict: Verdict = "ok";
|
|
1317
|
+
if (open) ask = "note";
|
|
1318
|
+
else if (onTouch === "advise") ask = "none";
|
|
1319
|
+
else if (onTouch === "ratchet") {
|
|
1320
|
+
ask = "hold";
|
|
1321
|
+
if (back.length > 0) verdict = "back";
|
|
1322
|
+
} else {
|
|
1323
|
+
ask = editsHoldout ? "paydown-required" : "paydown-optional";
|
|
1324
|
+
if (back.length > 0) verdict = "back";
|
|
1325
|
+
else if (editsHoldout && totalAfter >= totalBefore) verdict = "no-paydown";
|
|
1326
|
+
}
|
|
1327
|
+
if (verdict !== "ok" && hotfix !== null && by !== null) {
|
|
1328
|
+
// The escape: the growth is conceded, with a reason naming the hotfix.
|
|
1329
|
+
for (const objective of state.inWindow) {
|
|
1330
|
+
const ledger = ledgerOf(policy, rule, objective);
|
|
1331
|
+
if (ledger === undefined) continue;
|
|
1332
|
+
const entries = own
|
|
1333
|
+
.filter((hit) => hit.objective === objective.id)
|
|
1334
|
+
.map((hit) => hit.entry);
|
|
1335
|
+
const { unrecorded } = reconcileSector(ledger, name, entries, objective.unit);
|
|
1336
|
+
if (unrecorded.length === 0) continue;
|
|
1337
|
+
writeJson(
|
|
1338
|
+
policy.repoRoot,
|
|
1339
|
+
ledgerPathOf(campaignsOf(policy).ledgerDir, rule.id, objective.id),
|
|
1340
|
+
serializeLedger(
|
|
1341
|
+
concededSector(ledger, name, unrecorded, {
|
|
1342
|
+
at: policy.now,
|
|
1343
|
+
by,
|
|
1344
|
+
reason: `hotfix: ${hotfix}`,
|
|
1345
|
+
}),
|
|
1346
|
+
),
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
verdict = "hotfix";
|
|
1350
|
+
}
|
|
1351
|
+
if (verdict !== "ok" && verdict !== "hotfix") ok = false;
|
|
1352
|
+
sectors.push({
|
|
1353
|
+
campaign: rule.id,
|
|
1354
|
+
sector: name,
|
|
1355
|
+
phase: {
|
|
1356
|
+
id: phaseIdOf(rule, state.phase),
|
|
1357
|
+
index: state.phase,
|
|
1358
|
+
of: rule.phases.length,
|
|
1359
|
+
open,
|
|
1360
|
+
since: record?.since ?? null,
|
|
1361
|
+
},
|
|
1362
|
+
intent: phase?.intent ?? null,
|
|
1363
|
+
notes: (record?.notes ?? [])
|
|
1364
|
+
.filter((one) => one.phase === phaseIdOf(rule, state.phase))
|
|
1365
|
+
.map((one) => ({ at: one.at, by: one.by, text: one.text })),
|
|
1366
|
+
onTouch,
|
|
1367
|
+
ask,
|
|
1368
|
+
verdict,
|
|
1369
|
+
residue: { before, after },
|
|
1370
|
+
direction,
|
|
1371
|
+
toward: towardNextOf(rule, state),
|
|
1372
|
+
holdouts: { total: own.length, cap: HOLDOUT_CAP, shown },
|
|
1373
|
+
added: added.sort(),
|
|
1374
|
+
removed: removed.sort(),
|
|
1375
|
+
belongsInSector: name === LEGACY_SECTOR ? belongs.sort() : [],
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
return {
|
|
1380
|
+
version: 1,
|
|
1381
|
+
mode: base === null ? "ledger" : "exact",
|
|
1382
|
+
base: diff.base,
|
|
1383
|
+
touched,
|
|
1384
|
+
sectors,
|
|
1385
|
+
unbirths,
|
|
1386
|
+
testsChecked: "unknown",
|
|
1387
|
+
ok,
|
|
1388
|
+
};
|
|
1389
|
+
};
|
|
1390
|
+
|
|
1391
|
+
const days = (from: string | null, now: number): string => {
|
|
1392
|
+
if (from === null) return "";
|
|
1393
|
+
const elapsed = Math.floor((now - Date.parse(from)) / 86_400_000);
|
|
1394
|
+
return `, ${count(elapsed, "day")} here`;
|
|
1395
|
+
};
|
|
1396
|
+
|
|
1397
|
+
const describeAsk = (ask: Ask): string => {
|
|
1398
|
+
switch (ask) {
|
|
1399
|
+
case "none":
|
|
1400
|
+
return "none — the holdouts above are context; paydown is welcome in its own commit, not asked for";
|
|
1401
|
+
case "note":
|
|
1402
|
+
return "note";
|
|
1403
|
+
case "hold":
|
|
1404
|
+
return "hold — leave the sector no worse; paydown is welcome in its own commit, not asked for";
|
|
1405
|
+
case "paydown-optional":
|
|
1406
|
+
return "paydown-optional — this diff edits no holdout; paydown is welcome in its own commit";
|
|
1407
|
+
case "paydown-required":
|
|
1408
|
+
return "paydown-required — this diff edits a holdout, so the sector must leave with fewer";
|
|
1409
|
+
}
|
|
1410
|
+
};
|
|
1411
|
+
|
|
1412
|
+
export const renderNudge = (nudge: Nudge, now: number): ReadonlyArray<string> => {
|
|
1413
|
+
if (nudge.sectors.length === 0 && nudge.unbirths.length === 0) {
|
|
1414
|
+
return [
|
|
1415
|
+
`nothing you touched is under a campaign (${count(nudge.touched.length, "file")} in the diff).`,
|
|
1416
|
+
];
|
|
1417
|
+
}
|
|
1418
|
+
const lines: Array<string> = [];
|
|
1419
|
+
const say = (...more: ReadonlyArray<string>): void => {
|
|
1420
|
+
for (const one of more) lines.push(one);
|
|
1421
|
+
};
|
|
1422
|
+
const residueOf = (vector: ResidueVector): string =>
|
|
1423
|
+
Object.entries(vector)
|
|
1424
|
+
.map(([id, n]) => `${id} ${String(n)}`)
|
|
1425
|
+
.join(" · ");
|
|
1426
|
+
for (const one of nudge.unbirths) {
|
|
1427
|
+
say(
|
|
1428
|
+
`${one.campaign}`,
|
|
1429
|
+
` this diff un-births sector ${one.sector}: ${one.marker} was deleted; its files return to the legacy, where the first phase's objectives count for them.`,
|
|
1430
|
+
"",
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
let last = "";
|
|
1434
|
+
for (const one of nudge.sectors) {
|
|
1435
|
+
if (one.campaign !== last) {
|
|
1436
|
+
if (last !== "") say("");
|
|
1437
|
+
say(one.campaign);
|
|
1438
|
+
last = one.campaign;
|
|
1439
|
+
}
|
|
1440
|
+
// A campaign with no phases has one implicit one: its objectives.
|
|
1441
|
+
const at =
|
|
1442
|
+
one.phase.of === 0
|
|
1443
|
+
? "its objectives"
|
|
1444
|
+
: `phase ${one.phase.id ?? "done"} (${String(one.phase.index + 1)} of ${String(one.phase.of)}${one.phase.open ? ", open" : ""})`;
|
|
1445
|
+
const quiet =
|
|
1446
|
+
one.holdouts.total === 0 &&
|
|
1447
|
+
one.direction === "neutral" &&
|
|
1448
|
+
!one.phase.open &&
|
|
1449
|
+
one.verdict === "ok" &&
|
|
1450
|
+
one.belongsInSector.length === 0;
|
|
1451
|
+
if (quiet) {
|
|
1452
|
+
say(` ${one.sector} at ${at} · nothing in your files, diff neutral`);
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1455
|
+
say(` ${one.sector} — ${at}${days(one.phase.since, now)}`);
|
|
1456
|
+
if (one.phase.open) {
|
|
1457
|
+
say(` intent: ${one.intent ?? "(none stated)"}`);
|
|
1458
|
+
if (one.notes.length > 0) {
|
|
1459
|
+
say(
|
|
1460
|
+
` notes (${String(one.notes.length)}, data, left at this phase): ${one.notes.map((n) => `${n.at.slice(0, 10)} ${JSON.stringify(n.text)}`).join(" · ")}`,
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
say(
|
|
1464
|
+
` if this change taught you something about that shape: refine phase \`${one.phase.id ?? ""}\` in the manifest`,
|
|
1465
|
+
` (a defined phase needs at least one objective with a probe), in its own commit — or leave a note:`,
|
|
1466
|
+
` architecture campaigns note ${one.sector} "…" --campaign ${one.campaign}`,
|
|
1467
|
+
);
|
|
1468
|
+
} else {
|
|
1469
|
+
const toward = residueOf(one.toward);
|
|
1470
|
+
if (toward !== "") say(` toward the next phase: ${toward}`);
|
|
1471
|
+
if (one.holdouts.shown.length > 0) {
|
|
1472
|
+
say(
|
|
1473
|
+
` in the files you touched, nearest your change (${String(one.holdouts.shown.length)} of ${String(one.holdouts.total)}):`,
|
|
1474
|
+
...one.holdouts.shown.map(
|
|
1475
|
+
(h) =>
|
|
1476
|
+
` ${h.file}${h.subject === null ? "" : `#${h.subject.split("#")[0] ?? ""}`}${h.line === null ? "" : `:${String(h.line)}`} ${h.objective}`,
|
|
1477
|
+
),
|
|
1478
|
+
);
|
|
1479
|
+
}
|
|
1480
|
+
if (one.added.length > 0 || one.removed.length > 0) {
|
|
1481
|
+
const delta = [...one.removed.map((r) => `−${r}`), ...one.added.map((a) => `+${a}`)].join(
|
|
1482
|
+
" · ",
|
|
1483
|
+
);
|
|
1484
|
+
say(` this diff: ${delta} ${one.direction}`);
|
|
1485
|
+
} else if (one.direction !== "neutral") {
|
|
1486
|
+
say(
|
|
1487
|
+
` this diff: ${residueOf(one.residue.before)} → ${residueOf(one.residue.after)} ${one.direction}`,
|
|
1488
|
+
);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
for (const file of one.belongsInSector)
|
|
1492
|
+
say(` ${file} landed in the legacy inside the scope: this belongs in a sector`);
|
|
1493
|
+
say(` onTouch: ${one.onTouch} — ${one.verdict}`);
|
|
1494
|
+
say(` ask: ${describeAsk(one.ask)}`);
|
|
1495
|
+
}
|
|
1496
|
+
say("", `testsChecked: unknown · ${nudge.ok ? "ok" : "not ok"}`);
|
|
1497
|
+
return lines;
|
|
1498
|
+
};
|
|
1499
|
+
|
|
1500
|
+
// ---------------------------------------------------------------------------
|
|
1501
|
+
// History
|
|
1502
|
+
|
|
1503
|
+
export type HistoryRow = {
|
|
1504
|
+
readonly sha: string;
|
|
1505
|
+
readonly at: string;
|
|
1506
|
+
readonly subject: string;
|
|
1507
|
+
// Holdouts per objective, as the ledgers stood after the commit.
|
|
1508
|
+
readonly counts: Readonly<Record<string, number>>;
|
|
1509
|
+
readonly planChanged: boolean;
|
|
1510
|
+
};
|
|
1511
|
+
|
|
1512
|
+
// The series replayed from the ledgers' and the manifest's git history:
|
|
1513
|
+
// a step function of human actions — every `clear`, `concede`, `attest`,
|
|
1514
|
+
// `note` and plan edit — annotated where the plan changed.
|
|
1515
|
+
export const historyOf = (
|
|
1516
|
+
policy: LoadedPolicy,
|
|
1517
|
+
rule: CompiledCampaign,
|
|
1518
|
+
since: string | null,
|
|
1519
|
+
manifestPaths: ReadonlyArray<string>,
|
|
1520
|
+
): ReadonlyArray<HistoryRow> => {
|
|
1521
|
+
const dir = `${campaignsOf(policy).ledgerDir}/${rule.id}`;
|
|
1522
|
+
const legacy = `${campaignsOf(policy).ledgerDir}/${rule.id}.json`;
|
|
1523
|
+
const commits = commitsTouching(policy.repoRoot, [dir, legacy, ...manifestPaths], since);
|
|
1524
|
+
return commits.map((commit: Commit) => {
|
|
1525
|
+
const counts: Record<string, number> = {};
|
|
1526
|
+
for (const objective of rule.objectives) {
|
|
1527
|
+
const text =
|
|
1528
|
+
textAt(
|
|
1529
|
+
policy.repoRoot,
|
|
1530
|
+
commit.sha,
|
|
1531
|
+
ledgerPathOf(campaignsOf(policy).ledgerDir, rule.id, objective.id),
|
|
1532
|
+
) ?? (rule.objectives.length === 1 ? textAt(policy.repoRoot, commit.sha, legacy) : null);
|
|
1533
|
+
if (text === null) continue;
|
|
1534
|
+
try {
|
|
1535
|
+
const raw = JSON.parse(text) as {
|
|
1536
|
+
sectors?: Record<string, { holdouts?: Array<unknown> }>;
|
|
1537
|
+
entries?: Array<unknown>;
|
|
1538
|
+
};
|
|
1539
|
+
counts[objective.id] =
|
|
1540
|
+
raw.entries?.length ??
|
|
1541
|
+
Object.values(raw.sectors ?? {}).reduce(
|
|
1542
|
+
(sum, one) => sum + (one.holdouts?.length ?? 0),
|
|
1543
|
+
0,
|
|
1544
|
+
);
|
|
1545
|
+
} catch {
|
|
1546
|
+
// an unreadable ledger at that commit contributes nothing
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
return {
|
|
1550
|
+
sha: commit.sha.slice(0, 10),
|
|
1551
|
+
at: commit.at,
|
|
1552
|
+
subject: commit.subject,
|
|
1553
|
+
counts,
|
|
1554
|
+
planChanged: commit.files.some((file) => manifestPaths.includes(file)),
|
|
1555
|
+
};
|
|
1556
|
+
});
|
|
1557
|
+
};
|
|
1558
|
+
|
|
1559
|
+
export const renderHistory = (
|
|
1560
|
+
rule: CompiledCampaign,
|
|
1561
|
+
rows: ReadonlyArray<HistoryRow>,
|
|
1562
|
+
): ReadonlyArray<string> => {
|
|
1563
|
+
if (rows.length === 0)
|
|
1564
|
+
return [`${rule.id}: no history — no commit has touched its ledgers or the manifest.`];
|
|
1565
|
+
const width = Math.max(...rule.objectives.map((one) => one.id.length), 4);
|
|
1566
|
+
return [
|
|
1567
|
+
`${rule.id}: ${count(rows.length, "commit")}`,
|
|
1568
|
+
"",
|
|
1569
|
+
` ${"when".padEnd(10)} ${"sha".padEnd(10)} ${rule.objectives.map((one) => one.id.padStart(width)).join(" ")} plan`,
|
|
1570
|
+
...rows.map(
|
|
1571
|
+
(row) =>
|
|
1572
|
+
` ${row.at.slice(0, 10)} ${row.sha} ${rule.objectives
|
|
1573
|
+
.map((one) => String(row.counts[one.id] ?? "·").padStart(width))
|
|
1574
|
+
.join(" ")} ${row.planChanged ? "changed" : ""} ${row.subject}`,
|
|
1575
|
+
),
|
|
1576
|
+
];
|
|
1577
|
+
};
|
|
1578
|
+
|
|
1579
|
+
// ---------------------------------------------------------------------------
|
|
1580
|
+
// The explain paragraph
|
|
1581
|
+
|
|
1582
|
+
export const explainCampaignLines = (
|
|
1583
|
+
policy: LoadedPolicy,
|
|
1584
|
+
evaluation: CampaignEvaluation,
|
|
1585
|
+
file: string,
|
|
1586
|
+
): ReadonlyArray<string> => {
|
|
1587
|
+
const { rule } = evaluation;
|
|
1588
|
+
const sector = evaluation.index.sectorOf(file);
|
|
1589
|
+
if (sector === null) return [];
|
|
1590
|
+
const state = evaluation.sectors.get(sector);
|
|
1591
|
+
if (state === undefined) return [];
|
|
1592
|
+
const phase = rule.phases[state.phase];
|
|
1593
|
+
const at =
|
|
1594
|
+
rule.phases.length === 0
|
|
1595
|
+
? ""
|
|
1596
|
+
: ` — phase ${phase?.id ?? "done"} (${String(state.phase + 1)} of ${String(rule.phases.length)}${phase !== undefined && isOpenPhase(phase) ? ", open" : ""})`;
|
|
1597
|
+
const own = hitsInWindow(evaluation)
|
|
1598
|
+
.filter((hit) => hit.sector === sector && hit.violation.file === file)
|
|
1599
|
+
.sort((a, b) => (a.range?.start.line ?? 0) - (b.range?.start.line ?? 0));
|
|
1600
|
+
const firing = new Set(own.map((hit) => hit.objective));
|
|
1601
|
+
const record = recordOf(policy, rule, sector);
|
|
1602
|
+
return [
|
|
1603
|
+
` ${rule.name}: sector ${sector}${at}${record?.reached === undefined || record.reached === null ? "" : `, reached ${record.reached}`}`,
|
|
1604
|
+
` in window: ${state.inWindow.length === 0 ? "(nothing)" : state.inWindow.map((one) => `${one.id}${firing.has(one.id) ? " ✗" : ""}`).join(", ")}`,
|
|
1605
|
+
...(own.length === 0
|
|
1606
|
+
? []
|
|
1607
|
+
: [
|
|
1608
|
+
` nearest holdouts in this file:`,
|
|
1609
|
+
...own
|
|
1610
|
+
.slice(0, HOLDOUT_CAP)
|
|
1611
|
+
.map(
|
|
1612
|
+
(hit) =>
|
|
1613
|
+
` ${hit.range === undefined ? "" : `:${String(hit.range.start.line + 1)} `}${hit.objective}${hit.violation.subject === null ? "" : ` ${hit.violation.subject}`}`,
|
|
1614
|
+
),
|
|
1615
|
+
]),
|
|
1616
|
+
];
|
|
1617
|
+
};
|
|
1618
|
+
|
|
1619
|
+
// ---------------------------------------------------------------------------
|
|
1620
|
+
// The base tree, for the exact mode
|
|
1621
|
+
|
|
1622
|
+
// The campaigns evaluated over the tree at `ref`, through a policy loaded
|
|
1623
|
+
// from that tree, so `imports`, `requires`, `report` and `fn` terms say what
|
|
1624
|
+
// the base tree said. A full tool run for a `report`-backed objective, so
|
|
1625
|
+
// the answer is cached per commit under `node_modules/.cache/goodbones/`.
|
|
1626
|
+
export const baseSideAt = async (
|
|
1627
|
+
policy: LoadedPolicy,
|
|
1628
|
+
ref: string,
|
|
1629
|
+
roots: ReadonlyArray<string>,
|
|
1630
|
+
load: (repoRoot: string) => Promise<LoadedPolicy>,
|
|
1631
|
+
): Promise<BaseSide> => {
|
|
1632
|
+
let sha: string | null = null;
|
|
1633
|
+
try {
|
|
1634
|
+
sha = commitOf(policy.repoRoot, ref);
|
|
1635
|
+
} catch {
|
|
1636
|
+
sha = null;
|
|
1637
|
+
}
|
|
1638
|
+
const cacheAt =
|
|
1639
|
+
sha === null
|
|
1640
|
+
? null
|
|
1641
|
+
: path.join(policy.repoRoot, "node_modules", ".cache", "goodbones", `base-${sha}.json`);
|
|
1642
|
+
if (cacheAt !== null && existsSync(cacheAt)) {
|
|
1643
|
+
try {
|
|
1644
|
+
return JSON.parse(readFileSync(cacheAt, "utf8")) as BaseSide;
|
|
1645
|
+
} catch {
|
|
1646
|
+
// an unreadable cache is recomputed
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
const tree = materializeTree(policy.repoRoot, ref);
|
|
1650
|
+
try {
|
|
1651
|
+
const base = await load(tree.root);
|
|
1652
|
+
const files = listSourceFiles(tree.root, roots, base.languages, widenedExtensions(base));
|
|
1653
|
+
const side = baseSideOf(evaluateCampaigns(base, roots, files));
|
|
1654
|
+
if (cacheAt !== null && existsSync(path.join(policy.repoRoot, "node_modules"))) {
|
|
1655
|
+
mkdirSync(path.dirname(cacheAt), { recursive: true });
|
|
1656
|
+
writeFileSync(cacheAt, JSON.stringify(side));
|
|
1657
|
+
}
|
|
1658
|
+
return side;
|
|
1659
|
+
} finally {
|
|
1660
|
+
tree.dispose();
|
|
1661
|
+
}
|
|
1662
|
+
};
|
|
1663
|
+
|
|
1664
|
+
export { readDiff };
|