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