@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,615 @@
1
+ import {
2
+ anchored,
3
+ CONVENTIONS,
4
+ decodeManifestTree,
5
+ expandAliases,
6
+ globsOf,
7
+ globToRegexSource,
8
+ type LoweredRules,
9
+ lowerManifest,
10
+ type Manifest,
11
+ prefixed,
12
+ type ProbeLanguage,
13
+ } from "@goodbones/core";
14
+ import * as Result from "effect/Result";
15
+
16
+ import type {
17
+ CampaignProbe,
18
+ CampaignRule,
19
+ Detector,
20
+ ObjectiveRule,
21
+ PerimeterRule,
22
+ PhaseRule,
23
+ SectorTerm,
24
+ } from "../domain/config.js";
25
+ import {
26
+ type CampaignProbesSpec,
27
+ type CampaignsManifest,
28
+ type CampaignSpec,
29
+ type DetectorSpec,
30
+ durationMs,
31
+ type PhaseSpec,
32
+ type SyntaxTermSpec,
33
+ } from "./spec.js";
34
+
35
+ // The campaigns family's lowering: what a reader wrote under `campaigns`,
36
+ // with its globs resolved the way every other family's are, its phases
37
+ // checked for the shapes the design refuses, and an `endState` expanded into
38
+ // one synthetic objective per family.
39
+ //
40
+ // It reaches into @goodbones/core for the glob primitives the core's own
41
+ // lowering uses — `globToRegexSource`, `anchored`, `prefixed`, `expandAliases`
42
+ // and the naming `CONVENTIONS` — because a campaign's `scope` must resolve to
43
+ // exactly the pattern an `imports` rule's would. Writing a second glob
44
+ // compiler here is how the two would drift.
45
+
46
+ // The keys of a `syntax` term that belong to the engine's rule; `where` is
47
+ // the one that does not.
48
+ const SYNTAX_RULE_KEYS = [
49
+ "pattern",
50
+ "kind",
51
+ "regex",
52
+ "nthChild",
53
+ "inside",
54
+ "has",
55
+ "precedes",
56
+ "follows",
57
+ "all",
58
+ "any",
59
+ "not",
60
+ ] as const;
61
+
62
+ const syntaxRuleOf = (term: SyntaxTermSpec): unknown =>
63
+ Object.fromEntries(
64
+ SYNTAX_RULE_KEYS.flatMap((key) => (term[key] === undefined ? [] : [[key, term[key]]])),
65
+ );
66
+
67
+ // The leaf terms a path alone cannot exercise: each reads the file's text,
68
+ // its syntax, or its facts, so a probe for a detector holding one must carry
69
+ // a `source`.
70
+ const TERMS_NEEDING_SOURCE = ["content", "syntax", "exports", "members", "fn"] as const;
71
+
72
+ const leafTermsOf = (detector: DetectorSpec): ReadonlyArray<string> => {
73
+ if ("all" in detector) return detector.all.flatMap(leafTermsOf);
74
+ if ("any" in detector) return detector.any.flatMap(leafTermsOf);
75
+ if ("not" in detector) return leafTermsOf(detector.not);
76
+ return Object.keys(detector);
77
+ };
78
+
79
+ // The families an `endState` tree can expand into — one synthetic objective
80
+ // per family the lowered tree actually produces rules for.
81
+ const END_STATE_FAMILIES = ["imports", "exports", "members", "surface", "structure"] as const;
82
+ type EndStateFamily = (typeof END_STATE_FAMILIES)[number];
83
+
84
+ // The id a family's synthetic objective carries, `end-state-<family>`, and
85
+ // its ledger file's name.
86
+ export const endStateObjectiveId = (family: EndStateFamily): string => `end-state-${family}`;
87
+
88
+ // The abstract root an `endState` is compiled and probed at, once, before
89
+ // any sector exists to lower it for.
90
+ export const END_STATE_ROOT = "~";
91
+
92
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
93
+ typeof value === "object" && value !== null && !Array.isArray(value);
94
+
95
+ // A sector-relative tree lowered for one sector: every `~/` becomes the
96
+ // sector's root, and an allow entry `{ sector, via }` — another sector,
97
+ // through its port — becomes that sector's `via` path for each sector the
98
+ // name selects. The result is an ordinary manifest fragment, lowered by the
99
+ // same pass as the repository's own tree.
100
+ export const lowerEndState = (
101
+ tree: Readonly<Record<string, unknown>>,
102
+ root: string,
103
+ sectors: ReadonlyArray<{ readonly name: string; readonly root: string }>,
104
+ resolve: Manifest["resolve"],
105
+ languages: ReadonlyArray<ProbeLanguage>,
106
+ aliases: Readonly<Record<string, string>> = {},
107
+ ): LoweredRules => {
108
+ const rebase = (glob: string): string =>
109
+ glob === "~" || glob === "~/"
110
+ ? `${root}/`
111
+ : glob.startsWith("~/")
112
+ ? `${root}/${glob.slice(2)}`
113
+ : glob;
114
+ const walk = (value: unknown, key: string): unknown => {
115
+ if (Array.isArray(value)) {
116
+ return value.flatMap((item: unknown) => {
117
+ if (key === "allow" && isRecord(item) && typeof item.via === "string") {
118
+ const named = item.sector;
119
+ // `~/ports/` names a folder: everything under it.
120
+ const via = (item.via.startsWith("~/") ? item.via.slice(2) : String(item.via)).replace(
121
+ /\/$/,
122
+ "/**",
123
+ );
124
+ return sectors
125
+ .filter((one) => named === "*" || named === undefined || one.name === named)
126
+ .filter((one) => one.root !== root)
127
+ .map((one) => `${one.root}/${via}`);
128
+ }
129
+ return [walk(item, key)];
130
+ });
131
+ }
132
+ if (isRecord(value)) {
133
+ return Object.fromEntries(
134
+ Object.entries(value).map(([childKey, child]) => [
135
+ key === "children" || key === "" ? rebaseKey(childKey) : childKey,
136
+ walk(child, childKey),
137
+ ]),
138
+ );
139
+ }
140
+ return typeof value === "string" ? rebase(value) : value;
141
+ };
142
+ const rebaseKey = (key: string): string => (key === "~/" ? `${root}/` : key);
143
+ const rebased = decodeManifestTree(walk(tree, ""));
144
+ if (Result.isFailure(rebased)) {
145
+ throw new Error(`the endState does not decode as a tree:\n${rebased.failure}`);
146
+ }
147
+ const manifest: Manifest = { resolve, aliases, tree: rebased.success };
148
+ return lowerManifest(manifest, languages, {});
149
+ };
150
+
151
+ // Which families a tree yields at all, decided once at the abstract root.
152
+ const endStateFamiliesOf = (
153
+ tree: Readonly<Record<string, unknown>>,
154
+ resolve: Manifest["resolve"],
155
+ languages: ReadonlyArray<ProbeLanguage>,
156
+ ): ReadonlyArray<EndStateFamily> => {
157
+ const lowered = lowerEndState(tree, END_STATE_ROOT, [], resolve, languages);
158
+ const structure =
159
+ lowered.structure.roots.length +
160
+ lowered.structure.folders.length +
161
+ lowered.structure.parity.length +
162
+ lowered.structure.naming.length;
163
+ return END_STATE_FAMILIES.filter((family) =>
164
+ family === "structure" ? structure > 0 : lowered[family].length > 0,
165
+ );
166
+ };
167
+
168
+ // A stable digest of a phase's definition — its position, its objectives
169
+ // and their detectors — so a change to a defined phase is visible against
170
+ // what the plan file recorded. FNV-1a over the canonical JSON.
171
+ const digest = (value: unknown): string => {
172
+ const canonical = (one: unknown): unknown =>
173
+ Array.isArray(one)
174
+ ? one.map(canonical)
175
+ : isRecord(one)
176
+ ? Object.fromEntries(
177
+ Object.keys(one)
178
+ .sort()
179
+ .map((key) => [key, canonical(one[key])]),
180
+ )
181
+ : one;
182
+ const text = JSON.stringify(canonical(value));
183
+ let hash = 0x811c9dc5;
184
+ for (let i = 0; i < text.length; i += 1) {
185
+ hash ^= text.charCodeAt(i);
186
+ hash = Math.imul(hash, 0x01000193);
187
+ }
188
+ return (hash >>> 0).toString(16).padStart(8, "0");
189
+ };
190
+
191
+ // A campaign lowered: its globs resolved — `scope`, `legacy`, a perimeter's
192
+ // glob or marker, and inside each objective a path-shaped `resolves` the way
193
+ // graph rules resolve theirs, `exports`/`members` names the way `surface`
194
+ // and `members` rules do, the rest carried as written — its phases checked
195
+ // for the shapes the design refuses, and an `endState` expanded into one
196
+ // synthetic objective per family. The `fn` string is kept verbatim for the
197
+ // loader, which holds the function it names.
198
+ const lowerCampaign = (
199
+ id: string,
200
+ campaign: CampaignSpec,
201
+ aliases: Readonly<Record<string, string>>,
202
+ resolve: Manifest["resolve"],
203
+ languages: ReadonlyArray<ProbeLanguage>,
204
+ ): CampaignRule => {
205
+ const asPath = (glob: string): string =>
206
+ prefixed(
207
+ globToRegexSource(expandAliases(glob, aliases), {}, { declaring: false, nextGroup: 1 })
208
+ .source,
209
+ );
210
+ const asName = (globs: string | ReadonlyArray<string>): ReadonlyArray<string> =>
211
+ globsOf(globs).map((one) =>
212
+ anchored(globToRegexSource(one, {}, { declaring: false, nextGroup: 1 }).source),
213
+ );
214
+ const asWhole = (globs: string | ReadonlyArray<string>): ReadonlyArray<string> =>
215
+ globsOf(globs).map((one) =>
216
+ anchored(
217
+ globToRegexSource(expandAliases(one, aliases), {}, { declaring: false, nextGroup: 1 })
218
+ .source,
219
+ ),
220
+ );
221
+ const refuse = (detail: string): never => {
222
+ throw new Error(`campaign "${id}" ${detail}`);
223
+ };
224
+
225
+ const lower = (detector: DetectorSpec, objective: string): Detector => {
226
+ if ("all" in detector) return { all: detector.all.map((one) => lower(one, objective)) };
227
+ if ("any" in detector) return { any: detector.any.map((one) => lower(one, objective)) };
228
+ if ("not" in detector) return { not: lower(detector.not, objective) };
229
+ if ("path" in detector) {
230
+ const { convention, ...rest } = detector.path;
231
+ const conventionSource =
232
+ convention === undefined
233
+ ? undefined
234
+ : typeof convention === "string"
235
+ ? CONVENTIONS[convention]?.source
236
+ : convention.regex;
237
+ if (convention !== undefined && conventionSource === undefined) {
238
+ refuse(`(${objective}) names an unknown convention "${String(convention)}".`);
239
+ }
240
+ if (conventionSource !== undefined && rest.subject === undefined) {
241
+ refuse(
242
+ `(${objective}) states a path convention with no \`subject\`: say which ` +
243
+ `capture group of \`file\` holds the name the convention is about.`,
244
+ );
245
+ }
246
+ return {
247
+ path: {
248
+ file: globsOf(rest.file),
249
+ ...(rest.fileNot === undefined ? {} : { fileNot: globsOf(rest.fileNot) }),
250
+ ...(rest.subject === undefined ? {} : { subject: rest.subject }),
251
+ ...(conventionSource === undefined ? {} : { convention: conventionSource }),
252
+ },
253
+ };
254
+ }
255
+ if ("imports" in detector) {
256
+ const { resolves, symbols } = detector.imports;
257
+ return {
258
+ imports: {
259
+ resolves: typeof resolves === "string" ? asPath(resolves) : resolves,
260
+ ...(symbols === undefined ? {} : { symbols: [...symbols] }),
261
+ },
262
+ };
263
+ }
264
+ if ("exports" in detector) {
265
+ const term = detector.exports;
266
+ return {
267
+ exports: {
268
+ ...(term.name === undefined ? {} : { name: asName(term.name) }),
269
+ ...(term.kinds === undefined ? {} : { kinds: [...term.kinds] }),
270
+ ...(term.declares === undefined ? {} : { declares: [...term.declares] }),
271
+ ...(term.reexport === undefined ? {} : { reexport: term.reexport }),
272
+ },
273
+ };
274
+ }
275
+ if ("members" in detector) {
276
+ const term = detector.members;
277
+ return {
278
+ members: {
279
+ subject: term.subject,
280
+ ...(term.name === undefined ? {} : { name: asName(term.name) }),
281
+ ...(term.in === undefined ? {} : { in: asName(term.in) }),
282
+ ...(term.declares === undefined ? {} : { declares: [...term.declares] }),
283
+ },
284
+ };
285
+ }
286
+ if ("requires" in detector) return { requires: [...detector.requires] };
287
+ if ("content" in detector) return { content: { regex: detector.content.regex } };
288
+ if ("report" in detector) {
289
+ // Exactly one of `command` and `file`, and a pattern for `regex`: the
290
+ // manifest schema refused anything else at decode.
291
+ const term = detector.report;
292
+ return {
293
+ report: {
294
+ ...(term.command === undefined ? {} : { command: globsOf(term.command) }),
295
+ ...(term.file === undefined ? {} : { file: globsOf(term.file) }),
296
+ format: term.format,
297
+ ...(term.pattern === undefined ? {} : { pattern: term.pattern }),
298
+ ...(term.codes === undefined ? {} : { codes: [...term.codes] }),
299
+ ...(term.codesNot === undefined ? {} : { codesNot: [...term.codesNot] }),
300
+ },
301
+ };
302
+ }
303
+ if ("syntax" in detector) {
304
+ const { where } = detector.syntax;
305
+ const narrowed =
306
+ where === undefined
307
+ ? {}
308
+ : {
309
+ where: Object.fromEntries(
310
+ Object.entries(where).map(([capture, narrowing]) => [
311
+ capture,
312
+ {
313
+ ...(narrowing.regex === undefined ? {} : { regex: narrowing.regex }),
314
+ ...(narrowing.binding === undefined
315
+ ? {}
316
+ : {
317
+ binding: {
318
+ resolves:
319
+ typeof narrowing.binding.resolves === "string"
320
+ ? asPath(narrowing.binding.resolves)
321
+ : narrowing.binding.resolves,
322
+ ...(narrowing.binding.member === undefined
323
+ ? {}
324
+ : { member: [...narrowing.binding.member] }),
325
+ },
326
+ }),
327
+ },
328
+ ]),
329
+ ),
330
+ };
331
+ return { syntax: { rule: syntaxRuleOf(detector.syntax), ...narrowed } };
332
+ }
333
+ return { fn: detector.fn };
334
+ };
335
+
336
+ // A probe without a source proves only the path; a detector that reads
337
+ // the file needs the file. Refused here, with the term named, rather than
338
+ // at load as a probe that mysteriously never fires.
339
+ const checkProbes = (
340
+ what: string,
341
+ detector: DetectorSpec,
342
+ probes: CampaignProbesSpec | undefined,
343
+ ): { fires: ReadonlyArray<CampaignProbe>; ignores: ReadonlyArray<CampaignProbe> } => {
344
+ const leaves = leafTermsOf(detector);
345
+ const needsSource = TERMS_NEEDING_SOURCE.filter((term) => leaves.includes(term));
346
+ const all = [...(probes?.fires ?? []), ...(probes?.ignores ?? [])];
347
+ const sourceless = all.find((probe) => probe.source === undefined);
348
+ if (needsSource.length > 0 && sourceless !== undefined) {
349
+ refuse(
350
+ `(${what}) has a probe (${sourceless.path}) with no \`source\`, and its ` +
351
+ `detector holds a ${needsSource.map((term) => `\`${term}\``).join(", ")} term, which ` +
352
+ `a path alone cannot exercise. Give every probe a source.`,
353
+ );
354
+ }
355
+ return { fires: [...(probes?.fires ?? [])], ignores: [...(probes?.ignores ?? [])] };
356
+ };
357
+
358
+ const scopeSpec = campaign.scope ?? "**";
359
+ const scopeGlobs =
360
+ typeof scopeSpec === "string" || Array.isArray(scopeSpec)
361
+ ? globsOf(scopeSpec as string | ReadonlyArray<string>)
362
+ : globsOf((scopeSpec as { readonly path: string | ReadonlyArray<string> }).path);
363
+ const extensions =
364
+ typeof scopeSpec === "string" || Array.isArray(scopeSpec)
365
+ ? []
366
+ : [...((scopeSpec as { readonly extensions?: ReadonlyArray<string> }).extensions ?? [])];
367
+
368
+ const objectives: Array<ObjectiveRule> = Object.entries(campaign.objectives).map(
369
+ ([objectiveId, spec]) => {
370
+ const name = `campaign/${id}/${objectiveId}`;
371
+ const message = spec.how ?? campaign.how ?? `${objectiveId} (${id})`;
372
+ const why = spec.why ?? campaign.why;
373
+ const base = {
374
+ name,
375
+ id: objectiveId,
376
+ campaign: id,
377
+ message,
378
+ ...(why === undefined ? {} : { why }),
379
+ holdout: spec.holdout,
380
+ ...(spec.until === undefined ? {} : { until: spec.until }),
381
+ };
382
+ if (spec.match !== undefined) {
383
+ return {
384
+ ...base,
385
+ match: lower(spec.match, objectiveId),
386
+ probes: checkProbes(objectiveId, spec.match, spec.probes),
387
+ };
388
+ }
389
+ const term = spec.sector ?? refuse(`(${objectiveId}) has neither \`match\` nor \`sector\`.`);
390
+ const sector: SectorTerm =
391
+ "has" in term
392
+ ? { has: lower(term.has, objectiveId) }
393
+ : "oneRoot" in term
394
+ ? { oneRoot: true }
395
+ : { oneHost: globsOf(term.oneHost).map(asPath) };
396
+ return {
397
+ ...base,
398
+ sector,
399
+ probes:
400
+ "has" in term
401
+ ? checkProbes(objectiveId, term.has, spec.probes)
402
+ : {
403
+ fires: [...(spec.probes?.fires ?? [])],
404
+ ignores: [...(spec.probes?.ignores ?? [])],
405
+ },
406
+ };
407
+ },
408
+ );
409
+
410
+ // The perimeter, its globs resolved. A `match` perimeter is proven like
411
+ // an objective; the loader adds the end-shape check, which needs the
412
+ // objectives evaluated on the probe.
413
+ const perimeterSpec = campaign.perimeter;
414
+ const perimeter: PerimeterRule | undefined =
415
+ perimeterSpec === undefined
416
+ ? undefined
417
+ : perimeterSpec === "file"
418
+ ? { kind: "file" }
419
+ : perimeterSpec === "nx"
420
+ ? { kind: "nx" }
421
+ : "glob" in perimeterSpec
422
+ ? { kind: "glob", glob: globsOf(perimeterSpec.glob).map(asPath) }
423
+ : "marker" in perimeterSpec
424
+ ? {
425
+ kind: "marker",
426
+ marker: asWhole(perimeterSpec.marker),
427
+ ...(perimeterSpec.probes === undefined
428
+ ? {}
429
+ : {
430
+ probes: {
431
+ fires: globsOf(perimeterSpec.probes.fires ?? []),
432
+ ignores: globsOf(perimeterSpec.probes.ignores ?? []),
433
+ },
434
+ }),
435
+ }
436
+ : {
437
+ kind: "match",
438
+ match: lower(perimeterSpec.match, "perimeter"),
439
+ unit: perimeterSpec.holdout ?? "declaration",
440
+ probes: checkProbes("perimeter", perimeterSpec.match, perimeterSpec.probes),
441
+ };
442
+ if (perimeter?.kind === "match" && perimeter.probes.fires.length === 0) {
443
+ refuse(
444
+ `has a \`match\` perimeter with no \`probes.fires\`. A perimeter is the sector's identity ` +
445
+ `across every phase, so it is proven on a sector in its end shape as well as its first.`,
446
+ );
447
+ }
448
+
449
+ // The phases. An `endState` written on the campaign belongs to the last
450
+ // phase; a campaign with none and an end state gets one phase, `end`.
451
+ const phaseSpecs: Array<PhaseSpec> = [...(campaign.phases ?? [])];
452
+ if (campaign.endState !== undefined) {
453
+ const last = phaseSpecs.at(-1);
454
+ if (last === undefined) phaseSpecs.push({ id: "end", endState: campaign.endState });
455
+ else if (last.endState !== undefined) {
456
+ refuse(`writes an \`endState\` on the campaign and on its last phase; write one.`);
457
+ } else phaseSpecs[phaseSpecs.length - 1] = { ...last, endState: campaign.endState };
458
+ }
459
+ const objectiveIds = new Set(objectives.map((one) => one.id));
460
+ const namedBy = new Map<string, string>();
461
+ const phases: Array<PhaseRule> = [];
462
+ phaseSpecs.forEach((phase, index) => {
463
+ if (phases.some((one) => one.id === phase.id)) {
464
+ refuse(`names the phase "${phase.id}" twice.`);
465
+ }
466
+ for (const objectiveId of phase.objectives ?? []) {
467
+ if (!objectiveIds.has(objectiveId)) {
468
+ refuse(
469
+ `phase "${phase.id}" names an objective "${objectiveId}" the campaign does not declare.`,
470
+ );
471
+ }
472
+ const already = namedBy.get(objectiveId);
473
+ if (already !== undefined) {
474
+ refuse(
475
+ `names the objective "${objectiveId}" in two phases ("${already}" and "${phase.id}"); ` +
476
+ `an objective belongs to at most one.`,
477
+ );
478
+ }
479
+ namedBy.set(objectiveId, phase.id);
480
+ }
481
+ const expanded: Array<string> = [...(phase.objectives ?? [])];
482
+ if (phase.endState !== undefined) {
483
+ const families = endStateFamiliesOf(phase.endState, resolve, languages);
484
+ if (families.length === 0) {
485
+ refuse(`phase "${phase.id}" carries an \`endState\` that lowers to no rule at all.`);
486
+ }
487
+ for (const family of families) {
488
+ const objectiveId = endStateObjectiveId(family);
489
+ if (objectiveIds.has(objectiveId)) {
490
+ refuse(`declares an objective "${objectiveId}", which its endState needs for itself.`);
491
+ }
492
+ objectiveIds.add(objectiveId);
493
+ namedBy.set(objectiveId, phase.id);
494
+ expanded.push(objectiveId);
495
+ objectives.push({
496
+ name: `campaign/${id}/${objectiveId}`,
497
+ id: objectiveId,
498
+ campaign: id,
499
+ message:
500
+ campaign.how ??
501
+ `The sector's end state, as phase "${phase.id}" writes it: a ${family} violation against the sector-relative tree.`,
502
+ holdout: "declaration",
503
+ endState: { phase: phase.id, family },
504
+ probes: { fires: [], ignores: [] },
505
+ });
506
+ }
507
+ }
508
+ const defined = expanded.length > 0 || phase.attested === true;
509
+ if (!defined && phase.intent === undefined) {
510
+ refuse(
511
+ `phase "${phase.id}" names no objective and states no \`intent\`. An open phase is a ` +
512
+ `stated intent; a defined one names at least one objective (or is attested).`,
513
+ );
514
+ }
515
+ if (!defined && index !== phaseSpecs.length - 1) {
516
+ refuse(
517
+ `phase "${phase.id}" is open (it names no objective) and is not last. A plan that has ` +
518
+ `not been written cannot sit in the middle of one that has.`,
519
+ );
520
+ }
521
+ phases.push({
522
+ id: phase.id,
523
+ ...(phase.intent === undefined ? {} : { intent: phase.intent }),
524
+ objectives: expanded,
525
+ attested: phase.attested === true,
526
+ ...(phase.onTouch === undefined ? {} : { onTouch: phase.onTouch }),
527
+ ...(phase.endState === undefined ? {} : { endState: phase.endState }),
528
+ concessions: [...(phase.concessions ?? [])],
529
+ hash: "",
530
+ });
531
+ });
532
+ // A window: from the phase that names the objective until the phase
533
+ // `until` names, exclusive. Refused when `until` names no phase, or one
534
+ // at or before the naming phase — the two ways a remove or a reorder
535
+ // leaves a window empty.
536
+ for (const objective of objectives) {
537
+ if (objective.until === undefined) continue;
538
+ const to = phases.findIndex((one) => one.id === objective.until);
539
+ if (to === -1) {
540
+ refuse(
541
+ `objective "${objective.id}" runs \`until: ${objective.until}\`, and no phase has that id.`,
542
+ );
543
+ }
544
+ const from = phases.findIndex((one) => one.id === namedBy.get(objective.id));
545
+ if (from !== -1 && to <= from) {
546
+ refuse(
547
+ `objective "${objective.id}" is named by phase "${phases[from]?.id ?? ""}" and runs ` +
548
+ `\`until: ${objective.until}\`, which is not after it; the window is empty.`,
549
+ );
550
+ }
551
+ }
552
+ const hashed = phases.map((phase, index) => ({
553
+ ...phase,
554
+ hash: digest({
555
+ index,
556
+ id: phase.id,
557
+ attested: phase.attested,
558
+ endState: phase.endState ?? null,
559
+ objectives: phase.objectives.map((objectiveId) => {
560
+ const objective = objectives.find((one) => one.id === objectiveId);
561
+ return {
562
+ id: objectiveId,
563
+ holdout: objective?.holdout,
564
+ match: objective?.match ?? null,
565
+ sector: objective?.sector ?? null,
566
+ until: objective?.until ?? null,
567
+ };
568
+ }),
569
+ }),
570
+ }));
571
+ // The end is the last phase, so a change to it is a change to that
572
+ // phase's hash already; a campaign with no phases has an end that is
573
+ // every objective, digested as a phase of its own would be.
574
+ if (perimeter?.kind === "marker" && hashed.some((phase) => phase.endState !== undefined)) {
575
+ // A marker may list the globs it owns, so its sectors may have several
576
+ // roots — and a sector-relative tree needs one. Refused at lowering,
577
+ // where the two are in view together; `one-root` as an earlier phase's
578
+ // objective is how a campaign gets there.
579
+ refuse(
580
+ `carries an \`endState\` over a \`marker\` perimeter. A marker may list the globs it owns, ` +
581
+ `so a sector may have several roots, and a sector-relative tree needs one: use a ` +
582
+ `\`glob\`, \`file\` or \`nx\` perimeter, or reach one root first with a \`oneRoot\` objective.`,
583
+ );
584
+ }
585
+
586
+ return {
587
+ name: `campaign/${id}`,
588
+ id,
589
+ ...(campaign.title === undefined ? {} : { title: campaign.title }),
590
+ ...(campaign.why === undefined ? {} : { why: campaign.why }),
591
+ ...(campaign.owner === undefined ? {} : { owner: campaign.owner }),
592
+ scope: scopeGlobs.map(asPath),
593
+ extensions,
594
+ ...(campaign.legacy === undefined ? {} : { legacy: globsOf(campaign.legacy).map(asPath) }),
595
+ ...(perimeter === undefined ? {} : { perimeter }),
596
+ ...(campaign.onTouch === undefined ? {} : { onTouch: campaign.onTouch }),
597
+ phases: hashed,
598
+ objectives,
599
+ ...(campaign.staleAfter === undefined ? {} : { staleAfter: durationMs(campaign.staleAfter) }),
600
+ onComplete: campaign.onComplete ?? "keep",
601
+ };
602
+ };
603
+
604
+ // Every campaign in the manifest, lowered. The one entry point the loader
605
+ // extension calls: the core hands over what it decoded of the rest of the
606
+ // manifest (`aliases`, `resolve`) and the language packs it was composed
607
+ // with, and gets back the rules the evaluators compile.
608
+ export const lowerCampaigns = (
609
+ spec: CampaignsManifest,
610
+ config: Pick<Manifest, "aliases" | "resolve">,
611
+ languages: ReadonlyArray<ProbeLanguage>,
612
+ ): ReadonlyArray<CampaignRule> =>
613
+ Object.entries(spec.campaigns ?? {}).map(([id, campaign]) =>
614
+ lowerCampaign(id, campaign, config.aliases ?? {}, config.resolve, languages),
615
+ );