@goodbones/campaigns 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/build/dts/core/campaign-state.d.ts +38 -0
- package/build/dts/core/campaign-state.d.ts.map +1 -0
- package/build/dts/core/campaigns.d.ts +196 -0
- package/build/dts/core/campaigns.d.ts.map +1 -0
- package/build/dts/core/ledger.d.ts +173 -0
- package/build/dts/core/ledger.d.ts.map +1 -0
- package/build/dts/core/phases.d.ts +26 -0
- package/build/dts/core/phases.d.ts.map +1 -0
- package/build/dts/core/sectors.d.ts +52 -0
- package/build/dts/core/sectors.d.ts.map +1 -0
- package/build/dts/domain/config.d.ts +985 -0
- package/build/dts/domain/config.d.ts.map +1 -0
- package/build/dts/domain/report.d.ts +16 -0
- package/build/dts/domain/report.d.ts.map +1 -0
- package/build/dts/host/campaigns.d.ts +195 -0
- package/build/dts/host/campaigns.d.ts.map +1 -0
- package/build/dts/host/diff.d.ts +31 -0
- package/build/dts/host/diff.d.ts.map +1 -0
- package/build/dts/index.d.ts +17 -0
- package/build/dts/index.d.ts.map +1 -0
- package/build/dts/infrastructure/campaign-functions.d.ts +7 -0
- package/build/dts/infrastructure/campaign-functions.d.ts.map +1 -0
- package/build/dts/infrastructure/report-source-fake.d.ts +4 -0
- package/build/dts/infrastructure/report-source-fake.d.ts.map +1 -0
- package/build/dts/infrastructure/report-source-live.d.ts +3 -0
- package/build/dts/infrastructure/report-source-live.d.ts.map +1 -0
- package/build/dts/load/decode.d.ts +7 -0
- package/build/dts/load/decode.d.ts.map +1 -0
- package/build/dts/load/extension.d.ts +25 -0
- package/build/dts/load/extension.d.ts.map +1 -0
- package/build/dts/manifest/lower.d.ts +14 -0
- package/build/dts/manifest/lower.d.ts.map +1 -0
- package/build/dts/manifest/spec.d.ts +515 -0
- package/build/dts/manifest/spec.d.ts.map +1 -0
- package/build/dts/ports/campaign-predicate.d.ts +23 -0
- package/build/dts/ports/campaign-predicate.d.ts.map +1 -0
- package/build/dts/ports/report-source.d.ts +14 -0
- package/build/dts/ports/report-source.d.ts.map +1 -0
- package/build/dts/testing.d.ts +2 -0
- package/build/dts/testing.d.ts.map +1 -0
- package/build/esm/core/campaign-state.js +140 -0
- package/build/esm/core/campaign-state.js.map +1 -0
- package/build/esm/core/campaigns.js +954 -0
- package/build/esm/core/campaigns.js.map +1 -0
- package/build/esm/core/ledger.js +519 -0
- package/build/esm/core/ledger.js.map +1 -0
- package/build/esm/core/phases.js +87 -0
- package/build/esm/core/phases.js.map +1 -0
- package/build/esm/core/sectors.js +313 -0
- package/build/esm/core/sectors.js.map +1 -0
- package/build/esm/domain/config.js +245 -0
- package/build/esm/domain/config.js.map +1 -0
- package/build/esm/domain/report.js +168 -0
- package/build/esm/domain/report.js.map +1 -0
- package/build/esm/host/campaigns.js +1159 -0
- package/build/esm/host/campaigns.js.map +1 -0
- package/build/esm/host/diff.js +156 -0
- package/build/esm/host/diff.js.map +1 -0
- package/build/esm/index.js +27 -0
- package/build/esm/index.js.map +1 -0
- package/build/esm/infrastructure/campaign-functions.js +67 -0
- package/build/esm/infrastructure/campaign-functions.js.map +1 -0
- package/build/esm/infrastructure/report-source-fake.js +6 -0
- package/build/esm/infrastructure/report-source-fake.js.map +1 -0
- package/build/esm/infrastructure/report-source-live.js +165 -0
- package/build/esm/infrastructure/report-source-live.js.map +1 -0
- package/build/esm/load/decode.js +60 -0
- package/build/esm/load/decode.js.map +1 -0
- package/build/esm/load/extension.js +326 -0
- package/build/esm/load/extension.js.map +1 -0
- package/build/esm/manifest/lower.js +488 -0
- package/build/esm/manifest/lower.js.map +1 -0
- package/build/esm/manifest/spec.js +296 -0
- package/build/esm/manifest/spec.js.map +1 -0
- package/build/esm/ports/campaign-predicate.js +2 -0
- package/build/esm/ports/campaign-predicate.js.map +1 -0
- package/build/esm/ports/report-source.js +7 -0
- package/build/esm/ports/report-source.js.map +1 -0
- package/build/esm/testing.js +5 -0
- package/build/esm/testing.js.map +1 -0
- package/package.json +65 -0
- package/src/core/campaign-state.ts +227 -0
- package/src/core/campaigns.ts +1319 -0
- package/src/core/ledger.ts +675 -0
- package/src/core/phases.ts +138 -0
- package/src/core/sectors.ts +399 -0
- package/src/domain/config.ts +314 -0
- package/src/domain/report.ts +203 -0
- package/src/host/campaigns.ts +1664 -0
- package/src/host/diff.ts +203 -0
- package/src/index.ts +256 -0
- package/src/infrastructure/campaign-functions.ts +99 -0
- package/src/infrastructure/report-source-fake.ts +10 -0
- package/src/infrastructure/report-source-live.ts +193 -0
- package/src/load/decode.ts +81 -0
- package/src/load/extension.ts +466 -0
- package/src/manifest/lower.ts +615 -0
- package/src/manifest/spec.ts +364 -0
- package/src/ports/campaign-predicate.ts +29 -0
- package/src/ports/report-source.ts +35 -0
- package/src/testing.ts +4 -0
|
@@ -0,0 +1,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
|
+
});
|