@goodbones/campaigns 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/build/dts/core/campaign-state.d.ts +38 -0
  3. package/build/dts/core/campaign-state.d.ts.map +1 -0
  4. package/build/dts/core/campaigns.d.ts +196 -0
  5. package/build/dts/core/campaigns.d.ts.map +1 -0
  6. package/build/dts/core/ledger.d.ts +173 -0
  7. package/build/dts/core/ledger.d.ts.map +1 -0
  8. package/build/dts/core/phases.d.ts +26 -0
  9. package/build/dts/core/phases.d.ts.map +1 -0
  10. package/build/dts/core/sectors.d.ts +52 -0
  11. package/build/dts/core/sectors.d.ts.map +1 -0
  12. package/build/dts/domain/config.d.ts +985 -0
  13. package/build/dts/domain/config.d.ts.map +1 -0
  14. package/build/dts/domain/report.d.ts +16 -0
  15. package/build/dts/domain/report.d.ts.map +1 -0
  16. package/build/dts/host/campaigns.d.ts +195 -0
  17. package/build/dts/host/campaigns.d.ts.map +1 -0
  18. package/build/dts/host/diff.d.ts +31 -0
  19. package/build/dts/host/diff.d.ts.map +1 -0
  20. package/build/dts/index.d.ts +17 -0
  21. package/build/dts/index.d.ts.map +1 -0
  22. package/build/dts/infrastructure/campaign-functions.d.ts +7 -0
  23. package/build/dts/infrastructure/campaign-functions.d.ts.map +1 -0
  24. package/build/dts/infrastructure/report-source-fake.d.ts +4 -0
  25. package/build/dts/infrastructure/report-source-fake.d.ts.map +1 -0
  26. package/build/dts/infrastructure/report-source-live.d.ts +3 -0
  27. package/build/dts/infrastructure/report-source-live.d.ts.map +1 -0
  28. package/build/dts/load/decode.d.ts +7 -0
  29. package/build/dts/load/decode.d.ts.map +1 -0
  30. package/build/dts/load/extension.d.ts +25 -0
  31. package/build/dts/load/extension.d.ts.map +1 -0
  32. package/build/dts/manifest/lower.d.ts +14 -0
  33. package/build/dts/manifest/lower.d.ts.map +1 -0
  34. package/build/dts/manifest/spec.d.ts +515 -0
  35. package/build/dts/manifest/spec.d.ts.map +1 -0
  36. package/build/dts/ports/campaign-predicate.d.ts +23 -0
  37. package/build/dts/ports/campaign-predicate.d.ts.map +1 -0
  38. package/build/dts/ports/report-source.d.ts +14 -0
  39. package/build/dts/ports/report-source.d.ts.map +1 -0
  40. package/build/dts/testing.d.ts +2 -0
  41. package/build/dts/testing.d.ts.map +1 -0
  42. package/build/esm/core/campaign-state.js +140 -0
  43. package/build/esm/core/campaign-state.js.map +1 -0
  44. package/build/esm/core/campaigns.js +954 -0
  45. package/build/esm/core/campaigns.js.map +1 -0
  46. package/build/esm/core/ledger.js +519 -0
  47. package/build/esm/core/ledger.js.map +1 -0
  48. package/build/esm/core/phases.js +87 -0
  49. package/build/esm/core/phases.js.map +1 -0
  50. package/build/esm/core/sectors.js +313 -0
  51. package/build/esm/core/sectors.js.map +1 -0
  52. package/build/esm/domain/config.js +245 -0
  53. package/build/esm/domain/config.js.map +1 -0
  54. package/build/esm/domain/report.js +168 -0
  55. package/build/esm/domain/report.js.map +1 -0
  56. package/build/esm/host/campaigns.js +1159 -0
  57. package/build/esm/host/campaigns.js.map +1 -0
  58. package/build/esm/host/diff.js +156 -0
  59. package/build/esm/host/diff.js.map +1 -0
  60. package/build/esm/index.js +27 -0
  61. package/build/esm/index.js.map +1 -0
  62. package/build/esm/infrastructure/campaign-functions.js +67 -0
  63. package/build/esm/infrastructure/campaign-functions.js.map +1 -0
  64. package/build/esm/infrastructure/report-source-fake.js +6 -0
  65. package/build/esm/infrastructure/report-source-fake.js.map +1 -0
  66. package/build/esm/infrastructure/report-source-live.js +165 -0
  67. package/build/esm/infrastructure/report-source-live.js.map +1 -0
  68. package/build/esm/load/decode.js +60 -0
  69. package/build/esm/load/decode.js.map +1 -0
  70. package/build/esm/load/extension.js +326 -0
  71. package/build/esm/load/extension.js.map +1 -0
  72. package/build/esm/manifest/lower.js +488 -0
  73. package/build/esm/manifest/lower.js.map +1 -0
  74. package/build/esm/manifest/spec.js +296 -0
  75. package/build/esm/manifest/spec.js.map +1 -0
  76. package/build/esm/ports/campaign-predicate.js +2 -0
  77. package/build/esm/ports/campaign-predicate.js.map +1 -0
  78. package/build/esm/ports/report-source.js +7 -0
  79. package/build/esm/ports/report-source.js.map +1 -0
  80. package/build/esm/testing.js +5 -0
  81. package/build/esm/testing.js.map +1 -0
  82. package/package.json +65 -0
  83. package/src/core/campaign-state.ts +227 -0
  84. package/src/core/campaigns.ts +1319 -0
  85. package/src/core/ledger.ts +675 -0
  86. package/src/core/phases.ts +138 -0
  87. package/src/core/sectors.ts +399 -0
  88. package/src/domain/config.ts +314 -0
  89. package/src/domain/report.ts +203 -0
  90. package/src/host/campaigns.ts +1664 -0
  91. package/src/host/diff.ts +203 -0
  92. package/src/index.ts +256 -0
  93. package/src/infrastructure/campaign-functions.ts +99 -0
  94. package/src/infrastructure/report-source-fake.ts +10 -0
  95. package/src/infrastructure/report-source-live.ts +193 -0
  96. package/src/load/decode.ts +81 -0
  97. package/src/load/extension.ts +466 -0
  98. package/src/manifest/lower.ts +615 -0
  99. package/src/manifest/spec.ts +364 -0
  100. package/src/ports/campaign-predicate.ts +29 -0
  101. package/src/ports/report-source.ts +35 -0
  102. package/src/testing.ts +4 -0
@@ -0,0 +1,1319 @@
1
+ import {
2
+ type BindingKind,
3
+ compilePatterns,
4
+ type DeclarationKind,
5
+ type ExportSite,
6
+ type FactExtractor,
7
+ type FileSystem,
8
+ type ImportProbeTarget,
9
+ ImportUnresolved,
10
+ type MemberSite,
11
+ type MemberSubject,
12
+ type ModuleResolver,
13
+ PatternInvalid,
14
+ probeTargetOf,
15
+ type ResolvedTarget,
16
+ siblingsOf,
17
+ type SourceFacts,
18
+ type SyntaxMatch,
19
+ type SyntaxMatcher,
20
+ type SyntaxTree,
21
+ type Violation,
22
+ } from "@goodbones/core";
23
+ import * as Result from "effect/Result";
24
+
25
+ import type {
26
+ CampaignProbe,
27
+ CampaignRule,
28
+ CampaignUnit,
29
+ Detector,
30
+ Holdout,
31
+ ObjectiveRule,
32
+ OnTouch,
33
+ PerimeterRule,
34
+ PhaseRule,
35
+ ReportFormat,
36
+ } from "../domain/config.js";
37
+ import type { CampaignPredicate, Range } from "../ports/campaign-predicate.js";
38
+ import type { ReportSource, ReportSpec } from "../ports/report-source.js";
39
+
40
+ // A campaign is one multi-step refactor: `objectives` — each a rule, a
41
+ // baseline and a conformance measure with one thing the other families lack,
42
+ // a way to name a pattern the import graph cannot see — over `sectors` the
43
+ // code declares through a `perimeter`, through ordered `phases`. This module
44
+ // compiles a campaign and evaluates one objective on one file; the sectors
45
+ // and the phases are `sectors.ts` and `phases.ts`. The detector is a
46
+ // predicate algebra — `all`, `any`, `not` — over leaf terms borrowed from
47
+ // the other families (`path`, `imports`, `exports`, `members`, `requires`)
48
+ // and three of its own (`content`, `syntax`, `fn`).
49
+ //
50
+ // An objective declares what a holdout is: `file`, `declaration` or `match`
51
+ // (or `sector`, for a term over the sector's files, which `sectors.ts`
52
+ // answers). The unit
53
+ // decides what a term from another level means. In a `file` campaign every
54
+ // term is existential — "the file contains one" — and `not` is "contains
55
+ // none". In a `declaration` or `match` campaign the candidates are what the
56
+ // declaration- and match-level terms produce, a file-level term is a filter
57
+ // over all of them, and `not` is the complement within those candidates. So
58
+ // `all: [syntax A, not syntax B]` is the declarations A matches that B does
59
+ // not, and `not syntax B` alone is nothing at all: there is no universe of
60
+ // "every other declaration" to draw from, and saying so is the honest answer.
61
+ //
62
+ // Evaluation is in two passes. The leaves are evaluated in order of cost —
63
+ // the path first, then the facts the extractor already produced, then the
64
+ // file system, the text, the syntax tree, and last a predicate function —
65
+ // with an `all` stopping at the first file-level term that fails. Every
66
+ // candidate the leaves produced is then judged against the whole detector.
67
+
68
+ export type {
69
+ CampaignPredicate,
70
+ CampaignPredicateInput,
71
+ CampaignSubject,
72
+ Range,
73
+ } from "../ports/campaign-predicate.js";
74
+
75
+ type Target =
76
+ | { readonly kind: "path"; readonly pattern: RegExp }
77
+ | { readonly kind: "external"; readonly name: string }
78
+ | { readonly kind: "builtin"; readonly name: string };
79
+
80
+ type CompiledNarrowing = {
81
+ readonly capture: string;
82
+ readonly regex: RegExp | null;
83
+ readonly binding: {
84
+ readonly resolves: Target;
85
+ readonly member: ReadonlyArray<string> | null;
86
+ } | null;
87
+ };
88
+
89
+ export type CompiledDetector =
90
+ | { readonly kind: "all"; readonly terms: ReadonlyArray<CompiledDetector> }
91
+ | { readonly kind: "any"; readonly terms: ReadonlyArray<CompiledDetector> }
92
+ | { readonly kind: "not"; readonly term: CompiledDetector }
93
+ | {
94
+ readonly kind: "path";
95
+ readonly file: ReadonlyArray<RegExp>;
96
+ readonly fileNot: ReadonlyArray<RegExp>;
97
+ readonly subject: number | null;
98
+ readonly convention: RegExp | null;
99
+ }
100
+ | {
101
+ readonly kind: "imports";
102
+ readonly resolves: Target;
103
+ readonly symbols: ReadonlyArray<string> | null;
104
+ }
105
+ | {
106
+ readonly kind: "exports";
107
+ readonly name: ReadonlyArray<RegExp>;
108
+ readonly kinds: ReadonlyArray<BindingKind> | null;
109
+ readonly declares: ReadonlyArray<DeclarationKind> | null;
110
+ readonly reexport: boolean | null;
111
+ }
112
+ | {
113
+ readonly kind: "members";
114
+ readonly subject: MemberSubject;
115
+ readonly name: ReadonlyArray<RegExp>;
116
+ readonly in: ReadonlyArray<RegExp>;
117
+ readonly declares: ReadonlyArray<DeclarationKind> | null;
118
+ }
119
+ | { readonly kind: "requires"; readonly templates: ReadonlyArray<string> }
120
+ | { readonly kind: "content"; readonly regex: RegExp }
121
+ | {
122
+ readonly kind: "syntax";
123
+ readonly rule: unknown;
124
+ readonly where: ReadonlyArray<CompiledNarrowing>;
125
+ }
126
+ | {
127
+ readonly kind: "report";
128
+ readonly command: ReadonlyArray<string> | null;
129
+ readonly file: ReadonlyArray<string> | null;
130
+ readonly format: ReportFormat;
131
+ readonly pattern: string | null;
132
+ readonly codes: ReadonlySet<string> | null;
133
+ readonly codesNot: ReadonlySet<string>;
134
+ }
135
+ | { readonly kind: "fn"; readonly name: string };
136
+
137
+ export type CompiledSectorTerm =
138
+ | { readonly kind: "has"; readonly detect: CompiledDetector }
139
+ | { readonly kind: "oneRoot" }
140
+ | { readonly kind: "oneHost"; readonly hosts: ReadonlyArray<RegExp> };
141
+
142
+ export type CompiledObjective = {
143
+ readonly name: string;
144
+ readonly id: string;
145
+ readonly campaign: string;
146
+ readonly message: string;
147
+ readonly why: string | null;
148
+ readonly holdout: Holdout;
149
+ // The unit a per-file detector answers at; `declaration` for the
150
+ // objectives no per-file detector answers, whose entries are `file#subject`.
151
+ readonly unit: CampaignUnit;
152
+ readonly detect: CompiledDetector | null;
153
+ readonly sector: CompiledSectorTerm | null;
154
+ readonly endState: { readonly phase: string; readonly family: string } | null;
155
+ readonly until: string | null;
156
+ readonly probes: {
157
+ readonly fires: ReadonlyArray<CampaignProbe>;
158
+ readonly ignores: ReadonlyArray<CampaignProbe>;
159
+ };
160
+ };
161
+
162
+ export type CompiledPerimeter =
163
+ | { readonly kind: "file" }
164
+ | { readonly kind: "nx" }
165
+ | { readonly kind: "glob"; readonly glob: ReadonlyArray<RegExp> }
166
+ | {
167
+ readonly kind: "marker";
168
+ readonly marker: ReadonlyArray<RegExp>;
169
+ readonly probes: {
170
+ readonly fires: ReadonlyArray<string>;
171
+ readonly ignores: ReadonlyArray<string>;
172
+ } | null;
173
+ }
174
+ | {
175
+ readonly kind: "match";
176
+ readonly detect: CompiledDetector;
177
+ readonly unit: "declaration" | "match";
178
+ readonly probes: {
179
+ readonly fires: ReadonlyArray<CampaignProbe>;
180
+ readonly ignores: ReadonlyArray<CampaignProbe>;
181
+ };
182
+ };
183
+
184
+ export type CompiledCampaign = {
185
+ readonly name: string;
186
+ readonly id: string;
187
+ readonly title: string | null;
188
+ readonly why: string | null;
189
+ readonly owner: string | null;
190
+ readonly scope: ReadonlyArray<RegExp>;
191
+ readonly extensions: ReadonlyArray<string>;
192
+ readonly legacy: ReadonlyArray<RegExp> | null;
193
+ readonly perimeter: CompiledPerimeter | null;
194
+ readonly onTouch: OnTouch | null;
195
+ readonly phases: ReadonlyArray<PhaseRule>;
196
+ readonly objectives: ReadonlyArray<CompiledObjective>;
197
+ readonly staleAfter: number | null;
198
+ readonly onComplete: "keep" | "remove";
199
+ };
200
+
201
+ const targetOf = (
202
+ name: string,
203
+ field: string,
204
+ resolves: ImportProbeTarget,
205
+ ): Result.Result<Target, PatternInvalid> => {
206
+ if (typeof resolves === "string") {
207
+ const compiled = compilePatterns(name, field, resolves);
208
+ if (Result.isFailure(compiled)) return Result.fail(compiled.failure);
209
+ const [pattern] = compiled.success;
210
+ return pattern === undefined
211
+ ? Result.fail(
212
+ new PatternInvalid({ ruleName: name, field, pattern: resolves, detail: "empty pattern" }),
213
+ )
214
+ : Result.succeed({ kind: "path", pattern });
215
+ }
216
+ if ("external" in resolves) return Result.succeed({ kind: "external", name: resolves.external });
217
+ return Result.succeed({ kind: "builtin", name: resolves.builtin });
218
+ };
219
+
220
+ const compileDetector = (
221
+ name: string,
222
+ detector: Detector,
223
+ ): Result.Result<CompiledDetector, PatternInvalid> => {
224
+ const compileAll = (
225
+ terms: ReadonlyArray<Detector>,
226
+ ): Result.Result<ReadonlyArray<CompiledDetector>, PatternInvalid> => {
227
+ const compiled: Array<CompiledDetector> = [];
228
+ for (const term of terms) {
229
+ const one = compileDetector(name, term);
230
+ if (Result.isFailure(one)) return Result.fail(one.failure);
231
+ compiled.push(one.success);
232
+ }
233
+ return Result.succeed(compiled);
234
+ };
235
+ const patterns = (field: string, value: string | ReadonlyArray<string> | undefined) =>
236
+ compilePatterns(name, field, value);
237
+
238
+ if ("all" in detector) {
239
+ const terms = compileAll(detector.all);
240
+ return Result.isFailure(terms)
241
+ ? Result.fail(terms.failure)
242
+ : Result.succeed({ kind: "all", terms: terms.success });
243
+ }
244
+ if ("any" in detector) {
245
+ const terms = compileAll(detector.any);
246
+ return Result.isFailure(terms)
247
+ ? Result.fail(terms.failure)
248
+ : Result.succeed({ kind: "any", terms: terms.success });
249
+ }
250
+ if ("not" in detector) {
251
+ const term = compileDetector(name, detector.not);
252
+ return Result.isFailure(term)
253
+ ? Result.fail(term.failure)
254
+ : Result.succeed({ kind: "not", term: term.success });
255
+ }
256
+ if ("path" in detector) {
257
+ const file = patterns("path.file", detector.path.file);
258
+ if (Result.isFailure(file)) return Result.fail(file.failure);
259
+ const fileNot = patterns("path.fileNot", detector.path.fileNot);
260
+ if (Result.isFailure(fileNot)) return Result.fail(fileNot.failure);
261
+ const convention = patterns("path.convention", detector.path.convention);
262
+ if (Result.isFailure(convention)) return Result.fail(convention.failure);
263
+ return Result.succeed({
264
+ kind: "path",
265
+ file: file.success,
266
+ fileNot: fileNot.success,
267
+ subject: detector.path.subject ?? null,
268
+ convention: convention.success[0] ?? null,
269
+ });
270
+ }
271
+ if ("imports" in detector) {
272
+ const resolves = targetOf(name, "imports.resolves", detector.imports.resolves);
273
+ if (Result.isFailure(resolves)) return Result.fail(resolves.failure);
274
+ return Result.succeed({
275
+ kind: "imports",
276
+ resolves: resolves.success,
277
+ symbols: detector.imports.symbols === undefined ? null : [...detector.imports.symbols],
278
+ });
279
+ }
280
+ if ("exports" in detector) {
281
+ const named = patterns("exports.name", detector.exports.name);
282
+ if (Result.isFailure(named)) return Result.fail(named.failure);
283
+ return Result.succeed({
284
+ kind: "exports",
285
+ name: named.success,
286
+ kinds: detector.exports.kinds === undefined ? null : [...detector.exports.kinds],
287
+ declares: detector.exports.declares === undefined ? null : [...detector.exports.declares],
288
+ reexport: detector.exports.reexport ?? null,
289
+ });
290
+ }
291
+ if ("members" in detector) {
292
+ const named = patterns("members.name", detector.members.name);
293
+ if (Result.isFailure(named)) return Result.fail(named.failure);
294
+ const inside = patterns("members.in", detector.members.in);
295
+ if (Result.isFailure(inside)) return Result.fail(inside.failure);
296
+ return Result.succeed({
297
+ kind: "members",
298
+ subject: detector.members.subject,
299
+ name: named.success,
300
+ in: inside.success,
301
+ declares: detector.members.declares === undefined ? null : [...detector.members.declares],
302
+ });
303
+ }
304
+ if ("requires" in detector) {
305
+ return Result.succeed({ kind: "requires", templates: [...detector.requires] });
306
+ }
307
+ if ("content" in detector) {
308
+ // The codec refuses a bare string here; a caller that built the rule by
309
+ // hand can still pass one, and `new RegExp(undefined)` is `/(?:)/`, which
310
+ // matches every file. A detector that fires on everything is refused,
311
+ // not compiled.
312
+ const source: unknown = detector.content.regex;
313
+ if (typeof source !== "string") {
314
+ return Result.fail(
315
+ new PatternInvalid({
316
+ ruleName: name,
317
+ field: "content.regex",
318
+ pattern: String(source),
319
+ detail: "a content term is { regex: <string> }",
320
+ }),
321
+ );
322
+ }
323
+ let regex: RegExp;
324
+ try {
325
+ regex = new RegExp(source, "m");
326
+ } catch (cause) {
327
+ return Result.fail(
328
+ new PatternInvalid({
329
+ ruleName: name,
330
+ field: "content.regex",
331
+ pattern: detector.content.regex,
332
+ detail: String(cause),
333
+ }),
334
+ );
335
+ }
336
+ return Result.succeed({ kind: "content", regex });
337
+ }
338
+ if ("syntax" in detector) {
339
+ const where: Array<CompiledNarrowing> = [];
340
+ for (const [capture, narrowing] of Object.entries(detector.syntax.where ?? {})) {
341
+ const regex = patterns(`syntax.where.${capture}.regex`, narrowing.regex);
342
+ if (Result.isFailure(regex)) return Result.fail(regex.failure);
343
+ let binding: CompiledNarrowing["binding"] = null;
344
+ if (narrowing.binding !== undefined) {
345
+ const resolves = targetOf(
346
+ name,
347
+ `syntax.where.${capture}.binding.resolves`,
348
+ narrowing.binding.resolves,
349
+ );
350
+ if (Result.isFailure(resolves)) return Result.fail(resolves.failure);
351
+ binding = {
352
+ resolves: resolves.success,
353
+ member: narrowing.binding.member === undefined ? null : [...narrowing.binding.member],
354
+ };
355
+ }
356
+ where.push({ capture, regex: regex.success[0] ?? null, binding });
357
+ }
358
+ return Result.succeed({ kind: "syntax", rule: detector.syntax.rule, where });
359
+ }
360
+ if ("report" in detector) {
361
+ const term = detector.report;
362
+ if (term.pattern !== undefined) {
363
+ const pattern = patterns("report.pattern", term.pattern);
364
+ if (Result.isFailure(pattern)) return Result.fail(pattern.failure);
365
+ }
366
+ return Result.succeed({
367
+ kind: "report",
368
+ command: term.command ?? null,
369
+ file: term.file ?? null,
370
+ format: term.format,
371
+ pattern: term.pattern ?? null,
372
+ codes: term.codes === undefined ? null : new Set(term.codes),
373
+ codesNot: new Set(term.codesNot ?? []),
374
+ });
375
+ }
376
+ return Result.succeed({ kind: "fn", name: detector.fn });
377
+ };
378
+
379
+ export const compileObjective = (
380
+ rule: ObjectiveRule,
381
+ ): Result.Result<CompiledObjective, PatternInvalid> => {
382
+ let detect: CompiledDetector | null = null;
383
+ if (rule.match !== undefined) {
384
+ const compiled = compileDetector(rule.name, rule.match);
385
+ if (Result.isFailure(compiled)) return Result.fail(compiled.failure);
386
+ detect = compiled.success;
387
+ }
388
+ let sector: CompiledSectorTerm | null = null;
389
+ if (rule.sector !== undefined) {
390
+ if ("has" in rule.sector) {
391
+ const compiled = compileDetector(rule.name, rule.sector.has);
392
+ if (Result.isFailure(compiled)) return Result.fail(compiled.failure);
393
+ sector = { kind: "has", detect: compiled.success };
394
+ } else if ("oneRoot" in rule.sector) {
395
+ sector = { kind: "oneRoot" };
396
+ } else {
397
+ const hosts = compilePatterns(rule.name, "sector.oneHost", rule.sector.oneHost);
398
+ if (Result.isFailure(hosts)) return Result.fail(hosts.failure);
399
+ sector = { kind: "oneHost", hosts: hosts.success };
400
+ }
401
+ }
402
+ return Result.succeed({
403
+ name: rule.name,
404
+ id: rule.id,
405
+ campaign: rule.campaign,
406
+ message: rule.message,
407
+ why: rule.why ?? null,
408
+ holdout: rule.holdout,
409
+ unit: rule.holdout === "sector" ? "declaration" : rule.holdout,
410
+ detect,
411
+ sector,
412
+ endState: rule.endState ?? null,
413
+ until: rule.until ?? null,
414
+ probes: rule.probes,
415
+ });
416
+ };
417
+
418
+ const compilePerimeter = (
419
+ name: string,
420
+ perimeter: PerimeterRule,
421
+ ): Result.Result<CompiledPerimeter, PatternInvalid> => {
422
+ switch (perimeter.kind) {
423
+ case "file":
424
+ case "nx":
425
+ return Result.succeed({ kind: perimeter.kind });
426
+ case "glob": {
427
+ const glob = compilePatterns(name, "perimeter.glob", perimeter.glob);
428
+ return Result.isFailure(glob)
429
+ ? Result.fail(glob.failure)
430
+ : Result.succeed({ kind: "glob", glob: glob.success });
431
+ }
432
+ case "marker": {
433
+ const marker = compilePatterns(name, "perimeter.marker", perimeter.marker);
434
+ if (Result.isFailure(marker)) return Result.fail(marker.failure);
435
+ return Result.succeed({
436
+ kind: "marker",
437
+ marker: marker.success,
438
+ probes:
439
+ perimeter.probes === undefined
440
+ ? null
441
+ : {
442
+ fires: [
443
+ ...(typeof perimeter.probes.fires === "string"
444
+ ? [perimeter.probes.fires]
445
+ : perimeter.probes.fires),
446
+ ],
447
+ ignores: [
448
+ ...(typeof perimeter.probes.ignores === "string"
449
+ ? [perimeter.probes.ignores]
450
+ : perimeter.probes.ignores),
451
+ ],
452
+ },
453
+ });
454
+ }
455
+ case "match": {
456
+ const detect = compileDetector(`${name}/perimeter`, perimeter.match);
457
+ if (Result.isFailure(detect)) return Result.fail(detect.failure);
458
+ return Result.succeed({
459
+ kind: "match",
460
+ detect: detect.success,
461
+ unit: perimeter.unit,
462
+ probes: perimeter.probes,
463
+ });
464
+ }
465
+ }
466
+ };
467
+
468
+ export const compileCampaignRule = (
469
+ rule: CampaignRule,
470
+ ): Result.Result<CompiledCampaign, PatternInvalid> => {
471
+ const scope = compilePatterns(rule.name, "scope", rule.scope);
472
+ if (Result.isFailure(scope)) return Result.fail(scope.failure);
473
+ const legacy = compilePatterns(rule.name, "legacy", rule.legacy);
474
+ if (Result.isFailure(legacy)) return Result.fail(legacy.failure);
475
+ let perimeter: CompiledPerimeter | null = null;
476
+ if (rule.perimeter !== undefined) {
477
+ const compiled = compilePerimeter(rule.name, rule.perimeter);
478
+ if (Result.isFailure(compiled)) return Result.fail(compiled.failure);
479
+ perimeter = compiled.success;
480
+ }
481
+ const objectives: Array<CompiledObjective> = [];
482
+ for (const objective of rule.objectives) {
483
+ const one = compileObjective(objective);
484
+ if (Result.isFailure(one)) return Result.fail(one.failure);
485
+ objectives.push(one.success);
486
+ }
487
+ return Result.succeed({
488
+ name: rule.name,
489
+ id: rule.id,
490
+ title: rule.title ?? null,
491
+ why: rule.why ?? null,
492
+ owner: rule.owner ?? null,
493
+ scope: scope.success,
494
+ extensions: rule.extensions,
495
+ legacy: rule.legacy === undefined ? null : legacy.success,
496
+ perimeter,
497
+ onTouch: rule.onTouch ?? null,
498
+ phases: rule.phases,
499
+ objectives,
500
+ staleAfter: rule.staleAfter ?? null,
501
+ onComplete: rule.onComplete,
502
+ });
503
+ };
504
+
505
+ export const compileCampaignRules = (
506
+ rules: ReadonlyArray<CampaignRule>,
507
+ ): Result.Result<ReadonlyArray<CompiledCampaign>, PatternInvalid> => {
508
+ const compiled: Array<CompiledCampaign> = [];
509
+ for (const rule of rules) {
510
+ const one = compileCampaignRule(rule);
511
+ if (Result.isFailure(one)) return Result.fail(one.failure);
512
+ compiled.push(one.success);
513
+ }
514
+ return Result.succeed(compiled);
515
+ };
516
+
517
+ const anyMatches = (patterns: ReadonlyArray<RegExp>, value: string): boolean =>
518
+ patterns.some((pattern) => pattern.test(value));
519
+
520
+ export const campaignsSelecting = (
521
+ rules: ReadonlyArray<CompiledCampaign>,
522
+ file: string,
523
+ ): ReadonlyArray<CompiledCampaign> => rules.filter((rule) => anyMatches(rule.scope, file));
524
+
525
+ // The objectives a host evaluates per file: those with a per-file detector.
526
+ export const perFileObjectivesOf = (rule: CompiledCampaign): ReadonlyArray<CompiledObjective> =>
527
+ rule.objectives.filter((objective) => objective.detect !== null);
528
+
529
+ // Whether any of these detectors reads the syntax tree — the parse a host
530
+ // skips when none does.
531
+ export const needsSyntax = (detectors: ReadonlyArray<CompiledDetector>): boolean =>
532
+ detectors.some((detect) =>
533
+ leafTermsOf(detect).some((leaf) => leaf === "syntax" || leaf === "report" || leaf === "fn"),
534
+ );
535
+
536
+ // The leaf terms the compiled detector holds, in the order the evaluator
537
+ // ranks them by cost.
538
+ const LEAF_ORDER = [
539
+ "path",
540
+ "imports",
541
+ "exports",
542
+ "members",
543
+ "requires",
544
+ "content",
545
+ "syntax",
546
+ "report",
547
+ "fn",
548
+ ] as const;
549
+
550
+ type LeafKind = (typeof LEAF_ORDER)[number];
551
+
552
+ const costOf = (detector: CompiledDetector): number => {
553
+ switch (detector.kind) {
554
+ case "all":
555
+ case "any":
556
+ return Math.max(0, ...detector.terms.map(costOf));
557
+ case "not":
558
+ return costOf(detector.term);
559
+ default:
560
+ return LEAF_ORDER.indexOf(detector.kind);
561
+ }
562
+ };
563
+
564
+ const byCost = (terms: ReadonlyArray<CompiledDetector>): ReadonlyArray<CompiledDetector> =>
565
+ [...terms].sort((left, right) => costOf(left) - costOf(right));
566
+
567
+ export const leafTermsOf = (detector: CompiledDetector): ReadonlyArray<LeafKind> => {
568
+ switch (detector.kind) {
569
+ case "all":
570
+ case "any":
571
+ return detector.terms.flatMap(leafTermsOf);
572
+ case "not":
573
+ return leafTermsOf(detector.term);
574
+ default:
575
+ return [detector.kind];
576
+ }
577
+ };
578
+
579
+ // What the evaluator is given for one file. `syntax` is the file parsed by
580
+ // the scope's matcher, or `null` when there is none — a `syntax` term then
581
+ // matches nothing. `functions` holds every `fn` the loader imported.
582
+ export type CampaignInput = {
583
+ readonly file: string;
584
+ readonly text: string;
585
+ readonly facts: SourceFacts;
586
+ readonly resolver: ModuleResolver;
587
+ readonly fileSystem: FileSystem;
588
+ readonly syntax: SyntaxTree | null;
589
+ readonly functions: ReadonlyMap<string, CampaignPredicate>;
590
+ // Answers a `report` term: the live source runs the command once per
591
+ // process; a probe answers from the diagnostics it lists.
592
+ readonly reports: ReportSource;
593
+ };
594
+
595
+ export type CampaignHit = {
596
+ readonly violation: Violation;
597
+ readonly campaign: string;
598
+ readonly objective: string;
599
+ readonly range?: Range;
600
+ };
601
+
602
+ // A candidate the leaves produced: a declaration by name, or a match by its
603
+ // anchor and a hash of its text. The key is the violation's subject.
604
+ type Candidate = {
605
+ readonly key: string;
606
+ readonly anchor: string | null;
607
+ readonly range: Range | null;
608
+ };
609
+
610
+ // What one leaf answered: a verdict about the file, or the candidates it
611
+ // found — declarations by name, matches by key.
612
+ type LeafAnswer =
613
+ | { readonly level: "file"; readonly holds: boolean }
614
+ | {
615
+ readonly level: "declaration";
616
+ readonly names: ReadonlySet<string>;
617
+ readonly candidates: ReadonlyArray<Candidate>;
618
+ }
619
+ | {
620
+ readonly level: "match";
621
+ readonly anchors: ReadonlySet<string>;
622
+ readonly candidates: ReadonlyArray<Candidate>;
623
+ };
624
+
625
+ // A short, stable hash of a match's text, so a `match` subject survives the
626
+ // declaration around it being edited and changes only when the match does.
627
+ // FNV-1a, so the core needs no hashing library.
628
+ const contentHash = (text: string): string => {
629
+ let hash = 0x811c9dc5;
630
+ for (let i = 0; i < text.length; i += 1) {
631
+ hash ^= text.charCodeAt(i);
632
+ hash = Math.imul(hash, 0x01000193);
633
+ }
634
+ return (hash >>> 0).toString(16).padStart(8, "0");
635
+ };
636
+
637
+ export const matchKeyOf = (anchor: string | null, text: string): string =>
638
+ `${anchor ?? ""}#${contentHash(text)}`;
639
+
640
+ // Two matches with the same key — the same text twice in one declaration,
641
+ // the same diagnostic twice on one line — are two entries, not one: the
642
+ // second is `key~2`, the third `key~3`. The ordinal is positional only among
643
+ // duplicates, so a ledger keeps its count under any edit that leaves them
644
+ // duplicates.
645
+ const uniqueKeys = (): ((key: string) => string) => {
646
+ const seen = new Map<string, number>();
647
+ return (key) => {
648
+ const count = (seen.get(key) ?? 0) + 1;
649
+ seen.set(key, count);
650
+ return count === 1 ? key : `${key}~${String(count)}`;
651
+ };
652
+ };
653
+
654
+ const targetMatches = (target: Target, resolved: ResolvedTarget): boolean => {
655
+ switch (target.kind) {
656
+ case "path":
657
+ return target.pattern.test(resolved.path);
658
+ case "external":
659
+ return resolved.kind === "external" && resolved.package === target.name;
660
+ case "builtin":
661
+ return resolved.kind === "builtin" && resolved.path === target.name;
662
+ }
663
+ };
664
+
665
+ const exportGoverned = (
666
+ term: Extract<CompiledDetector, { kind: "exports" }>,
667
+ site: ExportSite,
668
+ ): boolean => {
669
+ if (term.kinds !== null && !term.kinds.includes(site.kind)) return false;
670
+ if (term.declares !== null && !term.declares.includes(site.declares)) return false;
671
+ if (term.reexport !== null && term.reexport !== site.reexport) return false;
672
+ if (term.name.length > 0 && !anyMatches(term.name, site.name)) return false;
673
+ return true;
674
+ };
675
+
676
+ const memberGoverned = (
677
+ term: Extract<CompiledDetector, { kind: "members" }>,
678
+ site: MemberSite,
679
+ ): boolean => {
680
+ if (term.subject !== site.subject) return false;
681
+ if (term.in.length > 0 && (site.in === undefined || !anyMatches(term.in, site.in))) return false;
682
+ if (
683
+ term.declares !== null &&
684
+ (site.declares === undefined || !term.declares.includes(site.declares))
685
+ ) {
686
+ return false;
687
+ }
688
+ if (term.name.length > 0 && !anyMatches(term.name, site.name)) return false;
689
+ return true;
690
+ };
691
+
692
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*/;
693
+ const ACCESSED = /^\s*\.\s*([A-Za-z_$][\w$]*)/;
694
+
695
+ // A capture narrowed by what its root identifier is bound to: the binding
696
+ // with that local name, the module its edge resolves to, and the name it
697
+ // pulled across — the exported name for a named import, the member accessed
698
+ // for a default or namespace one.
699
+ const bindingAdmits = (
700
+ narrowing: NonNullable<CompiledNarrowing["binding"]>,
701
+ captured: string,
702
+ input: CampaignInput,
703
+ ): boolean => {
704
+ const root = IDENTIFIER.exec(captured)?.[0];
705
+ if (root === undefined) return false;
706
+ for (const specifier of input.facts.specifiers) {
707
+ for (const binding of input.facts.bindings.get(specifier) ?? []) {
708
+ if (binding.local !== root) continue;
709
+ const resolved = input.resolver.resolve(input.file, specifier);
710
+ if (Result.isFailure(resolved)) return false;
711
+ if (!targetMatches(narrowing.resolves, resolved.success)) return false;
712
+ if (narrowing.member === null) return true;
713
+ const accessed =
714
+ binding.kind === "named" ? binding.symbol : ACCESSED.exec(captured.slice(root.length))?.[1];
715
+ return accessed !== undefined && narrowing.member.includes(accessed);
716
+ }
717
+ }
718
+ return false;
719
+ };
720
+
721
+ const narrowed = (
722
+ where: ReadonlyArray<CompiledNarrowing>,
723
+ match: SyntaxMatch,
724
+ input: CampaignInput,
725
+ ): boolean =>
726
+ where.every((one) => {
727
+ const captured = match.captures.get(one.capture);
728
+ if (captured === undefined) return false;
729
+ if (one.regex !== null && !one.regex.test(captured)) return false;
730
+ if (one.binding !== null && !bindingAdmits(one.binding, captured, input)) return false;
731
+ return true;
732
+ });
733
+
734
+ const declarationsOf = (
735
+ names: Iterable<string>,
736
+ ranges: ReadonlyMap<string, Range> = new Map(),
737
+ ): LeafAnswer => {
738
+ const set = new Set(names);
739
+ return {
740
+ level: "declaration",
741
+ names: set,
742
+ candidates: [...set].map((name) => ({
743
+ key: name,
744
+ anchor: name,
745
+ range: ranges.get(name) ?? null,
746
+ })),
747
+ };
748
+ };
749
+
750
+ const matchesOf = (candidates: ReadonlyArray<Candidate>): LeafAnswer => ({
751
+ level: "match",
752
+ anchors: new Set(candidates.flatMap((one) => (one.anchor === null ? [] : [one.anchor]))),
753
+ candidates,
754
+ });
755
+
756
+ const evaluateLeaf = (term: CompiledDetector, input: CampaignInput): LeafAnswer => {
757
+ switch (term.kind) {
758
+ case "path": {
759
+ if (anyMatches(term.fileNot, input.file)) return { level: "file", holds: false };
760
+ for (const pattern of term.file) {
761
+ const found = pattern.exec(input.file);
762
+ if (found === null) continue;
763
+ if (term.convention === null || term.subject === null) {
764
+ return { level: "file", holds: true };
765
+ }
766
+ const subject = found[term.subject];
767
+ return { level: "file", holds: subject !== undefined && term.convention.test(subject) };
768
+ }
769
+ return { level: "file", holds: false };
770
+ }
771
+ case "imports": {
772
+ for (const specifier of input.facts.specifiers) {
773
+ if (term.symbols !== null) {
774
+ const bound = input.facts.bindings.get(specifier) ?? [];
775
+ const wanted = term.symbols;
776
+ if (!bound.some((binding) => wanted.includes(binding.symbol))) continue;
777
+ }
778
+ const resolved = input.resolver.resolve(input.file, specifier);
779
+ if (Result.isFailure(resolved)) continue;
780
+ if (targetMatches(term.resolves, resolved.success)) return { level: "file", holds: true };
781
+ }
782
+ return { level: "file", holds: false };
783
+ }
784
+ case "exports":
785
+ return declarationsOf(
786
+ input.facts.exportSites
787
+ .filter((site) => exportGoverned(term, site))
788
+ .map((site) => site.name),
789
+ );
790
+ case "members": {
791
+ const sites = input.facts.memberSites.filter((site) => memberGoverned(term, site));
792
+ // A called name sits in no declaration the facts know, so a `calls`
793
+ // term is a statement about the file.
794
+ if (term.subject === "calls") return { level: "file", holds: sites.length > 0 };
795
+ return declarationsOf(sites.flatMap((site) => (site.in === undefined ? [] : [site.in])));
796
+ }
797
+ case "requires":
798
+ return {
799
+ level: "file",
800
+ holds: siblingsOf(term.templates, input.file).every((sibling) =>
801
+ input.fileSystem.exists(sibling),
802
+ ),
803
+ };
804
+ case "content":
805
+ return { level: "file", holds: term.regex.test(input.text) };
806
+ case "syntax": {
807
+ if (input.syntax === null) return matchesOf([]);
808
+ const unique = uniqueKeys();
809
+ const candidates: Array<Candidate> = [];
810
+ for (const match of input.syntax.findAll(term.rule)) {
811
+ if (!narrowed(term.where, match, input)) continue;
812
+ candidates.push({
813
+ key: unique(matchKeyOf(match.anchor, match.text)),
814
+ anchor: match.anchor,
815
+ range: match.range,
816
+ });
817
+ }
818
+ return matchesOf(candidates);
819
+ }
820
+ case "report": {
821
+ // Each diagnostic is a match: anchored on the declaration at its
822
+ // position (or none, at the top level or without a matcher), keyed by
823
+ // its code and a hash of its message — so an entry survives the line
824
+ // moving and changes when the message does.
825
+ const unique = uniqueKeys();
826
+ const candidates: Array<Candidate> = [];
827
+ for (const diagnostic of input.reports.diagnosticsOf(specOf(term), input.file)) {
828
+ if (term.codes !== null && !term.codes.has(diagnostic.code)) continue;
829
+ if (term.codesNot.has(diagnostic.code)) continue;
830
+ const position = { line: diagnostic.line, column: diagnostic.column };
831
+ const anchor = input.syntax?.anchorAt(position) ?? null;
832
+ const code = diagnostic.code === "" ? "" : `${diagnostic.code}#`;
833
+ candidates.push({
834
+ key: unique(`${anchor ?? ""}#${code}${contentHash(diagnostic.message)}`),
835
+ anchor,
836
+ range: { start: position, end: position },
837
+ });
838
+ }
839
+ return matchesOf(candidates);
840
+ }
841
+ case "fn": {
842
+ const predicate = input.functions.get(term.name);
843
+ if (predicate === undefined) return { level: "file", holds: false };
844
+ const answer = predicate({
845
+ file: input.file,
846
+ text: input.text,
847
+ facts: input.facts,
848
+ syntax: input.syntax,
849
+ });
850
+ if (typeof answer === "boolean") return { level: "file", holds: answer };
851
+ return matchesOf(
852
+ answer.map((one) => ({ key: one.subject, anchor: one.subject, range: one.range ?? null })),
853
+ );
854
+ }
855
+ default:
856
+ throw new Error(`not a leaf: ${term.kind}`);
857
+ }
858
+ };
859
+
860
+ // Whether every leaf under a node answers about the file, in which case the
861
+ // node has one boolean answer and an `all` may stop at it.
862
+ const isFileLevel = (term: CompiledDetector, unit: CampaignUnit): boolean =>
863
+ unit === "file" ||
864
+ leafTermsOf(term).every(
865
+ (leaf) =>
866
+ leaf !== "exports" &&
867
+ leaf !== "members" &&
868
+ leaf !== "syntax" &&
869
+ leaf !== "report" &&
870
+ leaf !== "fn",
871
+ );
872
+
873
+ type Evaluation = {
874
+ readonly answers: Map<CompiledDetector, LeafAnswer>;
875
+ readonly leaf: (term: CompiledDetector) => LeafAnswer;
876
+ };
877
+
878
+ // Whether a leaf answer holds for one candidate. A file-level answer holds
879
+ // for every candidate or none; a declaration-level one for the candidate
880
+ // named (or, for a match, the one anchored there); a match-level one for the
881
+ // candidate keyed (or, for a declaration, the one some match is anchored in).
882
+ const holdsFor = (answer: LeafAnswer, candidate: Candidate, unit: CampaignUnit): boolean => {
883
+ switch (answer.level) {
884
+ case "file":
885
+ return answer.holds;
886
+ case "declaration":
887
+ return unit === "match"
888
+ ? candidate.anchor !== null && answer.names.has(candidate.anchor)
889
+ : answer.names.has(candidate.key);
890
+ case "match":
891
+ return unit === "declaration"
892
+ ? answer.anchors.has(candidate.key)
893
+ : answer.candidates.some((one) => one.key === candidate.key);
894
+ }
895
+ };
896
+
897
+ // In a `file` campaign every term is existential.
898
+ const holdsAtAll = (answer: LeafAnswer): boolean =>
899
+ answer.level === "file" ? answer.holds : answer.candidates.length > 0;
900
+
901
+ const judge = (
902
+ term: CompiledDetector,
903
+ candidate: Candidate | null,
904
+ unit: CampaignUnit,
905
+ evaluation: Evaluation,
906
+ ): boolean => {
907
+ switch (term.kind) {
908
+ case "all":
909
+ return term.terms.every((one) => judge(one, candidate, unit, evaluation));
910
+ case "any":
911
+ return term.terms.some((one) => judge(one, candidate, unit, evaluation));
912
+ case "not":
913
+ return !judge(term.term, candidate, unit, evaluation);
914
+ default: {
915
+ const answer = evaluation.leaf(term);
916
+ return candidate === null ? holdsAtAll(answer) : holdsFor(answer, candidate, unit);
917
+ }
918
+ }
919
+ };
920
+
921
+ // The first pass: evaluate the leaves that decide anything, cheapest first.
922
+ // An `all` stops at a file-level child that fails — nothing after it can
923
+ // rescue the conjunction, and the terms after it are the expensive ones. An
924
+ // `any` in a `file` campaign stops at a child that holds; in the other units
925
+ // it goes on, because the other branches are where the candidates come from.
926
+ const prepare = (
927
+ term: CompiledDetector,
928
+ unit: CampaignUnit,
929
+ evaluation: Evaluation,
930
+ ): boolean | null => {
931
+ switch (term.kind) {
932
+ case "all": {
933
+ for (const child of byCost(term.terms)) {
934
+ const verdict = prepare(child, unit, evaluation);
935
+ if (verdict === false) return false;
936
+ }
937
+ return null;
938
+ }
939
+ case "any": {
940
+ for (const child of byCost(term.terms)) {
941
+ const verdict = prepare(child, unit, evaluation);
942
+ if (verdict === true && unit === "file") return true;
943
+ }
944
+ return null;
945
+ }
946
+ case "not": {
947
+ const verdict = prepare(term.term, unit, evaluation);
948
+ return verdict === null ? null : !verdict;
949
+ }
950
+ default: {
951
+ if (!isFileLevel(term, unit)) {
952
+ evaluation.leaf(term);
953
+ return null;
954
+ }
955
+ return holdsAtAll(evaluation.leaf(term));
956
+ }
957
+ }
958
+ };
959
+
960
+ const FILE_CANDIDATE: Candidate = { key: "", anchor: null, range: null };
961
+
962
+ const evaluationOf = (input: CampaignInput): Evaluation => {
963
+ const answers = new Map<CompiledDetector, LeafAnswer>();
964
+ return {
965
+ answers,
966
+ leaf: (term) => {
967
+ const found = answers.get(term);
968
+ if (found !== undefined) return found;
969
+ const answer = evaluateLeaf(term, input);
970
+ answers.set(term, answer);
971
+ return answer;
972
+ },
973
+ };
974
+ };
975
+
976
+ const universeOf = (evaluation: Evaluation, unit: CampaignUnit): ReadonlyArray<Candidate> => {
977
+ const byKey = new Map<string, Candidate>();
978
+ for (const answer of evaluation.answers.values()) {
979
+ if (answer.level === "file") continue;
980
+ if (unit === "declaration" && answer.level === "match") {
981
+ // A match sources the declaration it sits in; one at the top level of
982
+ // the file sits in none and sources nothing.
983
+ for (const one of answer.candidates) {
984
+ if (one.anchor === null || byKey.has(one.anchor)) continue;
985
+ byKey.set(one.anchor, { key: one.anchor, anchor: one.anchor, range: one.range });
986
+ }
987
+ continue;
988
+ }
989
+ if (unit === "match" && answer.level === "declaration") continue;
990
+ for (const one of answer.candidates) if (!byKey.has(one.key)) byKey.set(one.key, one);
991
+ }
992
+ return [...byKey.values()].sort((left, right) => left.key.localeCompare(right.key));
993
+ };
994
+
995
+ const hitOf = (rule: CompiledObjective, file: string, candidate: Candidate): CampaignHit => ({
996
+ violation: {
997
+ kind: "campaign",
998
+ ruleName: rule.name,
999
+ message: rule.message,
1000
+ file,
1001
+ subject: rule.unit === "file" ? null : candidate.key,
1002
+ },
1003
+ campaign: rule.campaign,
1004
+ objective: rule.id,
1005
+ ...(candidate.range === null ? {} : { range: candidate.range }),
1006
+ });
1007
+
1008
+ // The candidates a detector produces on one file at a unit, as the
1009
+ // perimeter and the sector terms ask it — without an objective to hang a
1010
+ // violation on.
1011
+ export const candidatesOf = (
1012
+ detect: CompiledDetector,
1013
+ unit: CampaignUnit,
1014
+ input: CampaignInput,
1015
+ ): ReadonlyArray<{ readonly key: string; readonly range: Range | null }> => {
1016
+ const evaluation = evaluationOf(input);
1017
+ const verdict = prepare(detect, unit, evaluation);
1018
+ if (unit === "file") {
1019
+ const holds = verdict ?? judge(detect, null, "file", evaluation);
1020
+ return holds ? [{ key: "", range: null }] : [];
1021
+ }
1022
+ if (verdict === false) return [];
1023
+ return universeOf(evaluation, unit)
1024
+ .filter((candidate) => judge(detect, candidate, unit, evaluation))
1025
+ .map((candidate) => ({ key: candidate.key, range: candidate.range }));
1026
+ };
1027
+
1028
+ // One objective on one file. An objective with no per-file detector — a
1029
+ // sector term, an end state — answers nothing here; the host that holds
1030
+ // the sector's files answers it.
1031
+ export const evaluateObjective = (
1032
+ rule: CompiledObjective,
1033
+ input: CampaignInput,
1034
+ ): ReadonlyArray<CampaignHit> => {
1035
+ if (rule.detect === null) return [];
1036
+ const detect = rule.detect;
1037
+ const evaluation = evaluationOf(input);
1038
+ const verdict = prepare(detect, rule.unit, evaluation);
1039
+ if (rule.unit === "file") {
1040
+ const holds = verdict ?? judge(detect, null, "file", evaluation);
1041
+ return holds ? [hitOf(rule, input.file, FILE_CANDIDATE)] : [];
1042
+ }
1043
+ if (verdict === false) return [];
1044
+ return universeOf(evaluation, rule.unit)
1045
+ .filter((candidate) => judge(detect, candidate, rule.unit, evaluation))
1046
+ .map((candidate) => hitOf(rule, input.file, candidate));
1047
+ };
1048
+
1049
+ // What a `report` term asks its source for: the command or file, the format
1050
+ // and, for `regex`, the pattern. The codes are the term's own filter.
1051
+ const specOf = (term: Extract<CompiledDetector, { kind: "report" }>): ReportSpec => ({
1052
+ ...(term.command === null ? {} : { command: term.command }),
1053
+ ...(term.file === null ? {} : { file: term.file }),
1054
+ format: term.format,
1055
+ ...(term.pattern === null ? {} : { pattern: term.pattern }),
1056
+ });
1057
+
1058
+ // Every distinct report the campaigns name, for a host that asks for them
1059
+ // before any file: the plugin does, at load, because a `command` forks the
1060
+ // process that asks, and the linter's process is one Linux can refuse to
1061
+ // fork once it is linting — its per-thread AST buffers merge into a mapping
1062
+ // larger than the machine has to give, and the heuristic overcommit mode
1063
+ // refuses that at fork. At load, the process is small.
1064
+ export const reportSpecsOf = (
1065
+ rules: ReadonlyArray<CompiledCampaign>,
1066
+ ): ReadonlyArray<ReportSpec> => {
1067
+ const seen = new Map<string, ReportSpec>();
1068
+ const walk = (detector: CompiledDetector): void => {
1069
+ switch (detector.kind) {
1070
+ case "all":
1071
+ case "any":
1072
+ for (const term of detector.terms) walk(term);
1073
+ return;
1074
+ case "not":
1075
+ walk(detector.term);
1076
+ return;
1077
+ case "report": {
1078
+ const spec = specOf(detector);
1079
+ seen.set(JSON.stringify(spec), spec);
1080
+ return;
1081
+ }
1082
+ default:
1083
+ return;
1084
+ }
1085
+ };
1086
+ for (const rule of rules) {
1087
+ for (const objective of rule.objectives) {
1088
+ if (objective.detect !== null) walk(objective.detect);
1089
+ if (objective.sector?.kind === "has") walk(objective.sector.detect);
1090
+ }
1091
+ if (rule.perimeter?.kind === "match") walk(rule.perimeter.detect);
1092
+ }
1093
+ return [...seen.values()];
1094
+ };
1095
+
1096
+ export const evaluateObjectives = (
1097
+ selected: ReadonlyArray<CompiledObjective>,
1098
+ input: CampaignInput,
1099
+ ): ReadonlyArray<CampaignHit> => selected.flatMap((rule) => evaluateObjective(rule, input));
1100
+
1101
+ // One line per leaf term: how it reads, and what it answered for this file —
1102
+ // every leaf evaluated, with no short-circuit, since the point is to show
1103
+ // which one is not saying what the author thinks it says.
1104
+ export type TermAnswer = {
1105
+ readonly term: string;
1106
+ readonly answer: boolean;
1107
+ // For a declaration- or match-level term: how many candidates it found.
1108
+ readonly count?: number;
1109
+ };
1110
+
1111
+ const describeTerm = (term: CompiledDetector): string => {
1112
+ switch (term.kind) {
1113
+ case "path":
1114
+ return `path ${term.file.map((one) => `/${one.source}/`).join(" | ")}`;
1115
+ case "imports":
1116
+ return `imports ${describeTarget(term.resolves)}${term.symbols === null ? "" : ` { ${term.symbols.join(", ")} }`}`;
1117
+ case "exports":
1118
+ return `exports${term.name.length === 0 ? "" : ` ${term.name.map((one) => `/${one.source}/`).join(" | ")}`}${term.kinds === null ? "" : ` [${term.kinds.join(", ")}]`}`;
1119
+ case "members":
1120
+ return `members (${term.subject})${term.name.length === 0 ? "" : ` ${term.name.map((one) => `/${one.source}/`).join(" | ")}`}`;
1121
+ case "requires":
1122
+ return `requires ${term.templates.join(", ")}`;
1123
+ case "content":
1124
+ return `content /${term.regex.source}/`;
1125
+ case "syntax":
1126
+ return `syntax ${JSON.stringify(term.rule)}`;
1127
+ case "report":
1128
+ return `report ${term.format} ${term.command === null ? `file ${(term.file ?? []).join(", ")}` : term.command.map((one) => `\`${one}\``).join(", ")}${term.codes === null ? "" : ` [${[...term.codes].join(", ")}]`}`;
1129
+ case "fn":
1130
+ return `fn ${term.name}`;
1131
+ default:
1132
+ return term.kind;
1133
+ }
1134
+ };
1135
+
1136
+ const describeTarget = (target: Target): string => {
1137
+ switch (target.kind) {
1138
+ case "path":
1139
+ return `/${target.pattern.source}/`;
1140
+ case "external":
1141
+ return `external ${target.name}`;
1142
+ case "builtin":
1143
+ return `builtin ${target.name}`;
1144
+ }
1145
+ };
1146
+
1147
+ // The detector an objective reads a file with: its own, or the one its
1148
+ // `has` term quantifies over the sector's files.
1149
+ export const detectorOf = (rule: CompiledObjective): CompiledDetector | null =>
1150
+ rule.detect ?? (rule.sector?.kind === "has" ? rule.sector.detect : null);
1151
+
1152
+ export const explainObjective = (
1153
+ rule: CompiledObjective,
1154
+ input: CampaignInput,
1155
+ ): ReadonlyArray<TermAnswer> => explainDetector(detectorOf(rule), input);
1156
+
1157
+ export const explainDetector = (
1158
+ detect: CompiledDetector | null,
1159
+ input: CampaignInput,
1160
+ ): ReadonlyArray<TermAnswer> => {
1161
+ if (detect === null) return [];
1162
+ const evaluation = evaluationOf(input);
1163
+ const lines: Array<TermAnswer> = [];
1164
+ const walk = (term: CompiledDetector, negated: boolean): void => {
1165
+ switch (term.kind) {
1166
+ case "all":
1167
+ case "any":
1168
+ for (const one of term.terms) walk(one, negated);
1169
+ return;
1170
+ case "not":
1171
+ walk(term.term, !negated);
1172
+ return;
1173
+ default: {
1174
+ const answer = evaluation.leaf(term);
1175
+ lines.push({
1176
+ term: `${negated ? "not " : ""}${describeTerm(term)}`,
1177
+ answer: holdsAtAll(answer),
1178
+ ...(answer.level === "file" ? {} : { count: answer.candidates.length }),
1179
+ });
1180
+ }
1181
+ }
1182
+ };
1183
+ walk(detect, false);
1184
+ return lines;
1185
+ };
1186
+
1187
+ // The input a probe stands in for: its edges answer the resolver, its files
1188
+ // the file system, its source the extractor and the matcher.
1189
+ const NOTHING: SourceFacts = {
1190
+ specifiers: [],
1191
+ bindings: new Map(),
1192
+ memberSites: [],
1193
+ exportSites: [],
1194
+ };
1195
+
1196
+ export const probeInputOf = (
1197
+ probe: CampaignProbe,
1198
+ extractor: FactExtractor,
1199
+ matcher: SyntaxMatcher | null,
1200
+ functions: ReadonlyMap<string, CampaignPredicate>,
1201
+ ): CampaignInput => {
1202
+ const edges = probe.edges ?? {};
1203
+ const files = new Set(probe.files ?? []);
1204
+ const text = probe.source ?? "";
1205
+ // The probe's diagnostics, one-based as written, on the probe's own file.
1206
+ const reported = (probe.report ?? []).map((one) => ({
1207
+ file: probe.path,
1208
+ line: Math.max(0, one.line - 1),
1209
+ column: Math.max(0, (one.column ?? 1) - 1),
1210
+ code: one.code ?? "",
1211
+ message: one.message ?? "",
1212
+ }));
1213
+ return {
1214
+ file: probe.path,
1215
+ text,
1216
+ facts: probe.source === undefined ? NOTHING : extractor.factsOf(probe.path, probe.source),
1217
+ resolver: {
1218
+ resolve: (fromFile, specifier) => {
1219
+ const target = edges[specifier];
1220
+ return target === undefined
1221
+ ? Result.fail(
1222
+ new ImportUnresolved({ fromFile, specifier, detail: "not among the probe's edges" }),
1223
+ )
1224
+ : Result.succeed(probeTargetOf(target));
1225
+ },
1226
+ },
1227
+ fileSystem: { exists: (at) => files.has(at), readText: () => null, list: () => [] },
1228
+ syntax: probe.source === undefined || matcher === null ? null : matcher.parse(probe.path, text),
1229
+ functions,
1230
+ reports: { diagnosticsOf: (_spec, file) => (file === probe.path ? reported : []) },
1231
+ };
1232
+ };
1233
+
1234
+ export type FailedProbe = {
1235
+ readonly name: string;
1236
+ readonly probe: CampaignProbe;
1237
+ readonly expected: "fires" | "ignores" | "end-shape";
1238
+ // For an `ignores` probe that fired: the first leaf term that held.
1239
+ readonly admittedBy?: string;
1240
+ // For a probe outside the campaign's own scope.
1241
+ readonly outOfScope?: boolean;
1242
+ };
1243
+
1244
+ // Every objective must fire on each of its `fires` probes and stay silent
1245
+ // on each of its `ignores` — the same vacuity check every family makes,
1246
+ // with the second half added because a detector is composed, and the term
1247
+ // that admits too much is the one the author wants named. A `match`
1248
+ // perimeter is proven the same way, plus one rule of its own: the perimeter
1249
+ // is the sector's identity across every phase, so at least one of its
1250
+ // `fires` probes must be a sector no objective fires on — its end shape —
1251
+ // or the sector would be un-born the moment the first phase was met.
1252
+ export const campaignsFailingTheirProbe = (
1253
+ rules: ReadonlyArray<CompiledCampaign>,
1254
+ extractor: FactExtractor,
1255
+ matcherFor: (file: string) => SyntaxMatcher | null,
1256
+ functions: ReadonlyMap<string, CampaignPredicate>,
1257
+ ): ReadonlyArray<FailedProbe> => {
1258
+ const failed: Array<FailedProbe> = [];
1259
+ for (const rule of rules) {
1260
+ const check = (
1261
+ name: string,
1262
+ detect: CompiledDetector,
1263
+ unit: CampaignUnit,
1264
+ probe: CampaignProbe,
1265
+ expected: "fires" | "ignores",
1266
+ ): CampaignInput | null => {
1267
+ if (!anyMatches(rule.scope, probe.path)) {
1268
+ failed.push({ name, probe, expected, outOfScope: true });
1269
+ return null;
1270
+ }
1271
+ const input = probeInputOf(probe, extractor, matcherFor(probe.path), functions);
1272
+ const hits = candidatesOf(detect, unit, input);
1273
+ if (expected === "fires" && hits.length === 0) failed.push({ name, probe, expected });
1274
+ if (expected === "ignores" && hits.length > 0) {
1275
+ const admitting = explainDetector(detect, input).find((line) => line.answer);
1276
+ failed.push({
1277
+ name,
1278
+ probe,
1279
+ expected,
1280
+ ...(admitting === undefined ? {} : { admittedBy: admitting.term }),
1281
+ });
1282
+ }
1283
+ return input;
1284
+ };
1285
+ for (const objective of rule.objectives) {
1286
+ const detect = detectorOf(objective);
1287
+ if (detect === null) continue;
1288
+ const unit = objective.sector?.kind === "has" ? "file" : objective.unit;
1289
+ for (const probe of objective.probes.fires)
1290
+ check(objective.name, detect, unit, probe, "fires");
1291
+ for (const probe of objective.probes.ignores) {
1292
+ check(objective.name, detect, unit, probe, "ignores");
1293
+ }
1294
+ }
1295
+ if (rule.perimeter?.kind === "match") {
1296
+ const perimeter = rule.perimeter;
1297
+ const name = `${rule.name}/perimeter`;
1298
+ let endShape = false;
1299
+ for (const probe of perimeter.probes.fires) {
1300
+ const input = check(name, perimeter.detect, perimeter.unit, probe, "fires");
1301
+ if (input === null) continue;
1302
+ const firing = rule.objectives.some(
1303
+ (objective) =>
1304
+ objective.detect !== null &&
1305
+ candidatesOf(objective.detect, objective.unit, input).length > 0,
1306
+ );
1307
+ if (!firing) endShape = true;
1308
+ }
1309
+ for (const probe of perimeter.probes.ignores) {
1310
+ check(name, perimeter.detect, perimeter.unit, probe, "ignores");
1311
+ }
1312
+ const [first] = perimeter.probes.fires;
1313
+ if (!endShape && first !== undefined) {
1314
+ failed.push({ name, probe: first, expected: "end-shape" });
1315
+ }
1316
+ }
1317
+ }
1318
+ return failed;
1319
+ };