@goodbones/core 0.1.0-beta.7 → 0.1.0-beta.9

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