@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,466 @@
1
+ import {
2
+ compileExportRules,
3
+ compileImportRules,
4
+ compileMemberRules,
5
+ compileStructure,
6
+ compileSurfaceRules,
7
+ ConfigInvalid,
8
+ exportRulesFailingTheirProbe,
9
+ type ExtensionContext,
10
+ type FileSystem,
11
+ type LoadedExtension,
12
+ type LoadedPolicy,
13
+ type LoweredRules,
14
+ type ManifestPath,
15
+ memberRulesFailingTheirProbe,
16
+ type PatternInvalid,
17
+ type PolicyExtension,
18
+ rulesFailingTheirProbe,
19
+ structureRulesFailingTheirProbe,
20
+ surfaceRulesFailingTheirProbe,
21
+ } from "@goodbones/core";
22
+ import * as Result from "effect/Result";
23
+
24
+ import {
25
+ campaignsFailingTheirProbe,
26
+ compileCampaignRules,
27
+ type CompiledCampaign,
28
+ type CompiledDetector,
29
+ detectorOf,
30
+ leafTermsOf,
31
+ } from "../core/campaigns.js";
32
+ import {
33
+ decodeLedger,
34
+ decodePlanRecord,
35
+ decodeSectorRecord,
36
+ isLegacyLedger,
37
+ type Ledger,
38
+ ledgerPathOf,
39
+ legacyLedgerPathOf,
40
+ planPathOf,
41
+ type PlanRecord,
42
+ type SectorRecord,
43
+ } from "../core/ledger.js";
44
+ import { END_STATE_ROOT, lowerCampaigns, lowerEndState } from "../manifest/lower.js";
45
+ import type { CampaignPredicate } from "../ports/campaign-predicate.js";
46
+ import { NO_REPORTS, type ReportSource } from "../ports/report-source.js";
47
+ import { type CampaignsManifest, decodeCampaigns, DEFAULT_LEDGER_DIR } from "./decode.js";
48
+
49
+ // The campaigns family as a `PolicyExtension`: everything `loadPolicy` used
50
+ // to do for this family, moved to the package that owns it.
51
+ //
52
+ // It claims two manifest keys, decodes them with its own codec, lowers them
53
+ // with the core's glob primitives, compiles and probes the rules, refuses the
54
+ // four things a campaign can name that its host cannot answer — a `fn` the
55
+ // host did not import, a `report` with no source, a `syntax` term in a scope
56
+ // whose language has no matcher, an `endState` that lowers to nothing — and
57
+ // reads the ledgers off the file system port. The core carries the result and
58
+ // never looks inside it; `campaignsOf(policy)` is how a host reads it back.
59
+
60
+ export const CAMPAIGNS_EXTENSION_ID = "campaigns";
61
+
62
+ // Keyed `<campaign>/<objective>`, and `<campaign>/<sector>` for a record.
63
+ export const ledgerKeyOf = (campaign: string, objective: string): string =>
64
+ `${campaign}/${objective}`;
65
+
66
+ export type CampaignPolicy = {
67
+ // Evaluated by both hosts, one file at a time; the ledgers say which hits
68
+ // are already counted. An objective with no ledger, or a sector no ledger
69
+ // has seen, is one `objectives clear` has not been run for, and `check`
70
+ // says so.
71
+ readonly campaignRules: ReadonlyArray<CompiledCampaign>;
72
+ // Keyed `<campaign>/<objective>`.
73
+ readonly ledgers: ReadonlyMap<string, Ledger>;
74
+ // Ledgers read from the family's first layout, `<ledgerDir>/<campaign>.json`,
75
+ // by campaign: `clear` rewrites each in the new layout and removes it.
76
+ readonly legacyLedgers: ReadonlyMap<string, string>;
77
+ // Keyed `<campaign>/<sector>`.
78
+ readonly sectorRecords: ReadonlyMap<string, SectorRecord>;
79
+ // What `clear` last saw of each campaign's phases, by campaign.
80
+ readonly plans: ReadonlyMap<string, PlanRecord>;
81
+ // Repo-relative; `<ledgerDir>/<campaign>/<objective>.json` is a ledger.
82
+ readonly ledgerDir: string;
83
+ // The predicate functions the host imported for the `fn` terms.
84
+ readonly functions: ReadonlyMap<string, CampaignPredicate>;
85
+ // Answers the `report` terms: the host's live source, or nothing.
86
+ readonly reports: ReportSource;
87
+ };
88
+
89
+ // A campaign-free policy, for a host that loaded the extension against a
90
+ // manifest holding no `campaigns` key at all.
91
+ const NOTHING: CampaignPolicy = {
92
+ campaignRules: [],
93
+ ledgers: new Map(),
94
+ legacyLedgers: new Map(),
95
+ sectorRecords: new Map(),
96
+ plans: new Map(),
97
+ ledgerDir: DEFAULT_LEDGER_DIR,
98
+ functions: new Map(),
99
+ reports: NO_REPORTS,
100
+ };
101
+
102
+ // What a host reads back off the policy. A policy loaded without this
103
+ // extension answers the empty family rather than throwing: `architecture
104
+ // check` on a manifest with no campaigns is not an error.
105
+ export const campaignsOf = (policy: LoadedPolicy): CampaignPolicy => {
106
+ const found = policy.extensions.get(CAMPAIGNS_EXTENSION_ID);
107
+ return found === undefined ? NOTHING : (found as CampaignPolicy);
108
+ };
109
+
110
+ export type CampaignsExtensionOptions = {
111
+ // The predicate functions the manifest's `fn` terms name, imported by the
112
+ // host before loading — `loadCampaignFunctions` does it.
113
+ readonly functions?: ReadonlyMap<string, CampaignPredicate> | undefined;
114
+ // The host's report source, for the `report` terms. Absent, a term naming
115
+ // one is refused rather than quietly answering nothing.
116
+ readonly reports?: ReportSource | undefined;
117
+ };
118
+
119
+ const referencedFunctions = (detect: CompiledDetector): ReadonlyArray<string> => {
120
+ switch (detect.kind) {
121
+ case "all":
122
+ case "any":
123
+ return detect.terms.flatMap(referencedFunctions);
124
+ case "not":
125
+ return referencedFunctions(detect.term);
126
+ case "fn":
127
+ return [detect.name];
128
+ default:
129
+ return [];
130
+ }
131
+ };
132
+
133
+ // Every detector a campaign holds: its objectives' (a `has` term's included)
134
+ // and its perimeter's.
135
+ const detectorsOf = (rule: CompiledCampaign): ReadonlyArray<CompiledDetector> => [
136
+ ...rule.objectives.flatMap((objective) => {
137
+ const detect = detectorOf(objective);
138
+ return detect === null ? [] : [detect];
139
+ }),
140
+ ...(rule.perimeter?.kind === "match" ? [rule.perimeter.detect] : []),
141
+ ];
142
+
143
+ type ReadLedgers = {
144
+ readonly ledgers: ReadonlyMap<string, Ledger>;
145
+ readonly legacy: ReadonlyMap<string, string>;
146
+ readonly sectorRecords: ReadonlyMap<string, SectorRecord>;
147
+ readonly plans: ReadonlyMap<string, PlanRecord>;
148
+ };
149
+
150
+ const readJson = (
151
+ configPath: string,
152
+ fileSystem: FileSystem,
153
+ at: string,
154
+ ): Result.Result<unknown | null, ConfigInvalid> => {
155
+ const text = fileSystem.readText(at);
156
+ if (text === null) return Result.succeed(null);
157
+ try {
158
+ return Result.succeed(JSON.parse(text) as unknown);
159
+ } catch (cause) {
160
+ return Result.fail(
161
+ new ConfigInvalid({ configPath, detail: `the ledger ${at} is not JSON: ${String(cause)}` }),
162
+ );
163
+ }
164
+ };
165
+
166
+ // The ledgers, read through the port. An absent file is an objective with no
167
+ // ledger yet; a malformed one is refused, since reading it as empty would
168
+ // report every hit as unrecorded growth. A file in the family's first layout
169
+ // — one per campaign, `<dir>/<campaign>.json` — is read as the ledger of the
170
+ // campaign's one objective, under the implicit sector, and remembered so
171
+ // `clear` can rewrite it where it now belongs.
172
+ const readLedgers = (
173
+ configPath: string,
174
+ fileSystem: FileSystem,
175
+ ledgerDir: string,
176
+ campaigns: ReadonlyArray<CompiledCampaign>,
177
+ ): Result.Result<ReadLedgers, ConfigInvalid> => {
178
+ const ledgers = new Map<string, Ledger>();
179
+ const legacy = new Map<string, string>();
180
+ const sectorRecords = new Map<string, SectorRecord>();
181
+ const plans = new Map<string, PlanRecord>();
182
+ for (const campaign of campaigns) {
183
+ for (const objective of campaign.objectives) {
184
+ const at = ledgerPathOf(ledgerDir, campaign.id, objective.id);
185
+ let raw = readJson(configPath, fileSystem, at);
186
+ if (Result.isFailure(raw)) return Result.fail(raw.failure);
187
+ let from = at;
188
+ if (raw.success === null && campaign.objectives.length === 1) {
189
+ from = legacyLedgerPathOf(ledgerDir, campaign.id);
190
+ raw = readJson(configPath, fileSystem, from);
191
+ if (Result.isFailure(raw)) return Result.fail(raw.failure);
192
+ if (raw.success !== null && !isLegacyLedger(raw.success)) {
193
+ return Result.fail(
194
+ new ConfigInvalid({
195
+ configPath,
196
+ detail:
197
+ `the ledger ${from} is not in the family's first layout, and an objective's ` +
198
+ `ledger is ${at}. Move it.`,
199
+ }),
200
+ );
201
+ }
202
+ if (raw.success !== null) legacy.set(campaign.id, from);
203
+ }
204
+ if (raw.success === null) continue;
205
+ const decoded = decodeLedger(raw.success);
206
+ if (Result.isFailure(decoded)) {
207
+ return Result.fail(
208
+ new ConfigInvalid({
209
+ configPath,
210
+ detail: `the ledger ${from} does not decode:\n${decoded.failure}`,
211
+ }),
212
+ );
213
+ }
214
+ const ledger = decoded.success;
215
+ const expected = from === at ? objective.id : campaign.id;
216
+ if (ledger.campaign !== campaign.id || ledger.objective !== expected) {
217
+ return Result.fail(
218
+ new ConfigInvalid({
219
+ configPath,
220
+ detail:
221
+ `the ledger ${from} says it belongs to "${ledger.campaign}/${ledger.objective}", ` +
222
+ `not "${campaign.id}/${objective.id}".`,
223
+ }),
224
+ );
225
+ }
226
+ ledgers.set(ledgerKeyOf(campaign.id, objective.id), {
227
+ ...ledger,
228
+ objective: objective.id,
229
+ });
230
+ }
231
+ const sectorsDir = `${ledgerDir}/${campaign.id}/sectors`;
232
+ for (const name of fileSystem.list(sectorsDir)) {
233
+ if (!name.endsWith(".json")) continue;
234
+ const at = `${sectorsDir}/${name}`;
235
+ const raw = readJson(configPath, fileSystem, at);
236
+ if (Result.isFailure(raw)) return Result.fail(raw.failure);
237
+ if (raw.success === null) continue;
238
+ const decoded = decodeSectorRecord(raw.success);
239
+ if (Result.isFailure(decoded)) {
240
+ return Result.fail(
241
+ new ConfigInvalid({
242
+ configPath,
243
+ detail: `the sector record ${at} does not decode:\n${decoded.failure}`,
244
+ }),
245
+ );
246
+ }
247
+ if (decoded.success.campaign !== campaign.id) {
248
+ return Result.fail(
249
+ new ConfigInvalid({
250
+ configPath,
251
+ detail: `the sector record ${at} says it belongs to "${decoded.success.campaign}", not "${campaign.id}".`,
252
+ }),
253
+ );
254
+ }
255
+ sectorRecords.set(ledgerKeyOf(campaign.id, decoded.success.sector), decoded.success);
256
+ }
257
+ const planAt = planPathOf(ledgerDir, campaign.id);
258
+ const rawPlan = readJson(configPath, fileSystem, planAt);
259
+ if (Result.isFailure(rawPlan)) return Result.fail(rawPlan.failure);
260
+ if (rawPlan.success !== null) {
261
+ const decoded = decodePlanRecord(rawPlan.success);
262
+ if (Result.isFailure(decoded)) {
263
+ return Result.fail(
264
+ new ConfigInvalid({
265
+ configPath,
266
+ detail: `the plan record ${planAt} does not decode:\n${decoded.failure}`,
267
+ }),
268
+ );
269
+ }
270
+ plans.set(campaign.id, decoded.success);
271
+ }
272
+ }
273
+ return Result.succeed({ ledgers, legacy, sectorRecords, plans });
274
+ };
275
+
276
+ const load = (
277
+ options: CampaignsExtensionOptions,
278
+ context: ExtensionContext<CampaignsManifest>,
279
+ ): Result.Result<LoadedExtension<CampaignPolicy>, ConfigInvalid | PatternInvalid> => {
280
+ const { config, configPath, extractor, fileSystem, languages, routeFor, spec } = context;
281
+
282
+ // Lowering refuses a shape the schema cannot — a phase naming an objective
283
+ // that is not there, an `endState` over a multi-root perimeter — and it
284
+ // refuses by throwing, as the core's own lowering does. That is the very
285
+ // thing this repository's `lowering-reports-not-throws` campaign is paying
286
+ // down; it is not this extension's to change on the way past.
287
+ const lowered = lowerCampaigns(spec, config, languages);
288
+
289
+ const campaignRules = compileCampaignRules(lowered);
290
+ if (Result.isFailure(campaignRules)) return Result.fail(campaignRules.failure);
291
+
292
+ // A `fn` term names a function the host was to import. One it did not is a
293
+ // term that would answer false for every file, and is refused.
294
+ const functions = options.functions ?? new Map<string, CampaignPredicate>();
295
+ const missing = campaignRules.success.flatMap((rule) =>
296
+ detectorsOf(rule).flatMap((detect) =>
297
+ referencedFunctions(detect).filter((name) => !functions.has(name)),
298
+ ),
299
+ );
300
+ if (missing.length > 0) {
301
+ return Result.fail(
302
+ new ConfigInvalid({
303
+ configPath,
304
+ detail:
305
+ `these campaigns name a predicate function the host did not load: ` +
306
+ `${[...new Set(missing)].join(", ")}. A \`fn\` term is \`module#export\`, resolved ` +
307
+ `relative to the manifest, and the export must be a function.`,
308
+ }),
309
+ );
310
+ }
311
+
312
+ // A `report` term is answered by the host's source. Without one every such
313
+ // campaign would report nothing and look complete.
314
+ const reporting = campaignRules.success.filter((rule) =>
315
+ detectorsOf(rule).some((detect) => leafTermsOf(detect).includes("report")),
316
+ );
317
+ if (options.reports === undefined && reporting.length > 0) {
318
+ return Result.fail(
319
+ new ConfigInvalid({
320
+ configPath,
321
+ detail:
322
+ `these campaigns hold a \`report\` term and the host provided no report source: ` +
323
+ `${reporting.map((rule) => rule.name).join(", ")}.`,
324
+ }),
325
+ );
326
+ }
327
+
328
+ // A `syntax` term needs a matcher for the language of every file in its
329
+ // scope. The probes are the files the campaign is proven on; a probe whose
330
+ // language has none would pass no probe and mean nothing.
331
+ const syntaxless = campaignRules.success.flatMap((rule) => {
332
+ const proven = [
333
+ ...rule.objectives.map((objective) => ({
334
+ name: objective.name,
335
+ detect: detectorOf(objective),
336
+ probes: objective.probes,
337
+ })),
338
+ ...(rule.perimeter?.kind === "match"
339
+ ? [
340
+ {
341
+ name: `${rule.name}/perimeter`,
342
+ detect: rule.perimeter.detect,
343
+ probes: rule.perimeter.probes,
344
+ },
345
+ ]
346
+ : []),
347
+ ];
348
+ return proven.flatMap(({ detect, name, probes }) => {
349
+ if (detect === null || !leafTermsOf(detect).includes("syntax")) return [];
350
+ return [...probes.fires, ...probes.ignores].flatMap((probe) => {
351
+ const route = routeFor(probe.path);
352
+ return route === undefined || route.language.syntax !== undefined
353
+ ? []
354
+ : [`${name} (${probe.path}: ${route.language.id} carries no syntax matcher)`];
355
+ });
356
+ });
357
+ });
358
+ if (syntaxless.length > 0) {
359
+ return Result.fail(
360
+ new ConfigInvalid({
361
+ configPath,
362
+ detail:
363
+ `these campaigns hold a \`syntax\` term in a scope whose language has no syntax ` +
364
+ `matcher: ${[...new Set(syntaxless)].join(", ")}. Compose the language pack with ` +
365
+ `one (\`@goodbones/ast-grep\` for TypeScript), or write the detector without it.`,
366
+ }),
367
+ );
368
+ }
369
+
370
+ // An `endState` is compiled and probed once, at an abstract root, before any
371
+ // sector exists to lower it for: a sector-relative tree whose rules cannot
372
+ // fire is refused like any other.
373
+ const endStateFailures: Array<string> = [];
374
+ for (const rule of campaignRules.success) {
375
+ for (const phase of rule.phases) {
376
+ if (phase.endState === undefined) continue;
377
+ let loweredEnd: LoweredRules;
378
+ try {
379
+ loweredEnd = lowerEndState(
380
+ phase.endState as Readonly<Record<string, unknown>>,
381
+ END_STATE_ROOT,
382
+ [],
383
+ config.resolve,
384
+ languages,
385
+ );
386
+ } catch (cause) {
387
+ return Result.fail(
388
+ new ConfigInvalid({
389
+ configPath,
390
+ detail: `campaign "${rule.id}" phase "${phase.id}": its endState does not lower: ${String(cause)}`,
391
+ }),
392
+ );
393
+ }
394
+ const endImports = compileImportRules(loweredEnd.imports);
395
+ if (Result.isFailure(endImports)) return Result.fail(endImports.failure);
396
+ const endExports = compileExportRules(loweredEnd.exports);
397
+ if (Result.isFailure(endExports)) return Result.fail(endExports.failure);
398
+ const endMembers = compileMemberRules(loweredEnd.members);
399
+ if (Result.isFailure(endMembers)) return Result.fail(endMembers.failure);
400
+ const endSurface = compileSurfaceRules(loweredEnd.surface);
401
+ if (Result.isFailure(endSurface)) return Result.fail(endSurface.failure);
402
+ const endStructure = compileStructure(loweredEnd.structure);
403
+ if (Result.isFailure(endStructure)) return Result.fail(endStructure.failure);
404
+ const label = (name: string): string => `${rule.name}/${phase.id}/endState ${name}`;
405
+ for (const name of [
406
+ ...rulesFailingTheirProbe(endImports.success).map((one) => one.name),
407
+ ...exportRulesFailingTheirProbe(endExports.success, extractor).map((one) => one.name),
408
+ ...memberRulesFailingTheirProbe(endMembers.success, extractor).map((one) => one.name),
409
+ ...surfaceRulesFailingTheirProbe(endSurface.success, extractor).map((one) => one.name),
410
+ ...structureRulesFailingTheirProbe(endStructure.success),
411
+ ]) {
412
+ endStateFailures.push(label(name));
413
+ }
414
+ }
415
+ }
416
+
417
+ const vacuous = [
418
+ ...campaignsFailingTheirProbe(
419
+ campaignRules.success,
420
+ extractor,
421
+ (file) => routeFor(file)?.language.syntax ?? null,
422
+ functions,
423
+ ).map((failed) =>
424
+ failed.outOfScope === true
425
+ ? `${failed.name} (its probe ${failed.probe.path} is outside the campaign's own scope)`
426
+ : failed.expected === "fires"
427
+ ? `${failed.name} (fires probe ${failed.probe.path} did not fire)`
428
+ : failed.expected === "end-shape"
429
+ ? `${failed.name} (no fires probe is a sector in its end shape — one that no objective ` +
430
+ `fires on — so the perimeter would un-birth the sector the moment its first phase was met)`
431
+ : `${failed.name} (ignores probe ${failed.probe.path} fired` +
432
+ (failed.admittedBy === undefined ? ")" : `, admitted by \`${failed.admittedBy}\`)`),
433
+ ),
434
+ ...endStateFailures,
435
+ ];
436
+
437
+ const ledgerDir = spec.ledger ?? DEFAULT_LEDGER_DIR;
438
+ const ledgers = readLedgers(configPath, fileSystem, ledgerDir, campaignRules.success);
439
+ if (Result.isFailure(ledgers)) return Result.fail(ledgers.failure);
440
+
441
+ return Result.succeed({
442
+ value: {
443
+ campaignRules: campaignRules.success,
444
+ ledgers: ledgers.success.ledgers,
445
+ legacyLedgers: ledgers.success.legacy,
446
+ sectorRecords: ledgers.success.sectorRecords,
447
+ plans: ledgers.success.plans,
448
+ ledgerDir,
449
+ functions,
450
+ reports: options.reports ?? NO_REPORTS,
451
+ },
452
+ vacuous,
453
+ });
454
+ };
455
+
456
+ // The extension, as a host composes it. `loadPolicy({ …, extensions: [
457
+ // campaignsExtension({ functions, reports }) ] })`.
458
+ export const campaignsExtension = (
459
+ options: CampaignsExtensionOptions = {},
460
+ ): PolicyExtension<CampaignsManifest, CampaignPolicy> => ({
461
+ id: CAMPAIGNS_EXTENSION_ID,
462
+ manifestKeys: ["campaigns", "ledger"],
463
+ decode: (slice: Readonly<Record<string, unknown>>, describe: (path: ManifestPath, detail: string) => string) =>
464
+ decodeCampaigns(slice, describe),
465
+ load: (context) => load(options, context),
466
+ });