@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.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/build/dts/core/campaign-state.d.ts +38 -0
  3. package/build/dts/core/campaign-state.d.ts.map +1 -0
  4. package/build/dts/core/campaigns.d.ts +196 -0
  5. package/build/dts/core/campaigns.d.ts.map +1 -0
  6. package/build/dts/core/ledger.d.ts +173 -0
  7. package/build/dts/core/ledger.d.ts.map +1 -0
  8. package/build/dts/core/phases.d.ts +26 -0
  9. package/build/dts/core/phases.d.ts.map +1 -0
  10. package/build/dts/core/sectors.d.ts +52 -0
  11. package/build/dts/core/sectors.d.ts.map +1 -0
  12. package/build/dts/domain/config.d.ts +985 -0
  13. package/build/dts/domain/config.d.ts.map +1 -0
  14. package/build/dts/domain/report.d.ts +16 -0
  15. package/build/dts/domain/report.d.ts.map +1 -0
  16. package/build/dts/host/campaigns.d.ts +195 -0
  17. package/build/dts/host/campaigns.d.ts.map +1 -0
  18. package/build/dts/host/diff.d.ts +31 -0
  19. package/build/dts/host/diff.d.ts.map +1 -0
  20. package/build/dts/index.d.ts +17 -0
  21. package/build/dts/index.d.ts.map +1 -0
  22. package/build/dts/infrastructure/campaign-functions.d.ts +7 -0
  23. package/build/dts/infrastructure/campaign-functions.d.ts.map +1 -0
  24. package/build/dts/infrastructure/report-source-fake.d.ts +4 -0
  25. package/build/dts/infrastructure/report-source-fake.d.ts.map +1 -0
  26. package/build/dts/infrastructure/report-source-live.d.ts +3 -0
  27. package/build/dts/infrastructure/report-source-live.d.ts.map +1 -0
  28. package/build/dts/load/decode.d.ts +7 -0
  29. package/build/dts/load/decode.d.ts.map +1 -0
  30. package/build/dts/load/extension.d.ts +25 -0
  31. package/build/dts/load/extension.d.ts.map +1 -0
  32. package/build/dts/manifest/lower.d.ts +14 -0
  33. package/build/dts/manifest/lower.d.ts.map +1 -0
  34. package/build/dts/manifest/spec.d.ts +515 -0
  35. package/build/dts/manifest/spec.d.ts.map +1 -0
  36. package/build/dts/ports/campaign-predicate.d.ts +23 -0
  37. package/build/dts/ports/campaign-predicate.d.ts.map +1 -0
  38. package/build/dts/ports/report-source.d.ts +14 -0
  39. package/build/dts/ports/report-source.d.ts.map +1 -0
  40. package/build/dts/testing.d.ts +2 -0
  41. package/build/dts/testing.d.ts.map +1 -0
  42. package/build/esm/core/campaign-state.js +140 -0
  43. package/build/esm/core/campaign-state.js.map +1 -0
  44. package/build/esm/core/campaigns.js +954 -0
  45. package/build/esm/core/campaigns.js.map +1 -0
  46. package/build/esm/core/ledger.js +519 -0
  47. package/build/esm/core/ledger.js.map +1 -0
  48. package/build/esm/core/phases.js +87 -0
  49. package/build/esm/core/phases.js.map +1 -0
  50. package/build/esm/core/sectors.js +313 -0
  51. package/build/esm/core/sectors.js.map +1 -0
  52. package/build/esm/domain/config.js +245 -0
  53. package/build/esm/domain/config.js.map +1 -0
  54. package/build/esm/domain/report.js +168 -0
  55. package/build/esm/domain/report.js.map +1 -0
  56. package/build/esm/host/campaigns.js +1159 -0
  57. package/build/esm/host/campaigns.js.map +1 -0
  58. package/build/esm/host/diff.js +156 -0
  59. package/build/esm/host/diff.js.map +1 -0
  60. package/build/esm/index.js +27 -0
  61. package/build/esm/index.js.map +1 -0
  62. package/build/esm/infrastructure/campaign-functions.js +67 -0
  63. package/build/esm/infrastructure/campaign-functions.js.map +1 -0
  64. package/build/esm/infrastructure/report-source-fake.js +6 -0
  65. package/build/esm/infrastructure/report-source-fake.js.map +1 -0
  66. package/build/esm/infrastructure/report-source-live.js +165 -0
  67. package/build/esm/infrastructure/report-source-live.js.map +1 -0
  68. package/build/esm/load/decode.js +60 -0
  69. package/build/esm/load/decode.js.map +1 -0
  70. package/build/esm/load/extension.js +326 -0
  71. package/build/esm/load/extension.js.map +1 -0
  72. package/build/esm/manifest/lower.js +488 -0
  73. package/build/esm/manifest/lower.js.map +1 -0
  74. package/build/esm/manifest/spec.js +296 -0
  75. package/build/esm/manifest/spec.js.map +1 -0
  76. package/build/esm/ports/campaign-predicate.js +2 -0
  77. package/build/esm/ports/campaign-predicate.js.map +1 -0
  78. package/build/esm/ports/report-source.js +7 -0
  79. package/build/esm/ports/report-source.js.map +1 -0
  80. package/build/esm/testing.js +5 -0
  81. package/build/esm/testing.js.map +1 -0
  82. package/package.json +65 -0
  83. package/src/core/campaign-state.ts +227 -0
  84. package/src/core/campaigns.ts +1319 -0
  85. package/src/core/ledger.ts +675 -0
  86. package/src/core/phases.ts +138 -0
  87. package/src/core/sectors.ts +399 -0
  88. package/src/domain/config.ts +314 -0
  89. package/src/domain/report.ts +203 -0
  90. package/src/host/campaigns.ts +1664 -0
  91. package/src/host/diff.ts +203 -0
  92. package/src/index.ts +256 -0
  93. package/src/infrastructure/campaign-functions.ts +99 -0
  94. package/src/infrastructure/report-source-fake.ts +10 -0
  95. package/src/infrastructure/report-source-live.ts +193 -0
  96. package/src/load/decode.ts +81 -0
  97. package/src/load/extension.ts +466 -0
  98. package/src/manifest/lower.ts +615 -0
  99. package/src/manifest/spec.ts +364 -0
  100. package/src/ports/campaign-predicate.ts +29 -0
  101. package/src/ports/report-source.ts +35 -0
  102. 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 };