@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,954 @@
|
|
|
1
|
+
import { compilePatterns, ImportUnresolved, PatternInvalid, probeTargetOf, siblingsOf, } from "@goodbones/core";
|
|
2
|
+
import * as Result from "effect/Result";
|
|
3
|
+
const targetOf = (name, field, resolves) => {
|
|
4
|
+
if (typeof resolves === "string") {
|
|
5
|
+
const compiled = compilePatterns(name, field, resolves);
|
|
6
|
+
if (Result.isFailure(compiled))
|
|
7
|
+
return Result.fail(compiled.failure);
|
|
8
|
+
const [pattern] = compiled.success;
|
|
9
|
+
return pattern === undefined
|
|
10
|
+
? Result.fail(new PatternInvalid({ ruleName: name, field, pattern: resolves, detail: "empty pattern" }))
|
|
11
|
+
: Result.succeed({ kind: "path", pattern });
|
|
12
|
+
}
|
|
13
|
+
if ("external" in resolves)
|
|
14
|
+
return Result.succeed({ kind: "external", name: resolves.external });
|
|
15
|
+
return Result.succeed({ kind: "builtin", name: resolves.builtin });
|
|
16
|
+
};
|
|
17
|
+
const compileDetector = (name, detector) => {
|
|
18
|
+
const compileAll = (terms) => {
|
|
19
|
+
const compiled = [];
|
|
20
|
+
for (const term of terms) {
|
|
21
|
+
const one = compileDetector(name, term);
|
|
22
|
+
if (Result.isFailure(one))
|
|
23
|
+
return Result.fail(one.failure);
|
|
24
|
+
compiled.push(one.success);
|
|
25
|
+
}
|
|
26
|
+
return Result.succeed(compiled);
|
|
27
|
+
};
|
|
28
|
+
const patterns = (field, value) => compilePatterns(name, field, value);
|
|
29
|
+
if ("all" in detector) {
|
|
30
|
+
const terms = compileAll(detector.all);
|
|
31
|
+
return Result.isFailure(terms)
|
|
32
|
+
? Result.fail(terms.failure)
|
|
33
|
+
: Result.succeed({ kind: "all", terms: terms.success });
|
|
34
|
+
}
|
|
35
|
+
if ("any" in detector) {
|
|
36
|
+
const terms = compileAll(detector.any);
|
|
37
|
+
return Result.isFailure(terms)
|
|
38
|
+
? Result.fail(terms.failure)
|
|
39
|
+
: Result.succeed({ kind: "any", terms: terms.success });
|
|
40
|
+
}
|
|
41
|
+
if ("not" in detector) {
|
|
42
|
+
const term = compileDetector(name, detector.not);
|
|
43
|
+
return Result.isFailure(term)
|
|
44
|
+
? Result.fail(term.failure)
|
|
45
|
+
: Result.succeed({ kind: "not", term: term.success });
|
|
46
|
+
}
|
|
47
|
+
if ("path" in detector) {
|
|
48
|
+
const file = patterns("path.file", detector.path.file);
|
|
49
|
+
if (Result.isFailure(file))
|
|
50
|
+
return Result.fail(file.failure);
|
|
51
|
+
const fileNot = patterns("path.fileNot", detector.path.fileNot);
|
|
52
|
+
if (Result.isFailure(fileNot))
|
|
53
|
+
return Result.fail(fileNot.failure);
|
|
54
|
+
const convention = patterns("path.convention", detector.path.convention);
|
|
55
|
+
if (Result.isFailure(convention))
|
|
56
|
+
return Result.fail(convention.failure);
|
|
57
|
+
return Result.succeed({
|
|
58
|
+
kind: "path",
|
|
59
|
+
file: file.success,
|
|
60
|
+
fileNot: fileNot.success,
|
|
61
|
+
subject: detector.path.subject ?? null,
|
|
62
|
+
convention: convention.success[0] ?? null,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if ("imports" in detector) {
|
|
66
|
+
const resolves = targetOf(name, "imports.resolves", detector.imports.resolves);
|
|
67
|
+
if (Result.isFailure(resolves))
|
|
68
|
+
return Result.fail(resolves.failure);
|
|
69
|
+
return Result.succeed({
|
|
70
|
+
kind: "imports",
|
|
71
|
+
resolves: resolves.success,
|
|
72
|
+
symbols: detector.imports.symbols === undefined ? null : [...detector.imports.symbols],
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if ("exports" in detector) {
|
|
76
|
+
const named = patterns("exports.name", detector.exports.name);
|
|
77
|
+
if (Result.isFailure(named))
|
|
78
|
+
return Result.fail(named.failure);
|
|
79
|
+
return Result.succeed({
|
|
80
|
+
kind: "exports",
|
|
81
|
+
name: named.success,
|
|
82
|
+
kinds: detector.exports.kinds === undefined ? null : [...detector.exports.kinds],
|
|
83
|
+
declares: detector.exports.declares === undefined ? null : [...detector.exports.declares],
|
|
84
|
+
reexport: detector.exports.reexport ?? null,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
if ("members" in detector) {
|
|
88
|
+
const named = patterns("members.name", detector.members.name);
|
|
89
|
+
if (Result.isFailure(named))
|
|
90
|
+
return Result.fail(named.failure);
|
|
91
|
+
const inside = patterns("members.in", detector.members.in);
|
|
92
|
+
if (Result.isFailure(inside))
|
|
93
|
+
return Result.fail(inside.failure);
|
|
94
|
+
return Result.succeed({
|
|
95
|
+
kind: "members",
|
|
96
|
+
subject: detector.members.subject,
|
|
97
|
+
name: named.success,
|
|
98
|
+
in: inside.success,
|
|
99
|
+
declares: detector.members.declares === undefined ? null : [...detector.members.declares],
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if ("requires" in detector) {
|
|
103
|
+
return Result.succeed({ kind: "requires", templates: [...detector.requires] });
|
|
104
|
+
}
|
|
105
|
+
if ("content" in detector) {
|
|
106
|
+
// The codec refuses a bare string here; a caller that built the rule by
|
|
107
|
+
// hand can still pass one, and `new RegExp(undefined)` is `/(?:)/`, which
|
|
108
|
+
// matches every file. A detector that fires on everything is refused,
|
|
109
|
+
// not compiled.
|
|
110
|
+
const source = detector.content.regex;
|
|
111
|
+
if (typeof source !== "string") {
|
|
112
|
+
return Result.fail(new PatternInvalid({
|
|
113
|
+
ruleName: name,
|
|
114
|
+
field: "content.regex",
|
|
115
|
+
pattern: String(source),
|
|
116
|
+
detail: "a content term is { regex: <string> }",
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
let regex;
|
|
120
|
+
try {
|
|
121
|
+
regex = new RegExp(source, "m");
|
|
122
|
+
}
|
|
123
|
+
catch (cause) {
|
|
124
|
+
return Result.fail(new PatternInvalid({
|
|
125
|
+
ruleName: name,
|
|
126
|
+
field: "content.regex",
|
|
127
|
+
pattern: detector.content.regex,
|
|
128
|
+
detail: String(cause),
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
return Result.succeed({ kind: "content", regex });
|
|
132
|
+
}
|
|
133
|
+
if ("syntax" in detector) {
|
|
134
|
+
const where = [];
|
|
135
|
+
for (const [capture, narrowing] of Object.entries(detector.syntax.where ?? {})) {
|
|
136
|
+
const regex = patterns(`syntax.where.${capture}.regex`, narrowing.regex);
|
|
137
|
+
if (Result.isFailure(regex))
|
|
138
|
+
return Result.fail(regex.failure);
|
|
139
|
+
let binding = null;
|
|
140
|
+
if (narrowing.binding !== undefined) {
|
|
141
|
+
const resolves = targetOf(name, `syntax.where.${capture}.binding.resolves`, narrowing.binding.resolves);
|
|
142
|
+
if (Result.isFailure(resolves))
|
|
143
|
+
return Result.fail(resolves.failure);
|
|
144
|
+
binding = {
|
|
145
|
+
resolves: resolves.success,
|
|
146
|
+
member: narrowing.binding.member === undefined ? null : [...narrowing.binding.member],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
where.push({ capture, regex: regex.success[0] ?? null, binding });
|
|
150
|
+
}
|
|
151
|
+
return Result.succeed({ kind: "syntax", rule: detector.syntax.rule, where });
|
|
152
|
+
}
|
|
153
|
+
if ("report" in detector) {
|
|
154
|
+
const term = detector.report;
|
|
155
|
+
if (term.pattern !== undefined) {
|
|
156
|
+
const pattern = patterns("report.pattern", term.pattern);
|
|
157
|
+
if (Result.isFailure(pattern))
|
|
158
|
+
return Result.fail(pattern.failure);
|
|
159
|
+
}
|
|
160
|
+
return Result.succeed({
|
|
161
|
+
kind: "report",
|
|
162
|
+
command: term.command ?? null,
|
|
163
|
+
file: term.file ?? null,
|
|
164
|
+
format: term.format,
|
|
165
|
+
pattern: term.pattern ?? null,
|
|
166
|
+
codes: term.codes === undefined ? null : new Set(term.codes),
|
|
167
|
+
codesNot: new Set(term.codesNot ?? []),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return Result.succeed({ kind: "fn", name: detector.fn });
|
|
171
|
+
};
|
|
172
|
+
export const compileObjective = (rule) => {
|
|
173
|
+
let detect = null;
|
|
174
|
+
if (rule.match !== undefined) {
|
|
175
|
+
const compiled = compileDetector(rule.name, rule.match);
|
|
176
|
+
if (Result.isFailure(compiled))
|
|
177
|
+
return Result.fail(compiled.failure);
|
|
178
|
+
detect = compiled.success;
|
|
179
|
+
}
|
|
180
|
+
let sector = null;
|
|
181
|
+
if (rule.sector !== undefined) {
|
|
182
|
+
if ("has" in rule.sector) {
|
|
183
|
+
const compiled = compileDetector(rule.name, rule.sector.has);
|
|
184
|
+
if (Result.isFailure(compiled))
|
|
185
|
+
return Result.fail(compiled.failure);
|
|
186
|
+
sector = { kind: "has", detect: compiled.success };
|
|
187
|
+
}
|
|
188
|
+
else if ("oneRoot" in rule.sector) {
|
|
189
|
+
sector = { kind: "oneRoot" };
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
const hosts = compilePatterns(rule.name, "sector.oneHost", rule.sector.oneHost);
|
|
193
|
+
if (Result.isFailure(hosts))
|
|
194
|
+
return Result.fail(hosts.failure);
|
|
195
|
+
sector = { kind: "oneHost", hosts: hosts.success };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return Result.succeed({
|
|
199
|
+
name: rule.name,
|
|
200
|
+
id: rule.id,
|
|
201
|
+
campaign: rule.campaign,
|
|
202
|
+
message: rule.message,
|
|
203
|
+
why: rule.why ?? null,
|
|
204
|
+
holdout: rule.holdout,
|
|
205
|
+
unit: rule.holdout === "sector" ? "declaration" : rule.holdout,
|
|
206
|
+
detect,
|
|
207
|
+
sector,
|
|
208
|
+
endState: rule.endState ?? null,
|
|
209
|
+
until: rule.until ?? null,
|
|
210
|
+
probes: rule.probes,
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
const compilePerimeter = (name, perimeter) => {
|
|
214
|
+
switch (perimeter.kind) {
|
|
215
|
+
case "file":
|
|
216
|
+
case "nx":
|
|
217
|
+
return Result.succeed({ kind: perimeter.kind });
|
|
218
|
+
case "glob": {
|
|
219
|
+
const glob = compilePatterns(name, "perimeter.glob", perimeter.glob);
|
|
220
|
+
return Result.isFailure(glob)
|
|
221
|
+
? Result.fail(glob.failure)
|
|
222
|
+
: Result.succeed({ kind: "glob", glob: glob.success });
|
|
223
|
+
}
|
|
224
|
+
case "marker": {
|
|
225
|
+
const marker = compilePatterns(name, "perimeter.marker", perimeter.marker);
|
|
226
|
+
if (Result.isFailure(marker))
|
|
227
|
+
return Result.fail(marker.failure);
|
|
228
|
+
return Result.succeed({
|
|
229
|
+
kind: "marker",
|
|
230
|
+
marker: marker.success,
|
|
231
|
+
probes: perimeter.probes === undefined
|
|
232
|
+
? null
|
|
233
|
+
: {
|
|
234
|
+
fires: [
|
|
235
|
+
...(typeof perimeter.probes.fires === "string"
|
|
236
|
+
? [perimeter.probes.fires]
|
|
237
|
+
: perimeter.probes.fires),
|
|
238
|
+
],
|
|
239
|
+
ignores: [
|
|
240
|
+
...(typeof perimeter.probes.ignores === "string"
|
|
241
|
+
? [perimeter.probes.ignores]
|
|
242
|
+
: perimeter.probes.ignores),
|
|
243
|
+
],
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
case "match": {
|
|
248
|
+
const detect = compileDetector(`${name}/perimeter`, perimeter.match);
|
|
249
|
+
if (Result.isFailure(detect))
|
|
250
|
+
return Result.fail(detect.failure);
|
|
251
|
+
return Result.succeed({
|
|
252
|
+
kind: "match",
|
|
253
|
+
detect: detect.success,
|
|
254
|
+
unit: perimeter.unit,
|
|
255
|
+
probes: perimeter.probes,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
export const compileCampaignRule = (rule) => {
|
|
261
|
+
const scope = compilePatterns(rule.name, "scope", rule.scope);
|
|
262
|
+
if (Result.isFailure(scope))
|
|
263
|
+
return Result.fail(scope.failure);
|
|
264
|
+
const legacy = compilePatterns(rule.name, "legacy", rule.legacy);
|
|
265
|
+
if (Result.isFailure(legacy))
|
|
266
|
+
return Result.fail(legacy.failure);
|
|
267
|
+
let perimeter = null;
|
|
268
|
+
if (rule.perimeter !== undefined) {
|
|
269
|
+
const compiled = compilePerimeter(rule.name, rule.perimeter);
|
|
270
|
+
if (Result.isFailure(compiled))
|
|
271
|
+
return Result.fail(compiled.failure);
|
|
272
|
+
perimeter = compiled.success;
|
|
273
|
+
}
|
|
274
|
+
const objectives = [];
|
|
275
|
+
for (const objective of rule.objectives) {
|
|
276
|
+
const one = compileObjective(objective);
|
|
277
|
+
if (Result.isFailure(one))
|
|
278
|
+
return Result.fail(one.failure);
|
|
279
|
+
objectives.push(one.success);
|
|
280
|
+
}
|
|
281
|
+
return Result.succeed({
|
|
282
|
+
name: rule.name,
|
|
283
|
+
id: rule.id,
|
|
284
|
+
title: rule.title ?? null,
|
|
285
|
+
why: rule.why ?? null,
|
|
286
|
+
owner: rule.owner ?? null,
|
|
287
|
+
scope: scope.success,
|
|
288
|
+
extensions: rule.extensions,
|
|
289
|
+
legacy: rule.legacy === undefined ? null : legacy.success,
|
|
290
|
+
perimeter,
|
|
291
|
+
onTouch: rule.onTouch ?? null,
|
|
292
|
+
phases: rule.phases,
|
|
293
|
+
objectives,
|
|
294
|
+
staleAfter: rule.staleAfter ?? null,
|
|
295
|
+
onComplete: rule.onComplete,
|
|
296
|
+
});
|
|
297
|
+
};
|
|
298
|
+
export const compileCampaignRules = (rules) => {
|
|
299
|
+
const compiled = [];
|
|
300
|
+
for (const rule of rules) {
|
|
301
|
+
const one = compileCampaignRule(rule);
|
|
302
|
+
if (Result.isFailure(one))
|
|
303
|
+
return Result.fail(one.failure);
|
|
304
|
+
compiled.push(one.success);
|
|
305
|
+
}
|
|
306
|
+
return Result.succeed(compiled);
|
|
307
|
+
};
|
|
308
|
+
const anyMatches = (patterns, value) => patterns.some((pattern) => pattern.test(value));
|
|
309
|
+
export const campaignsSelecting = (rules, file) => rules.filter((rule) => anyMatches(rule.scope, file));
|
|
310
|
+
// The objectives a host evaluates per file: those with a per-file detector.
|
|
311
|
+
export const perFileObjectivesOf = (rule) => rule.objectives.filter((objective) => objective.detect !== null);
|
|
312
|
+
// Whether any of these detectors reads the syntax tree — the parse a host
|
|
313
|
+
// skips when none does.
|
|
314
|
+
export const needsSyntax = (detectors) => detectors.some((detect) => leafTermsOf(detect).some((leaf) => leaf === "syntax" || leaf === "report" || leaf === "fn"));
|
|
315
|
+
// The leaf terms the compiled detector holds, in the order the evaluator
|
|
316
|
+
// ranks them by cost.
|
|
317
|
+
const LEAF_ORDER = [
|
|
318
|
+
"path",
|
|
319
|
+
"imports",
|
|
320
|
+
"exports",
|
|
321
|
+
"members",
|
|
322
|
+
"requires",
|
|
323
|
+
"content",
|
|
324
|
+
"syntax",
|
|
325
|
+
"report",
|
|
326
|
+
"fn",
|
|
327
|
+
];
|
|
328
|
+
const costOf = (detector) => {
|
|
329
|
+
switch (detector.kind) {
|
|
330
|
+
case "all":
|
|
331
|
+
case "any":
|
|
332
|
+
return Math.max(0, ...detector.terms.map(costOf));
|
|
333
|
+
case "not":
|
|
334
|
+
return costOf(detector.term);
|
|
335
|
+
default:
|
|
336
|
+
return LEAF_ORDER.indexOf(detector.kind);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
const byCost = (terms) => [...terms].sort((left, right) => costOf(left) - costOf(right));
|
|
340
|
+
export const leafTermsOf = (detector) => {
|
|
341
|
+
switch (detector.kind) {
|
|
342
|
+
case "all":
|
|
343
|
+
case "any":
|
|
344
|
+
return detector.terms.flatMap(leafTermsOf);
|
|
345
|
+
case "not":
|
|
346
|
+
return leafTermsOf(detector.term);
|
|
347
|
+
default:
|
|
348
|
+
return [detector.kind];
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
// A short, stable hash of a match's text, so a `match` subject survives the
|
|
352
|
+
// declaration around it being edited and changes only when the match does.
|
|
353
|
+
// FNV-1a, so the core needs no hashing library.
|
|
354
|
+
const contentHash = (text) => {
|
|
355
|
+
let hash = 0x811c9dc5;
|
|
356
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
357
|
+
hash ^= text.charCodeAt(i);
|
|
358
|
+
hash = Math.imul(hash, 0x01000193);
|
|
359
|
+
}
|
|
360
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
361
|
+
};
|
|
362
|
+
export const matchKeyOf = (anchor, text) => `${anchor ?? ""}#${contentHash(text)}`;
|
|
363
|
+
// Two matches with the same key — the same text twice in one declaration,
|
|
364
|
+
// the same diagnostic twice on one line — are two entries, not one: the
|
|
365
|
+
// second is `key~2`, the third `key~3`. The ordinal is positional only among
|
|
366
|
+
// duplicates, so a ledger keeps its count under any edit that leaves them
|
|
367
|
+
// duplicates.
|
|
368
|
+
const uniqueKeys = () => {
|
|
369
|
+
const seen = new Map();
|
|
370
|
+
return (key) => {
|
|
371
|
+
const count = (seen.get(key) ?? 0) + 1;
|
|
372
|
+
seen.set(key, count);
|
|
373
|
+
return count === 1 ? key : `${key}~${String(count)}`;
|
|
374
|
+
};
|
|
375
|
+
};
|
|
376
|
+
const targetMatches = (target, resolved) => {
|
|
377
|
+
switch (target.kind) {
|
|
378
|
+
case "path":
|
|
379
|
+
return target.pattern.test(resolved.path);
|
|
380
|
+
case "external":
|
|
381
|
+
return resolved.kind === "external" && resolved.package === target.name;
|
|
382
|
+
case "builtin":
|
|
383
|
+
return resolved.kind === "builtin" && resolved.path === target.name;
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
const exportGoverned = (term, site) => {
|
|
387
|
+
if (term.kinds !== null && !term.kinds.includes(site.kind))
|
|
388
|
+
return false;
|
|
389
|
+
if (term.declares !== null && !term.declares.includes(site.declares))
|
|
390
|
+
return false;
|
|
391
|
+
if (term.reexport !== null && term.reexport !== site.reexport)
|
|
392
|
+
return false;
|
|
393
|
+
if (term.name.length > 0 && !anyMatches(term.name, site.name))
|
|
394
|
+
return false;
|
|
395
|
+
return true;
|
|
396
|
+
};
|
|
397
|
+
const memberGoverned = (term, site) => {
|
|
398
|
+
if (term.subject !== site.subject)
|
|
399
|
+
return false;
|
|
400
|
+
if (term.in.length > 0 && (site.in === undefined || !anyMatches(term.in, site.in)))
|
|
401
|
+
return false;
|
|
402
|
+
if (term.declares !== null &&
|
|
403
|
+
(site.declares === undefined || !term.declares.includes(site.declares))) {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
if (term.name.length > 0 && !anyMatches(term.name, site.name))
|
|
407
|
+
return false;
|
|
408
|
+
return true;
|
|
409
|
+
};
|
|
410
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*/;
|
|
411
|
+
const ACCESSED = /^\s*\.\s*([A-Za-z_$][\w$]*)/;
|
|
412
|
+
// A capture narrowed by what its root identifier is bound to: the binding
|
|
413
|
+
// with that local name, the module its edge resolves to, and the name it
|
|
414
|
+
// pulled across — the exported name for a named import, the member accessed
|
|
415
|
+
// for a default or namespace one.
|
|
416
|
+
const bindingAdmits = (narrowing, captured, input) => {
|
|
417
|
+
const root = IDENTIFIER.exec(captured)?.[0];
|
|
418
|
+
if (root === undefined)
|
|
419
|
+
return false;
|
|
420
|
+
for (const specifier of input.facts.specifiers) {
|
|
421
|
+
for (const binding of input.facts.bindings.get(specifier) ?? []) {
|
|
422
|
+
if (binding.local !== root)
|
|
423
|
+
continue;
|
|
424
|
+
const resolved = input.resolver.resolve(input.file, specifier);
|
|
425
|
+
if (Result.isFailure(resolved))
|
|
426
|
+
return false;
|
|
427
|
+
if (!targetMatches(narrowing.resolves, resolved.success))
|
|
428
|
+
return false;
|
|
429
|
+
if (narrowing.member === null)
|
|
430
|
+
return true;
|
|
431
|
+
const accessed = binding.kind === "named" ? binding.symbol : ACCESSED.exec(captured.slice(root.length))?.[1];
|
|
432
|
+
return accessed !== undefined && narrowing.member.includes(accessed);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return false;
|
|
436
|
+
};
|
|
437
|
+
const narrowed = (where, match, input) => where.every((one) => {
|
|
438
|
+
const captured = match.captures.get(one.capture);
|
|
439
|
+
if (captured === undefined)
|
|
440
|
+
return false;
|
|
441
|
+
if (one.regex !== null && !one.regex.test(captured))
|
|
442
|
+
return false;
|
|
443
|
+
if (one.binding !== null && !bindingAdmits(one.binding, captured, input))
|
|
444
|
+
return false;
|
|
445
|
+
return true;
|
|
446
|
+
});
|
|
447
|
+
const declarationsOf = (names, ranges = new Map()) => {
|
|
448
|
+
const set = new Set(names);
|
|
449
|
+
return {
|
|
450
|
+
level: "declaration",
|
|
451
|
+
names: set,
|
|
452
|
+
candidates: [...set].map((name) => ({
|
|
453
|
+
key: name,
|
|
454
|
+
anchor: name,
|
|
455
|
+
range: ranges.get(name) ?? null,
|
|
456
|
+
})),
|
|
457
|
+
};
|
|
458
|
+
};
|
|
459
|
+
const matchesOf = (candidates) => ({
|
|
460
|
+
level: "match",
|
|
461
|
+
anchors: new Set(candidates.flatMap((one) => (one.anchor === null ? [] : [one.anchor]))),
|
|
462
|
+
candidates,
|
|
463
|
+
});
|
|
464
|
+
const evaluateLeaf = (term, input) => {
|
|
465
|
+
switch (term.kind) {
|
|
466
|
+
case "path": {
|
|
467
|
+
if (anyMatches(term.fileNot, input.file))
|
|
468
|
+
return { level: "file", holds: false };
|
|
469
|
+
for (const pattern of term.file) {
|
|
470
|
+
const found = pattern.exec(input.file);
|
|
471
|
+
if (found === null)
|
|
472
|
+
continue;
|
|
473
|
+
if (term.convention === null || term.subject === null) {
|
|
474
|
+
return { level: "file", holds: true };
|
|
475
|
+
}
|
|
476
|
+
const subject = found[term.subject];
|
|
477
|
+
return { level: "file", holds: subject !== undefined && term.convention.test(subject) };
|
|
478
|
+
}
|
|
479
|
+
return { level: "file", holds: false };
|
|
480
|
+
}
|
|
481
|
+
case "imports": {
|
|
482
|
+
for (const specifier of input.facts.specifiers) {
|
|
483
|
+
if (term.symbols !== null) {
|
|
484
|
+
const bound = input.facts.bindings.get(specifier) ?? [];
|
|
485
|
+
const wanted = term.symbols;
|
|
486
|
+
if (!bound.some((binding) => wanted.includes(binding.symbol)))
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
const resolved = input.resolver.resolve(input.file, specifier);
|
|
490
|
+
if (Result.isFailure(resolved))
|
|
491
|
+
continue;
|
|
492
|
+
if (targetMatches(term.resolves, resolved.success))
|
|
493
|
+
return { level: "file", holds: true };
|
|
494
|
+
}
|
|
495
|
+
return { level: "file", holds: false };
|
|
496
|
+
}
|
|
497
|
+
case "exports":
|
|
498
|
+
return declarationsOf(input.facts.exportSites
|
|
499
|
+
.filter((site) => exportGoverned(term, site))
|
|
500
|
+
.map((site) => site.name));
|
|
501
|
+
case "members": {
|
|
502
|
+
const sites = input.facts.memberSites.filter((site) => memberGoverned(term, site));
|
|
503
|
+
// A called name sits in no declaration the facts know, so a `calls`
|
|
504
|
+
// term is a statement about the file.
|
|
505
|
+
if (term.subject === "calls")
|
|
506
|
+
return { level: "file", holds: sites.length > 0 };
|
|
507
|
+
return declarationsOf(sites.flatMap((site) => (site.in === undefined ? [] : [site.in])));
|
|
508
|
+
}
|
|
509
|
+
case "requires":
|
|
510
|
+
return {
|
|
511
|
+
level: "file",
|
|
512
|
+
holds: siblingsOf(term.templates, input.file).every((sibling) => input.fileSystem.exists(sibling)),
|
|
513
|
+
};
|
|
514
|
+
case "content":
|
|
515
|
+
return { level: "file", holds: term.regex.test(input.text) };
|
|
516
|
+
case "syntax": {
|
|
517
|
+
if (input.syntax === null)
|
|
518
|
+
return matchesOf([]);
|
|
519
|
+
const unique = uniqueKeys();
|
|
520
|
+
const candidates = [];
|
|
521
|
+
for (const match of input.syntax.findAll(term.rule)) {
|
|
522
|
+
if (!narrowed(term.where, match, input))
|
|
523
|
+
continue;
|
|
524
|
+
candidates.push({
|
|
525
|
+
key: unique(matchKeyOf(match.anchor, match.text)),
|
|
526
|
+
anchor: match.anchor,
|
|
527
|
+
range: match.range,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
return matchesOf(candidates);
|
|
531
|
+
}
|
|
532
|
+
case "report": {
|
|
533
|
+
// Each diagnostic is a match: anchored on the declaration at its
|
|
534
|
+
// position (or none, at the top level or without a matcher), keyed by
|
|
535
|
+
// its code and a hash of its message — so an entry survives the line
|
|
536
|
+
// moving and changes when the message does.
|
|
537
|
+
const unique = uniqueKeys();
|
|
538
|
+
const candidates = [];
|
|
539
|
+
for (const diagnostic of input.reports.diagnosticsOf(specOf(term), input.file)) {
|
|
540
|
+
if (term.codes !== null && !term.codes.has(diagnostic.code))
|
|
541
|
+
continue;
|
|
542
|
+
if (term.codesNot.has(diagnostic.code))
|
|
543
|
+
continue;
|
|
544
|
+
const position = { line: diagnostic.line, column: diagnostic.column };
|
|
545
|
+
const anchor = input.syntax?.anchorAt(position) ?? null;
|
|
546
|
+
const code = diagnostic.code === "" ? "" : `${diagnostic.code}#`;
|
|
547
|
+
candidates.push({
|
|
548
|
+
key: unique(`${anchor ?? ""}#${code}${contentHash(diagnostic.message)}`),
|
|
549
|
+
anchor,
|
|
550
|
+
range: { start: position, end: position },
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
return matchesOf(candidates);
|
|
554
|
+
}
|
|
555
|
+
case "fn": {
|
|
556
|
+
const predicate = input.functions.get(term.name);
|
|
557
|
+
if (predicate === undefined)
|
|
558
|
+
return { level: "file", holds: false };
|
|
559
|
+
const answer = predicate({
|
|
560
|
+
file: input.file,
|
|
561
|
+
text: input.text,
|
|
562
|
+
facts: input.facts,
|
|
563
|
+
syntax: input.syntax,
|
|
564
|
+
});
|
|
565
|
+
if (typeof answer === "boolean")
|
|
566
|
+
return { level: "file", holds: answer };
|
|
567
|
+
return matchesOf(answer.map((one) => ({ key: one.subject, anchor: one.subject, range: one.range ?? null })));
|
|
568
|
+
}
|
|
569
|
+
default:
|
|
570
|
+
throw new Error(`not a leaf: ${term.kind}`);
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
// Whether every leaf under a node answers about the file, in which case the
|
|
574
|
+
// node has one boolean answer and an `all` may stop at it.
|
|
575
|
+
const isFileLevel = (term, unit) => unit === "file" ||
|
|
576
|
+
leafTermsOf(term).every((leaf) => leaf !== "exports" &&
|
|
577
|
+
leaf !== "members" &&
|
|
578
|
+
leaf !== "syntax" &&
|
|
579
|
+
leaf !== "report" &&
|
|
580
|
+
leaf !== "fn");
|
|
581
|
+
// Whether a leaf answer holds for one candidate. A file-level answer holds
|
|
582
|
+
// for every candidate or none; a declaration-level one for the candidate
|
|
583
|
+
// named (or, for a match, the one anchored there); a match-level one for the
|
|
584
|
+
// candidate keyed (or, for a declaration, the one some match is anchored in).
|
|
585
|
+
const holdsFor = (answer, candidate, unit) => {
|
|
586
|
+
switch (answer.level) {
|
|
587
|
+
case "file":
|
|
588
|
+
return answer.holds;
|
|
589
|
+
case "declaration":
|
|
590
|
+
return unit === "match"
|
|
591
|
+
? candidate.anchor !== null && answer.names.has(candidate.anchor)
|
|
592
|
+
: answer.names.has(candidate.key);
|
|
593
|
+
case "match":
|
|
594
|
+
return unit === "declaration"
|
|
595
|
+
? answer.anchors.has(candidate.key)
|
|
596
|
+
: answer.candidates.some((one) => one.key === candidate.key);
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
// In a `file` campaign every term is existential.
|
|
600
|
+
const holdsAtAll = (answer) => answer.level === "file" ? answer.holds : answer.candidates.length > 0;
|
|
601
|
+
const judge = (term, candidate, unit, evaluation) => {
|
|
602
|
+
switch (term.kind) {
|
|
603
|
+
case "all":
|
|
604
|
+
return term.terms.every((one) => judge(one, candidate, unit, evaluation));
|
|
605
|
+
case "any":
|
|
606
|
+
return term.terms.some((one) => judge(one, candidate, unit, evaluation));
|
|
607
|
+
case "not":
|
|
608
|
+
return !judge(term.term, candidate, unit, evaluation);
|
|
609
|
+
default: {
|
|
610
|
+
const answer = evaluation.leaf(term);
|
|
611
|
+
return candidate === null ? holdsAtAll(answer) : holdsFor(answer, candidate, unit);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
// The first pass: evaluate the leaves that decide anything, cheapest first.
|
|
616
|
+
// An `all` stops at a file-level child that fails — nothing after it can
|
|
617
|
+
// rescue the conjunction, and the terms after it are the expensive ones. An
|
|
618
|
+
// `any` in a `file` campaign stops at a child that holds; in the other units
|
|
619
|
+
// it goes on, because the other branches are where the candidates come from.
|
|
620
|
+
const prepare = (term, unit, evaluation) => {
|
|
621
|
+
switch (term.kind) {
|
|
622
|
+
case "all": {
|
|
623
|
+
for (const child of byCost(term.terms)) {
|
|
624
|
+
const verdict = prepare(child, unit, evaluation);
|
|
625
|
+
if (verdict === false)
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
case "any": {
|
|
631
|
+
for (const child of byCost(term.terms)) {
|
|
632
|
+
const verdict = prepare(child, unit, evaluation);
|
|
633
|
+
if (verdict === true && unit === "file")
|
|
634
|
+
return true;
|
|
635
|
+
}
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
case "not": {
|
|
639
|
+
const verdict = prepare(term.term, unit, evaluation);
|
|
640
|
+
return verdict === null ? null : !verdict;
|
|
641
|
+
}
|
|
642
|
+
default: {
|
|
643
|
+
if (!isFileLevel(term, unit)) {
|
|
644
|
+
evaluation.leaf(term);
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
return holdsAtAll(evaluation.leaf(term));
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
const FILE_CANDIDATE = { key: "", anchor: null, range: null };
|
|
652
|
+
const evaluationOf = (input) => {
|
|
653
|
+
const answers = new Map();
|
|
654
|
+
return {
|
|
655
|
+
answers,
|
|
656
|
+
leaf: (term) => {
|
|
657
|
+
const found = answers.get(term);
|
|
658
|
+
if (found !== undefined)
|
|
659
|
+
return found;
|
|
660
|
+
const answer = evaluateLeaf(term, input);
|
|
661
|
+
answers.set(term, answer);
|
|
662
|
+
return answer;
|
|
663
|
+
},
|
|
664
|
+
};
|
|
665
|
+
};
|
|
666
|
+
const universeOf = (evaluation, unit) => {
|
|
667
|
+
const byKey = new Map();
|
|
668
|
+
for (const answer of evaluation.answers.values()) {
|
|
669
|
+
if (answer.level === "file")
|
|
670
|
+
continue;
|
|
671
|
+
if (unit === "declaration" && answer.level === "match") {
|
|
672
|
+
// A match sources the declaration it sits in; one at the top level of
|
|
673
|
+
// the file sits in none and sources nothing.
|
|
674
|
+
for (const one of answer.candidates) {
|
|
675
|
+
if (one.anchor === null || byKey.has(one.anchor))
|
|
676
|
+
continue;
|
|
677
|
+
byKey.set(one.anchor, { key: one.anchor, anchor: one.anchor, range: one.range });
|
|
678
|
+
}
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
if (unit === "match" && answer.level === "declaration")
|
|
682
|
+
continue;
|
|
683
|
+
for (const one of answer.candidates)
|
|
684
|
+
if (!byKey.has(one.key))
|
|
685
|
+
byKey.set(one.key, one);
|
|
686
|
+
}
|
|
687
|
+
return [...byKey.values()].sort((left, right) => left.key.localeCompare(right.key));
|
|
688
|
+
};
|
|
689
|
+
const hitOf = (rule, file, candidate) => ({
|
|
690
|
+
violation: {
|
|
691
|
+
kind: "campaign",
|
|
692
|
+
ruleName: rule.name,
|
|
693
|
+
message: rule.message,
|
|
694
|
+
file,
|
|
695
|
+
subject: rule.unit === "file" ? null : candidate.key,
|
|
696
|
+
},
|
|
697
|
+
campaign: rule.campaign,
|
|
698
|
+
objective: rule.id,
|
|
699
|
+
...(candidate.range === null ? {} : { range: candidate.range }),
|
|
700
|
+
});
|
|
701
|
+
// The candidates a detector produces on one file at a unit, as the
|
|
702
|
+
// perimeter and the sector terms ask it — without an objective to hang a
|
|
703
|
+
// violation on.
|
|
704
|
+
export const candidatesOf = (detect, unit, input) => {
|
|
705
|
+
const evaluation = evaluationOf(input);
|
|
706
|
+
const verdict = prepare(detect, unit, evaluation);
|
|
707
|
+
if (unit === "file") {
|
|
708
|
+
const holds = verdict ?? judge(detect, null, "file", evaluation);
|
|
709
|
+
return holds ? [{ key: "", range: null }] : [];
|
|
710
|
+
}
|
|
711
|
+
if (verdict === false)
|
|
712
|
+
return [];
|
|
713
|
+
return universeOf(evaluation, unit)
|
|
714
|
+
.filter((candidate) => judge(detect, candidate, unit, evaluation))
|
|
715
|
+
.map((candidate) => ({ key: candidate.key, range: candidate.range }));
|
|
716
|
+
};
|
|
717
|
+
// One objective on one file. An objective with no per-file detector — a
|
|
718
|
+
// sector term, an end state — answers nothing here; the host that holds
|
|
719
|
+
// the sector's files answers it.
|
|
720
|
+
export const evaluateObjective = (rule, input) => {
|
|
721
|
+
if (rule.detect === null)
|
|
722
|
+
return [];
|
|
723
|
+
const detect = rule.detect;
|
|
724
|
+
const evaluation = evaluationOf(input);
|
|
725
|
+
const verdict = prepare(detect, rule.unit, evaluation);
|
|
726
|
+
if (rule.unit === "file") {
|
|
727
|
+
const holds = verdict ?? judge(detect, null, "file", evaluation);
|
|
728
|
+
return holds ? [hitOf(rule, input.file, FILE_CANDIDATE)] : [];
|
|
729
|
+
}
|
|
730
|
+
if (verdict === false)
|
|
731
|
+
return [];
|
|
732
|
+
return universeOf(evaluation, rule.unit)
|
|
733
|
+
.filter((candidate) => judge(detect, candidate, rule.unit, evaluation))
|
|
734
|
+
.map((candidate) => hitOf(rule, input.file, candidate));
|
|
735
|
+
};
|
|
736
|
+
// What a `report` term asks its source for: the command or file, the format
|
|
737
|
+
// and, for `regex`, the pattern. The codes are the term's own filter.
|
|
738
|
+
const specOf = (term) => ({
|
|
739
|
+
...(term.command === null ? {} : { command: term.command }),
|
|
740
|
+
...(term.file === null ? {} : { file: term.file }),
|
|
741
|
+
format: term.format,
|
|
742
|
+
...(term.pattern === null ? {} : { pattern: term.pattern }),
|
|
743
|
+
});
|
|
744
|
+
// Every distinct report the campaigns name, for a host that asks for them
|
|
745
|
+
// before any file: the plugin does, at load, because a `command` forks the
|
|
746
|
+
// process that asks, and the linter's process is one Linux can refuse to
|
|
747
|
+
// fork once it is linting — its per-thread AST buffers merge into a mapping
|
|
748
|
+
// larger than the machine has to give, and the heuristic overcommit mode
|
|
749
|
+
// refuses that at fork. At load, the process is small.
|
|
750
|
+
export const reportSpecsOf = (rules) => {
|
|
751
|
+
const seen = new Map();
|
|
752
|
+
const walk = (detector) => {
|
|
753
|
+
switch (detector.kind) {
|
|
754
|
+
case "all":
|
|
755
|
+
case "any":
|
|
756
|
+
for (const term of detector.terms)
|
|
757
|
+
walk(term);
|
|
758
|
+
return;
|
|
759
|
+
case "not":
|
|
760
|
+
walk(detector.term);
|
|
761
|
+
return;
|
|
762
|
+
case "report": {
|
|
763
|
+
const spec = specOf(detector);
|
|
764
|
+
seen.set(JSON.stringify(spec), spec);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
default:
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
for (const rule of rules) {
|
|
772
|
+
for (const objective of rule.objectives) {
|
|
773
|
+
if (objective.detect !== null)
|
|
774
|
+
walk(objective.detect);
|
|
775
|
+
if (objective.sector?.kind === "has")
|
|
776
|
+
walk(objective.sector.detect);
|
|
777
|
+
}
|
|
778
|
+
if (rule.perimeter?.kind === "match")
|
|
779
|
+
walk(rule.perimeter.detect);
|
|
780
|
+
}
|
|
781
|
+
return [...seen.values()];
|
|
782
|
+
};
|
|
783
|
+
export const evaluateObjectives = (selected, input) => selected.flatMap((rule) => evaluateObjective(rule, input));
|
|
784
|
+
const describeTerm = (term) => {
|
|
785
|
+
switch (term.kind) {
|
|
786
|
+
case "path":
|
|
787
|
+
return `path ${term.file.map((one) => `/${one.source}/`).join(" | ")}`;
|
|
788
|
+
case "imports":
|
|
789
|
+
return `imports ${describeTarget(term.resolves)}${term.symbols === null ? "" : ` { ${term.symbols.join(", ")} }`}`;
|
|
790
|
+
case "exports":
|
|
791
|
+
return `exports${term.name.length === 0 ? "" : ` ${term.name.map((one) => `/${one.source}/`).join(" | ")}`}${term.kinds === null ? "" : ` [${term.kinds.join(", ")}]`}`;
|
|
792
|
+
case "members":
|
|
793
|
+
return `members (${term.subject})${term.name.length === 0 ? "" : ` ${term.name.map((one) => `/${one.source}/`).join(" | ")}`}`;
|
|
794
|
+
case "requires":
|
|
795
|
+
return `requires ${term.templates.join(", ")}`;
|
|
796
|
+
case "content":
|
|
797
|
+
return `content /${term.regex.source}/`;
|
|
798
|
+
case "syntax":
|
|
799
|
+
return `syntax ${JSON.stringify(term.rule)}`;
|
|
800
|
+
case "report":
|
|
801
|
+
return `report ${term.format} ${term.command === null ? `file ${(term.file ?? []).join(", ")}` : term.command.map((one) => `\`${one}\``).join(", ")}${term.codes === null ? "" : ` [${[...term.codes].join(", ")}]`}`;
|
|
802
|
+
case "fn":
|
|
803
|
+
return `fn ${term.name}`;
|
|
804
|
+
default:
|
|
805
|
+
return term.kind;
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
const describeTarget = (target) => {
|
|
809
|
+
switch (target.kind) {
|
|
810
|
+
case "path":
|
|
811
|
+
return `/${target.pattern.source}/`;
|
|
812
|
+
case "external":
|
|
813
|
+
return `external ${target.name}`;
|
|
814
|
+
case "builtin":
|
|
815
|
+
return `builtin ${target.name}`;
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
// The detector an objective reads a file with: its own, or the one its
|
|
819
|
+
// `has` term quantifies over the sector's files.
|
|
820
|
+
export const detectorOf = (rule) => rule.detect ?? (rule.sector?.kind === "has" ? rule.sector.detect : null);
|
|
821
|
+
export const explainObjective = (rule, input) => explainDetector(detectorOf(rule), input);
|
|
822
|
+
export const explainDetector = (detect, input) => {
|
|
823
|
+
if (detect === null)
|
|
824
|
+
return [];
|
|
825
|
+
const evaluation = evaluationOf(input);
|
|
826
|
+
const lines = [];
|
|
827
|
+
const walk = (term, negated) => {
|
|
828
|
+
switch (term.kind) {
|
|
829
|
+
case "all":
|
|
830
|
+
case "any":
|
|
831
|
+
for (const one of term.terms)
|
|
832
|
+
walk(one, negated);
|
|
833
|
+
return;
|
|
834
|
+
case "not":
|
|
835
|
+
walk(term.term, !negated);
|
|
836
|
+
return;
|
|
837
|
+
default: {
|
|
838
|
+
const answer = evaluation.leaf(term);
|
|
839
|
+
lines.push({
|
|
840
|
+
term: `${negated ? "not " : ""}${describeTerm(term)}`,
|
|
841
|
+
answer: holdsAtAll(answer),
|
|
842
|
+
...(answer.level === "file" ? {} : { count: answer.candidates.length }),
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
walk(detect, false);
|
|
848
|
+
return lines;
|
|
849
|
+
};
|
|
850
|
+
// The input a probe stands in for: its edges answer the resolver, its files
|
|
851
|
+
// the file system, its source the extractor and the matcher.
|
|
852
|
+
const NOTHING = {
|
|
853
|
+
specifiers: [],
|
|
854
|
+
bindings: new Map(),
|
|
855
|
+
memberSites: [],
|
|
856
|
+
exportSites: [],
|
|
857
|
+
};
|
|
858
|
+
export const probeInputOf = (probe, extractor, matcher, functions) => {
|
|
859
|
+
const edges = probe.edges ?? {};
|
|
860
|
+
const files = new Set(probe.files ?? []);
|
|
861
|
+
const text = probe.source ?? "";
|
|
862
|
+
// The probe's diagnostics, one-based as written, on the probe's own file.
|
|
863
|
+
const reported = (probe.report ?? []).map((one) => ({
|
|
864
|
+
file: probe.path,
|
|
865
|
+
line: Math.max(0, one.line - 1),
|
|
866
|
+
column: Math.max(0, (one.column ?? 1) - 1),
|
|
867
|
+
code: one.code ?? "",
|
|
868
|
+
message: one.message ?? "",
|
|
869
|
+
}));
|
|
870
|
+
return {
|
|
871
|
+
file: probe.path,
|
|
872
|
+
text,
|
|
873
|
+
facts: probe.source === undefined ? NOTHING : extractor.factsOf(probe.path, probe.source),
|
|
874
|
+
resolver: {
|
|
875
|
+
resolve: (fromFile, specifier) => {
|
|
876
|
+
const target = edges[specifier];
|
|
877
|
+
return target === undefined
|
|
878
|
+
? Result.fail(new ImportUnresolved({ fromFile, specifier, detail: "not among the probe's edges" }))
|
|
879
|
+
: Result.succeed(probeTargetOf(target));
|
|
880
|
+
},
|
|
881
|
+
},
|
|
882
|
+
fileSystem: { exists: (at) => files.has(at), readText: () => null, list: () => [] },
|
|
883
|
+
syntax: probe.source === undefined || matcher === null ? null : matcher.parse(probe.path, text),
|
|
884
|
+
functions,
|
|
885
|
+
reports: { diagnosticsOf: (_spec, file) => (file === probe.path ? reported : []) },
|
|
886
|
+
};
|
|
887
|
+
};
|
|
888
|
+
// Every objective must fire on each of its `fires` probes and stay silent
|
|
889
|
+
// on each of its `ignores` — the same vacuity check every family makes,
|
|
890
|
+
// with the second half added because a detector is composed, and the term
|
|
891
|
+
// that admits too much is the one the author wants named. A `match`
|
|
892
|
+
// perimeter is proven the same way, plus one rule of its own: the perimeter
|
|
893
|
+
// is the sector's identity across every phase, so at least one of its
|
|
894
|
+
// `fires` probes must be a sector no objective fires on — its end shape —
|
|
895
|
+
// or the sector would be un-born the moment the first phase was met.
|
|
896
|
+
export const campaignsFailingTheirProbe = (rules, extractor, matcherFor, functions) => {
|
|
897
|
+
const failed = [];
|
|
898
|
+
for (const rule of rules) {
|
|
899
|
+
const check = (name, detect, unit, probe, expected) => {
|
|
900
|
+
if (!anyMatches(rule.scope, probe.path)) {
|
|
901
|
+
failed.push({ name, probe, expected, outOfScope: true });
|
|
902
|
+
return null;
|
|
903
|
+
}
|
|
904
|
+
const input = probeInputOf(probe, extractor, matcherFor(probe.path), functions);
|
|
905
|
+
const hits = candidatesOf(detect, unit, input);
|
|
906
|
+
if (expected === "fires" && hits.length === 0)
|
|
907
|
+
failed.push({ name, probe, expected });
|
|
908
|
+
if (expected === "ignores" && hits.length > 0) {
|
|
909
|
+
const admitting = explainDetector(detect, input).find((line) => line.answer);
|
|
910
|
+
failed.push({
|
|
911
|
+
name,
|
|
912
|
+
probe,
|
|
913
|
+
expected,
|
|
914
|
+
...(admitting === undefined ? {} : { admittedBy: admitting.term }),
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
return input;
|
|
918
|
+
};
|
|
919
|
+
for (const objective of rule.objectives) {
|
|
920
|
+
const detect = detectorOf(objective);
|
|
921
|
+
if (detect === null)
|
|
922
|
+
continue;
|
|
923
|
+
const unit = objective.sector?.kind === "has" ? "file" : objective.unit;
|
|
924
|
+
for (const probe of objective.probes.fires)
|
|
925
|
+
check(objective.name, detect, unit, probe, "fires");
|
|
926
|
+
for (const probe of objective.probes.ignores) {
|
|
927
|
+
check(objective.name, detect, unit, probe, "ignores");
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
if (rule.perimeter?.kind === "match") {
|
|
931
|
+
const perimeter = rule.perimeter;
|
|
932
|
+
const name = `${rule.name}/perimeter`;
|
|
933
|
+
let endShape = false;
|
|
934
|
+
for (const probe of perimeter.probes.fires) {
|
|
935
|
+
const input = check(name, perimeter.detect, perimeter.unit, probe, "fires");
|
|
936
|
+
if (input === null)
|
|
937
|
+
continue;
|
|
938
|
+
const firing = rule.objectives.some((objective) => objective.detect !== null &&
|
|
939
|
+
candidatesOf(objective.detect, objective.unit, input).length > 0);
|
|
940
|
+
if (!firing)
|
|
941
|
+
endShape = true;
|
|
942
|
+
}
|
|
943
|
+
for (const probe of perimeter.probes.ignores) {
|
|
944
|
+
check(name, perimeter.detect, perimeter.unit, probe, "ignores");
|
|
945
|
+
}
|
|
946
|
+
const [first] = perimeter.probes.fires;
|
|
947
|
+
if (!endShape && first !== undefined) {
|
|
948
|
+
failed.push({ name, probe: first, expected: "end-shape" });
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
return failed;
|
|
953
|
+
};
|
|
954
|
+
//# sourceMappingURL=campaigns.js.map
|