@goodbones/core 0.1.0-beta.6 → 0.1.0-beta.8

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