@archwall/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/index.cjs +1400 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +721 -0
- package/dist/index.d.mts +721 -0
- package/dist/index.mjs +1349 -0
- package/dist/index.mjs.map +1 -0
- package/dist/internal.cjs +12 -0
- package/dist/internal.d.cts +106 -0
- package/dist/internal.d.mts +106 -0
- package/dist/internal.mjs +2 -0
- package/dist/prepare-BJHgDEui.mjs +748 -0
- package/dist/prepare-BJHgDEui.mjs.map +1 -0
- package/dist/prepare-C1FfL8Qd.cjs +921 -0
- package/dist/prepare-C1FfL8Qd.cjs.map +1 -0
- package/dist/transform-CnUPOO0E.d.cts +638 -0
- package/dist/transform-CnUPOO0E.d.mts +638 -0
- package/package.json +54 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1349 @@
|
|
|
1
|
+
import { S as sourceRelative, _ as isThirdParty, a as filterKey, b as IrVersionMismatchError, d as ProjectGraph, f as THIRD_PARTY_KINDS, g as isFirstParty, h as irMajor, i as GraphQuery, l as IR_VERSION, m as displayModuleId, n as prepareGraph, o as GraphComputationCache, p as assertIrCompatible, s as FIRST_PARTY_KINDS, u as MODULE_ID_SCHEMES, v as parseModuleId, w as toRelative, x as hashParts, y as ArchWallError } from "./prepare-BJHgDEui.mjs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import picomatch from "picomatch";
|
|
4
|
+
//#region src/contracts/analysis.ts
|
|
5
|
+
function defineGraphComputation(computation) {
|
|
6
|
+
return computation;
|
|
7
|
+
}
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region src/analysis/scc.ts
|
|
10
|
+
/**
|
|
11
|
+
* Strongly connected components over static+reexport edges (a dynamic import is a
|
|
12
|
+
* legal cycle-breaker). Iterative Tarjan — recursion would overflow at 10k+ modules.
|
|
13
|
+
* Every module appears in exactly one component.
|
|
14
|
+
*/
|
|
15
|
+
const stronglyConnectedComponents = defineGraphComputation({
|
|
16
|
+
name: "scc",
|
|
17
|
+
compute(q) {
|
|
18
|
+
const index = /* @__PURE__ */ new Map();
|
|
19
|
+
const low = /* @__PURE__ */ new Map();
|
|
20
|
+
const onStack = /* @__PURE__ */ new Set();
|
|
21
|
+
const stack = [];
|
|
22
|
+
const out = [];
|
|
23
|
+
let counter = 0;
|
|
24
|
+
const neighbors = (v) => q.edgesOutOf(v).filter((e) => e.kind !== "dynamic" && q.has(e.to)).map((e) => e.to);
|
|
25
|
+
for (const root of q.moduleIds()) {
|
|
26
|
+
if (index.has(root)) continue;
|
|
27
|
+
const work = [[
|
|
28
|
+
root,
|
|
29
|
+
0,
|
|
30
|
+
neighbors(root)
|
|
31
|
+
]];
|
|
32
|
+
while (work.length > 0) {
|
|
33
|
+
const frame = work[work.length - 1];
|
|
34
|
+
const [v, i, ns] = frame;
|
|
35
|
+
if (i === 0) {
|
|
36
|
+
index.set(v, counter);
|
|
37
|
+
low.set(v, counter);
|
|
38
|
+
counter++;
|
|
39
|
+
stack.push(v);
|
|
40
|
+
onStack.add(v);
|
|
41
|
+
}
|
|
42
|
+
if (i < ns.length) {
|
|
43
|
+
frame[1] = i + 1;
|
|
44
|
+
const w = ns[i];
|
|
45
|
+
if (!index.has(w)) work.push([
|
|
46
|
+
w,
|
|
47
|
+
0,
|
|
48
|
+
neighbors(w)
|
|
49
|
+
]);
|
|
50
|
+
else if (onStack.has(w)) low.set(v, Math.min(low.get(v), index.get(w)));
|
|
51
|
+
} else {
|
|
52
|
+
if (low.get(v) === index.get(v)) {
|
|
53
|
+
const comp = [];
|
|
54
|
+
for (;;) {
|
|
55
|
+
const w = stack.pop();
|
|
56
|
+
onStack.delete(w);
|
|
57
|
+
comp.push(w);
|
|
58
|
+
if (w === v) break;
|
|
59
|
+
}
|
|
60
|
+
out.push(comp);
|
|
61
|
+
}
|
|
62
|
+
work.pop();
|
|
63
|
+
const parent = work[work.length - 1];
|
|
64
|
+
if (parent) low.set(parent[0], Math.min(low.get(parent[0]), low.get(v)));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/contracts/classifier.ts
|
|
73
|
+
function defineClassifier(classifier) {
|
|
74
|
+
return classifier;
|
|
75
|
+
}
|
|
76
|
+
//#endregion
|
|
77
|
+
//#region src/match.ts
|
|
78
|
+
/**
|
|
79
|
+
* Pattern matching, on ONE grammar.
|
|
80
|
+
*
|
|
81
|
+
* Patterns appear in `include`/`exclude`, `overrides` keys, `pathClassifier` patterns,
|
|
82
|
+
* specifier patterns, and the CLI scanner. This is the grammar all of them share, anchored
|
|
83
|
+
* full-match:
|
|
84
|
+
*
|
|
85
|
+
* * matches within one segment (no "/")
|
|
86
|
+
* ** matches across segments, and ZERO of them: `src/**` matches `src` itself and
|
|
87
|
+
* `src/**\/*.ts` matches `src/index.ts`
|
|
88
|
+
* {a,b} alternation, nestable
|
|
89
|
+
* :name captures exactly one segment as `name` — {@link matchCaptures} only
|
|
90
|
+
*
|
|
91
|
+
* Two implementations, deliberately. {@link matchesPattern} delegates to picomatch;
|
|
92
|
+
* {@link matchCaptures} compiles its own regex, because `:name` SEGMENT CAPTURES are the one
|
|
93
|
+
* thing picomatch cannot do and they are how `pathClassifier` turns a path into tags
|
|
94
|
+
* (`:layer/:slice/**` → `{ layer, slice }`). Extracting a capture is a different job from
|
|
95
|
+
* deciding a match; reimplementing the decision is the price of doing it.
|
|
96
|
+
*
|
|
97
|
+
* What keeps that price honest is that the grammar above is the CONTRACT and both
|
|
98
|
+
* implementations owe it: `test/match-dialect.test.ts` asserts they agree on match/no-match
|
|
99
|
+
* across a shared corpus. `{app,pages}/**` used to mean alternation in one place and a
|
|
100
|
+
* literal brace in the other; that is what the differential test exists to prevent recurring.
|
|
101
|
+
*
|
|
102
|
+
* BEYOND the grammar above, picomatch accepts more than the capture compiler does — extglobs
|
|
103
|
+
* (`+(a|b)`), negation (`!`), `?`, numeric ranges (`{1..3}`), POSIX classes. Those are not
|
|
104
|
+
* part of the contract, are not exercised by the differential test, and must not be used in a
|
|
105
|
+
* classifier pattern, where they match literally. Widening the shared grammar means teaching
|
|
106
|
+
* {@link translate} the same syntax and extending the corpus, in that order.
|
|
107
|
+
*
|
|
108
|
+
* ONE divergence is known and deliberate: a trailing `**` preceded by a wildcard segment.
|
|
109
|
+
* We read it consistently (`X` alone always matches); picomatch does so for literal and brace
|
|
110
|
+
* prefixes but not for wildcard ones, and inconsistently even there. The test file states the
|
|
111
|
+
* case and pins picomatch's behaviour so we find out if it ever changes.
|
|
112
|
+
*/
|
|
113
|
+
/**
|
|
114
|
+
* Bounded compile cache.
|
|
115
|
+
*
|
|
116
|
+
* Unbounded module-level caches leak in long-lived watch processes whenever patterns are
|
|
117
|
+
* dynamic, and both caches here are keyed by user-supplied strings. Patterns come from
|
|
118
|
+
* configuration and are few, so a small cap costs nothing and removes the failure mode.
|
|
119
|
+
*/
|
|
120
|
+
const MAX_CACHED = 500;
|
|
121
|
+
function cached(store, key, make) {
|
|
122
|
+
const hit = store.get(key);
|
|
123
|
+
if (hit !== void 0) return hit;
|
|
124
|
+
const value = make();
|
|
125
|
+
if (store.size >= MAX_CACHED) {
|
|
126
|
+
const oldest = store.keys().next();
|
|
127
|
+
if (!oldest.done) store.delete(oldest.value);
|
|
128
|
+
}
|
|
129
|
+
store.set(key, value);
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
const matchers = /* @__PURE__ */ new Map();
|
|
133
|
+
/**
|
|
134
|
+
* Anchored full-match test.
|
|
135
|
+
*
|
|
136
|
+
* `dot: true` so a pattern matches dotfiles without every caller remembering to say so —
|
|
137
|
+
* a rule that silently skips `.storybook/` is the kind of quiet gap this tool exists to
|
|
138
|
+
* prevent.
|
|
139
|
+
*/
|
|
140
|
+
function matchesPattern(value, pattern) {
|
|
141
|
+
return cached(matchers, pattern, () => picomatch(pattern, { dot: true }))(value);
|
|
142
|
+
}
|
|
143
|
+
/** Segments of the capture grammar, in the order the regex builder must handle them. */
|
|
144
|
+
const CAPTURE = /^:([A-Za-z_][A-Za-z0-9_]*)/;
|
|
145
|
+
/** Regex metacharacters that must survive as literals. `*` and `{` never reach the escaper. */
|
|
146
|
+
const META = /[.+?^$}()|[\]\\]/;
|
|
147
|
+
const compiled = /* @__PURE__ */ new Map();
|
|
148
|
+
/** Index of the `}` closing the `{` at `start`, or -1 if it is never closed. */
|
|
149
|
+
function closingBrace(pattern, start) {
|
|
150
|
+
let depth = 0;
|
|
151
|
+
for (let i = start; i < pattern.length; i++) if (pattern[i] === "{") depth++;
|
|
152
|
+
else if (pattern[i] === "}" && --depth === 0) return i;
|
|
153
|
+
return -1;
|
|
154
|
+
}
|
|
155
|
+
/** Splits a brace body on its top-level commas, leaving nested groups intact. */
|
|
156
|
+
function splitAlternatives(body) {
|
|
157
|
+
const out = [];
|
|
158
|
+
let depth = 0;
|
|
159
|
+
let start = 0;
|
|
160
|
+
for (let i = 0; i < body.length; i++) {
|
|
161
|
+
const c = body[i];
|
|
162
|
+
if (c === "{") depth++;
|
|
163
|
+
else if (c === "}") depth--;
|
|
164
|
+
else if (c === "," && depth === 0) {
|
|
165
|
+
out.push(body.slice(start, i));
|
|
166
|
+
start = i + 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
out.push(body.slice(start));
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
/** Translates one pattern into regex source, appending any capture names it emits. */
|
|
173
|
+
function translate(pattern, names) {
|
|
174
|
+
let source = "";
|
|
175
|
+
let i = 0;
|
|
176
|
+
while (i < pattern.length) {
|
|
177
|
+
const rest = pattern.slice(i);
|
|
178
|
+
const capture = CAPTURE.exec(rest);
|
|
179
|
+
if (capture) {
|
|
180
|
+
names.push(capture[1]);
|
|
181
|
+
source += "([^/]+)";
|
|
182
|
+
i += capture[0].length;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (rest.startsWith("/**/")) {
|
|
186
|
+
source += "\\/(?:.*\\/)?";
|
|
187
|
+
i += 4;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (rest === "/**") {
|
|
191
|
+
source += "(?:\\/.*)?";
|
|
192
|
+
i += 3;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (i === 0 && rest.startsWith("**/")) {
|
|
196
|
+
source += "(?:.*\\/)?";
|
|
197
|
+
i += 3;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (rest.startsWith("**")) {
|
|
201
|
+
source += ".*";
|
|
202
|
+
i += 2;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (rest.startsWith("*")) {
|
|
206
|
+
source += "[^/]*";
|
|
207
|
+
i += 1;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (rest.startsWith("{")) {
|
|
211
|
+
const end = closingBrace(pattern, i);
|
|
212
|
+
if (end !== -1) {
|
|
213
|
+
const alternatives = splitAlternatives(pattern.slice(i + 1, end));
|
|
214
|
+
source += `(?:${alternatives.map((a) => translate(a, names)).join("|")})`;
|
|
215
|
+
i = end + 1;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const ch = pattern[i];
|
|
220
|
+
source += META.test(ch) ? `\\${ch}` : ch;
|
|
221
|
+
i += 1;
|
|
222
|
+
}
|
|
223
|
+
return source;
|
|
224
|
+
}
|
|
225
|
+
function compile(pattern) {
|
|
226
|
+
return cached(compiled, pattern, () => {
|
|
227
|
+
const names = [];
|
|
228
|
+
const source = translate(pattern, names);
|
|
229
|
+
return {
|
|
230
|
+
regex: new RegExp(`^(?=[\\s\\S])${source}$`),
|
|
231
|
+
names
|
|
232
|
+
};
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Anchored full-match returning the `:name` captures, or null when the pattern does not
|
|
237
|
+
* match. A pattern with no captures yields an empty object on match — callers must check
|
|
238
|
+
* for null rather than for emptiness.
|
|
239
|
+
*/
|
|
240
|
+
function matchCaptures(value, pattern) {
|
|
241
|
+
const { regex, names } = compile(pattern);
|
|
242
|
+
const m = regex.exec(value);
|
|
243
|
+
if (!m) return null;
|
|
244
|
+
const out = {};
|
|
245
|
+
names.forEach((name, idx) => {
|
|
246
|
+
const captured = m[idx + 1];
|
|
247
|
+
if (captured !== void 0) out[name] = captured;
|
|
248
|
+
});
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
//#endregion
|
|
252
|
+
//#region src/classifiers/path.ts
|
|
253
|
+
/**
|
|
254
|
+
* Declarative path→tag mapping. Every built-in preset is built on this, and it is the
|
|
255
|
+
* supported way to describe a custom architecture without writing a classify function.
|
|
256
|
+
*/
|
|
257
|
+
function pathClassifier(opts) {
|
|
258
|
+
const { name = "path", root = ".", patterns } = opts;
|
|
259
|
+
return defineClassifier({
|
|
260
|
+
name,
|
|
261
|
+
classify(module, ctx) {
|
|
262
|
+
if (module.kind !== "source" || !module.file) return null;
|
|
263
|
+
const rel = sourceRelative(path.resolve(ctx.sourceRoot, root), module.file);
|
|
264
|
+
if (rel === null) return null;
|
|
265
|
+
for (const entry of patterns) {
|
|
266
|
+
const captures = matchCaptures(rel, entry.pattern);
|
|
267
|
+
if (!captures) continue;
|
|
268
|
+
if (entry.only && !allowed(captures, entry.only)) continue;
|
|
269
|
+
return {
|
|
270
|
+
...captures,
|
|
271
|
+
...entry.tags
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
function allowed(captures, only) {
|
|
279
|
+
return Object.entries(only).every(([key, values]) => {
|
|
280
|
+
const captured = captures[key];
|
|
281
|
+
return captured === void 0 || values.includes(captured);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
//#endregion
|
|
285
|
+
//#region src/violations.ts
|
|
286
|
+
/** Normalizes the three input spellings into the canonical location list. */
|
|
287
|
+
function locationsOf(input) {
|
|
288
|
+
if (input.locations !== void 0) return input.locations;
|
|
289
|
+
if (input.edge !== void 0) return [{
|
|
290
|
+
type: "edge",
|
|
291
|
+
edge: input.edge
|
|
292
|
+
}];
|
|
293
|
+
if (input.module !== void 0) return [{
|
|
294
|
+
type: "module",
|
|
295
|
+
module: input.module
|
|
296
|
+
}];
|
|
297
|
+
return [];
|
|
298
|
+
}
|
|
299
|
+
/** The edge a finding is primarily about, when it is about one. */
|
|
300
|
+
function primaryEdge(v) {
|
|
301
|
+
for (const l of v.locations) if (l.type === "edge") return l.edge;
|
|
302
|
+
}
|
|
303
|
+
/** The module a finding is primarily about: an explicit module, else an edge's source. */
|
|
304
|
+
function primaryModule(v) {
|
|
305
|
+
for (const l of v.locations) {
|
|
306
|
+
if (l.type === "module") return l.module;
|
|
307
|
+
if (l.type === "edge") return l.edge.from;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/** Where a finding should be anchored in an editor or in SARIF, when that is knowable. */
|
|
311
|
+
function primarySourceLocation(v) {
|
|
312
|
+
for (const l of v.locations) {
|
|
313
|
+
if (l.type === "edge" && l.edge.loc !== void 0) return l.edge.loc;
|
|
314
|
+
if (l.type === "path" && l.loc !== void 0) return l.loc;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Renders `{placeholder}` templates. Unknown placeholders are left verbatim, so a
|
|
319
|
+
* mis-keyed template is visible in the output rather than silently blank.
|
|
320
|
+
*/
|
|
321
|
+
function renderMessage(template, data) {
|
|
322
|
+
if (data === void 0) return template;
|
|
323
|
+
return template.replace(/\{(\w+)\}/g, (whole, key) => key in data ? String(data[key]) : whole);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Fingerprint scheme version. Bump when the algorithm changes so that a stale baseline
|
|
327
|
+
* ERRORS instead of silently mismatching every entry.
|
|
328
|
+
*
|
|
329
|
+
* `aw3` is the first scheme over canonical module ids
|
|
330
|
+
* (docs/adr/0012-canonical-module-identity.md). Before it, a violation about `react` hashed the
|
|
331
|
+
* host's own id — a resolved `node_modules` path under the CLI, the bare specifier under esbuild
|
|
332
|
+
* — so the same finding fingerprinted differently under two bundlers.
|
|
333
|
+
*/
|
|
334
|
+
const FINGERPRINT_SCHEME = "aw3";
|
|
335
|
+
/**
|
|
336
|
+
* `toRelative` is a no-op on a canonical id, which is never absolute — it is here for the ids
|
|
337
|
+
* that are not canonical: in-memory graphs built by hand (`@archwall/test-utils`, a playground)
|
|
338
|
+
* use bare absolute paths, and those must still fingerprint identically across machines.
|
|
339
|
+
*/
|
|
340
|
+
function locationParts(repoRoot, l) {
|
|
341
|
+
switch (l.type) {
|
|
342
|
+
case "edge": return [
|
|
343
|
+
"e",
|
|
344
|
+
toRelative(repoRoot, l.edge.from),
|
|
345
|
+
toRelative(repoRoot, l.edge.to)
|
|
346
|
+
];
|
|
347
|
+
case "module": return ["m", toRelative(repoRoot, l.module)];
|
|
348
|
+
case "path": return ["p", toRelative(repoRoot, l.path)];
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Identity is (rule instance, offending locations) — deliberately NOT the message, so
|
|
353
|
+
* improving the wording of a rule's output does not invalidate every baseline entry that
|
|
354
|
+
* rule ever produced. `identity` overrides the locations when a rule knows better.
|
|
355
|
+
*/
|
|
356
|
+
function fingerprintOf(repoRoot, ruleId, input) {
|
|
357
|
+
let parts;
|
|
358
|
+
if (input.identity !== void 0) parts = input.identity.map((p) => toRelative(repoRoot, p)).sort();
|
|
359
|
+
else {
|
|
360
|
+
const locations = locationsOf(input);
|
|
361
|
+
parts = locations.length === 0 ? [""] : locations.flatMap((l) => locationParts(repoRoot, l));
|
|
362
|
+
}
|
|
363
|
+
return `aw3:${hashParts([ruleId, ...parts])}`;
|
|
364
|
+
}
|
|
365
|
+
/** One definition of "how many of each", shared by every consumer that needs counts. */
|
|
366
|
+
function countBySeverity(violations) {
|
|
367
|
+
const counts = {
|
|
368
|
+
error: 0,
|
|
369
|
+
warn: 0,
|
|
370
|
+
info: 0
|
|
371
|
+
};
|
|
372
|
+
for (const v of violations) counts[v.severity]++;
|
|
373
|
+
return counts;
|
|
374
|
+
}
|
|
375
|
+
/** Sortable string for a location, so ordering is a property of the finding. */
|
|
376
|
+
function locationKey(l) {
|
|
377
|
+
if (l === void 0) return "";
|
|
378
|
+
switch (l.type) {
|
|
379
|
+
case "edge": return `${l.edge.from}${l.edge.to}${l.edge.rawSpecifier}`;
|
|
380
|
+
case "module": return l.module;
|
|
381
|
+
case "path": return l.path;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Total order over violations, so two runs of the same analysis produce byte-identical
|
|
386
|
+
* output. Required by baselines, CI diffing, and snapshot tests; without it, ordering
|
|
387
|
+
* follows rule registration order and each rule's internal scan order, which differs
|
|
388
|
+
* between hosts because module insertion order does.
|
|
389
|
+
*/
|
|
390
|
+
function compareViolations(a, b) {
|
|
391
|
+
return a.ruleId.localeCompare(b.ruleId) || locationKey(a.locations[0]).localeCompare(locationKey(b.locations[0])) || a.message.localeCompare(b.message);
|
|
392
|
+
}
|
|
393
|
+
//#endregion
|
|
394
|
+
//#region src/reporters/console.ts
|
|
395
|
+
/**
|
|
396
|
+
* Console-only IO: the portable default.
|
|
397
|
+
*
|
|
398
|
+
* Core stays runnable wherever a graph can be built — browser playground, worker, edge
|
|
399
|
+
* runtime — so it cannot open files. A host with a filesystem supplies an IO that can
|
|
400
|
+
* (`@archwall/integration-kit` exports `nodeIO`); asking this one for a file is an error
|
|
401
|
+
* rather than a silent fallback to stdout, because a run that was told to write
|
|
402
|
+
* `archwall.sarif` and printed to the terminal instead has failed at its actual job.
|
|
403
|
+
*/
|
|
404
|
+
const defaultIO = { open(destination) {
|
|
405
|
+
if (destination === "stdout") return { write: (text) => console.log(text) };
|
|
406
|
+
if (destination === "stderr") return { write: (text) => console.error(text) };
|
|
407
|
+
throw new ArchWallError(`Cannot write reporter output to "${destination}": this environment has no filesystem. Use "stdout"/"stderr", or run through a host that supplies a filesystem-capable ReporterIO.`);
|
|
408
|
+
} };
|
|
409
|
+
/**
|
|
410
|
+
* Shared violation block format — also used by adapters when mapping violations into host
|
|
411
|
+
* diagnostics (error locality: anchored on the importer edge, resolution shown as
|
|
412
|
+
* explanation, never as the location).
|
|
413
|
+
*
|
|
414
|
+
* `repoRoot` makes every path repository-relative. Absolute paths are the right module
|
|
415
|
+
* identity inside a run and the wrong thing in every output.
|
|
416
|
+
*/
|
|
417
|
+
function formatViolation(v, repoRoot) {
|
|
418
|
+
const at = (p) => repoRoot === void 0 ? p : toRelative(repoRoot, p);
|
|
419
|
+
const idOf = (id) => displayModuleId(at(id));
|
|
420
|
+
const lines = [`[${v.severity}] ${v.ruleId}: ${v.message}`];
|
|
421
|
+
const loc = primarySourceLocation(v);
|
|
422
|
+
if (loc) lines.push(` at ${at(loc.file)}:${loc.line}:${loc.column}`);
|
|
423
|
+
for (const l of v.locations) if (l.type === "edge") lines.push(l.edge.rawSpecifier !== l.edge.resolvedPath ? ` import "${l.edge.rawSpecifier}" → resolves to ${idOf(l.edge.resolvedPath)}` : ` import "${l.edge.rawSpecifier}"`);
|
|
424
|
+
const modules = v.locations.filter((l) => l.type === "module");
|
|
425
|
+
if (modules.length > 1) for (const m of modules) lines.push(` · ${idOf(m.module)}`);
|
|
426
|
+
if (v.explanation) lines.push(` ${v.explanation}`);
|
|
427
|
+
return lines.join("\n");
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Stateless: one pass over the finished result, in `onRunEnd`.
|
|
431
|
+
*
|
|
432
|
+
* There is no `onRunStart` and no per-run state to reset, which is what makes it safe for
|
|
433
|
+
* the run object to be memoized across watch rebuilds in the bundler adapters — a reporter
|
|
434
|
+
* that accumulated anything would grow for the life of the process.
|
|
435
|
+
*/
|
|
436
|
+
function consoleReporter(sink) {
|
|
437
|
+
return {
|
|
438
|
+
name: "console",
|
|
439
|
+
onRunEnd(result) {
|
|
440
|
+
for (const v of result.violations) sink.write(formatViolation(v, result.repoRoot));
|
|
441
|
+
const docs = new Map(result.rules.filter((r) => r.docsUrl !== void 0 && r.violations > 0).map((r) => [r.id, r.docsUrl]));
|
|
442
|
+
for (const [id, url] of docs) sink.write(` ${id}: ${url}`);
|
|
443
|
+
for (const d of result.diagnostics) sink.write(`${d.severity}: ${d.message}`);
|
|
444
|
+
const { error, warn, info } = countBySeverity(result.violations);
|
|
445
|
+
sink.write(`${error} error(s), ${warn} warning(s)${info > 0 ? `, ${info} info` : ""} — ${result.stats.moduleCount} modules, ${result.stats.edgeCount} edges in ${Math.round(result.stats.durationMs)}ms`);
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
//#endregion
|
|
450
|
+
//#region src/reporters/json.ts
|
|
451
|
+
/**
|
|
452
|
+
* Ids keep their scheme — a machine consumer wants the identity it can correlate against a
|
|
453
|
+
* fingerprint or a baseline, not a prettified path
|
|
454
|
+
*
|
|
455
|
+
* `toRelative` is still applied, and is a no-op on a canonical id, which is never absolute. It
|
|
456
|
+
* is here for the ids that are not canonical: an in-memory graph built by hand uses bare
|
|
457
|
+
* absolute paths, and this document has to be identical on every machine either way.
|
|
458
|
+
*/
|
|
459
|
+
function serializeLocation(repoRoot, l) {
|
|
460
|
+
switch (l.type) {
|
|
461
|
+
case "edge": return {
|
|
462
|
+
type: "edge",
|
|
463
|
+
edge: {
|
|
464
|
+
...l.edge,
|
|
465
|
+
from: toRelative(repoRoot, l.edge.from),
|
|
466
|
+
to: toRelative(repoRoot, l.edge.to),
|
|
467
|
+
resolvedPath: toRelative(repoRoot, l.edge.resolvedPath),
|
|
468
|
+
...l.edge.loc !== void 0 ? { loc: {
|
|
469
|
+
...l.edge.loc,
|
|
470
|
+
file: toRelative(repoRoot, l.edge.loc.file)
|
|
471
|
+
} } : {}
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
case "module": return {
|
|
475
|
+
type: "module",
|
|
476
|
+
module: toRelative(repoRoot, l.module)
|
|
477
|
+
};
|
|
478
|
+
case "path": return {
|
|
479
|
+
type: "path",
|
|
480
|
+
path: toRelative(repoRoot, l.path),
|
|
481
|
+
...l.loc !== void 0 ? { loc: {
|
|
482
|
+
...l.loc,
|
|
483
|
+
file: toRelative(repoRoot, l.loc.file)
|
|
484
|
+
} } : {}
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/** Paths are repository-relative so the document is identical on every machine. */
|
|
489
|
+
function serialize(repoRoot, v) {
|
|
490
|
+
return {
|
|
491
|
+
ruleName: v.ruleName,
|
|
492
|
+
ruleId: v.ruleId,
|
|
493
|
+
severity: v.severity,
|
|
494
|
+
message: v.message,
|
|
495
|
+
...v.messageId !== void 0 ? { messageId: v.messageId } : {},
|
|
496
|
+
...v.data !== void 0 ? { data: v.data } : {},
|
|
497
|
+
locations: v.locations.map((l) => serializeLocation(repoRoot, l)),
|
|
498
|
+
...v.explanation !== void 0 ? { explanation: v.explanation } : {},
|
|
499
|
+
fingerprint: v.fingerprint
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
function jsonReporter(sink) {
|
|
503
|
+
return {
|
|
504
|
+
name: "json",
|
|
505
|
+
onRunEnd(result) {
|
|
506
|
+
sink.write(JSON.stringify({
|
|
507
|
+
violations: result.violations.map((v) => serialize(result.repoRoot, v)),
|
|
508
|
+
diagnostics: result.diagnostics,
|
|
509
|
+
rules: result.rules,
|
|
510
|
+
stats: result.stats,
|
|
511
|
+
host: {
|
|
512
|
+
name: result.host.name,
|
|
513
|
+
version: result.host.version,
|
|
514
|
+
capabilities: [...result.host.capabilities]
|
|
515
|
+
},
|
|
516
|
+
delivery: result.delivery
|
|
517
|
+
}, null, 2));
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
//#endregion
|
|
522
|
+
//#region src/reporters/sarif.ts
|
|
523
|
+
/** ArchWall's vocabulary is not SARIF's; `info` is SARIF's "note". */
|
|
524
|
+
const SARIF_LEVEL = {
|
|
525
|
+
error: "error",
|
|
526
|
+
warn: "warning",
|
|
527
|
+
info: "note"
|
|
528
|
+
};
|
|
529
|
+
/**
|
|
530
|
+
* SARIF locations for a violation.
|
|
531
|
+
*
|
|
532
|
+
* All of them, not just the first: a cycle is one result about N files, and SARIF's
|
|
533
|
+
* `locations` array is exactly the right shape for that. Locations without a source
|
|
534
|
+
* position are omitted — SARIF needs a `physicalLocation`, and inventing line 1 for a
|
|
535
|
+
* module the host gave us no position for would point reviewers at the wrong line.
|
|
536
|
+
*/
|
|
537
|
+
function sarifLocations(repoRoot, v) {
|
|
538
|
+
const out = [];
|
|
539
|
+
for (const l of v.locations) {
|
|
540
|
+
const loc = l.type === "edge" ? l.edge.loc : l.type === "path" ? l.loc : void 0;
|
|
541
|
+
if (loc === void 0) continue;
|
|
542
|
+
out.push({ physicalLocation: {
|
|
543
|
+
artifactLocation: { uri: toRelative(repoRoot, loc.file) },
|
|
544
|
+
region: {
|
|
545
|
+
startLine: loc.line,
|
|
546
|
+
startColumn: loc.column + 1
|
|
547
|
+
}
|
|
548
|
+
} });
|
|
549
|
+
}
|
|
550
|
+
if (out.length > 0) return out;
|
|
551
|
+
const fallback = primarySourceLocation(v);
|
|
552
|
+
if (fallback !== void 0) return [{ physicalLocation: { artifactLocation: { uri: toRelative(repoRoot, fallback.file) } } }];
|
|
553
|
+
return [];
|
|
554
|
+
}
|
|
555
|
+
function sarifReporter(sink) {
|
|
556
|
+
return {
|
|
557
|
+
name: "sarif",
|
|
558
|
+
onRunEnd(result) {
|
|
559
|
+
const described = new Map(result.rules.map((r) => [r.id, r]));
|
|
560
|
+
for (const v of result.violations) if (!described.has(v.ruleId)) described.set(v.ruleId, {
|
|
561
|
+
id: v.ruleId,
|
|
562
|
+
name: v.ruleName,
|
|
563
|
+
description: "",
|
|
564
|
+
severity: v.severity,
|
|
565
|
+
status: "ran",
|
|
566
|
+
violations: 0,
|
|
567
|
+
durationMs: 0
|
|
568
|
+
});
|
|
569
|
+
const doc = {
|
|
570
|
+
$schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
|
571
|
+
version: "2.1.0",
|
|
572
|
+
runs: [{
|
|
573
|
+
tool: { driver: {
|
|
574
|
+
name: "archwall",
|
|
575
|
+
rules: [...described.values()].map((r) => ({
|
|
576
|
+
id: r.id,
|
|
577
|
+
name: r.name,
|
|
578
|
+
...r.description !== "" ? { shortDescription: { text: r.description } } : {},
|
|
579
|
+
...r.docsUrl !== void 0 && /^https?:\/\//.test(r.docsUrl) ? { helpUri: r.docsUrl } : {}
|
|
580
|
+
}))
|
|
581
|
+
} },
|
|
582
|
+
results: result.violations.map((v) => ({
|
|
583
|
+
ruleId: v.ruleId,
|
|
584
|
+
level: SARIF_LEVEL[v.severity],
|
|
585
|
+
message: { text: v.explanation ? `${v.message} — ${v.explanation}` : v.message },
|
|
586
|
+
partialFingerprints: { archwall: v.fingerprint },
|
|
587
|
+
...v.data !== void 0 ? { properties: v.data } : {},
|
|
588
|
+
locations: sarifLocations(result.repoRoot, v)
|
|
589
|
+
})),
|
|
590
|
+
invocations: [{
|
|
591
|
+
executionSuccessful: !result.diagnostics.some((d) => d.severity === "error"),
|
|
592
|
+
toolExecutionNotifications: result.diagnostics.map((d) => ({
|
|
593
|
+
level: SARIF_LEVEL[d.severity],
|
|
594
|
+
message: { text: d.message },
|
|
595
|
+
descriptor: { id: d.code },
|
|
596
|
+
...d.ruleId !== void 0 ? { associatedRule: { id: d.ruleId } } : {}
|
|
597
|
+
}))
|
|
598
|
+
}]
|
|
599
|
+
}]
|
|
600
|
+
};
|
|
601
|
+
sink.write(JSON.stringify(doc, null, 2));
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
//#endregion
|
|
606
|
+
//#region src/reporters/resolve.ts
|
|
607
|
+
const BUILTIN_REPORTER_NAMES = [
|
|
608
|
+
"console",
|
|
609
|
+
"json",
|
|
610
|
+
"sarif"
|
|
611
|
+
];
|
|
612
|
+
const BUILTINS = {
|
|
613
|
+
console: consoleReporter,
|
|
614
|
+
json: jsonReporter,
|
|
615
|
+
sarif: sarifReporter
|
|
616
|
+
};
|
|
617
|
+
function isBuiltinReporterName(name) {
|
|
618
|
+
return name in BUILTINS;
|
|
619
|
+
}
|
|
620
|
+
function normalize(spec) {
|
|
621
|
+
if (typeof spec === "string") return { reporter: spec };
|
|
622
|
+
if ("reporter" in spec) return spec;
|
|
623
|
+
return { reporter: spec };
|
|
624
|
+
}
|
|
625
|
+
function resolveReporters(specs, io = defaultIO) {
|
|
626
|
+
const opened = [];
|
|
627
|
+
return {
|
|
628
|
+
reporters: specs.map((raw) => {
|
|
629
|
+
const spec = normalize(raw);
|
|
630
|
+
if (typeof spec.reporter !== "string") return spec.reporter;
|
|
631
|
+
const factory = BUILTINS[spec.reporter];
|
|
632
|
+
if (!factory) throw new ArchWallError(`Unknown reporter "${spec.reporter}". Built-ins: ${BUILTIN_REPORTER_NAMES.join(", ")}. A third-party reporter must be installed and resolvable, or passed as an object.`);
|
|
633
|
+
const sink = io.open(spec.output ?? "stdout");
|
|
634
|
+
opened.push(sink);
|
|
635
|
+
return factory(sink);
|
|
636
|
+
}),
|
|
637
|
+
async close() {
|
|
638
|
+
for (const sink of opened) await sink.close?.();
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
//#endregion
|
|
643
|
+
//#region src/config.ts
|
|
644
|
+
/**
|
|
645
|
+
* Which diagnostic codes each `failOnDiagnostics` switch governs, and whether it is on by
|
|
646
|
+
* default. The single source of truth for both.
|
|
647
|
+
*
|
|
648
|
+
* One table because there used to be three: the code list lived in `@archwall/integration-kit`,
|
|
649
|
+
* the defaults lived in `resolveConfig` below, and a second copy of the defaults lived beside
|
|
650
|
+
* the code list. Nothing linked them, so adding a gate meant remembering all three, and
|
|
651
|
+
* forgetting the third produced a switch that resolved correctly and then gated nothing.
|
|
652
|
+
*
|
|
653
|
+
* The `satisfies` is what keeps it honest: a key added to {@link ResolvedFailOnDiagnostics}
|
|
654
|
+
* and not here is a compile error, and vice versa.
|
|
655
|
+
*/
|
|
656
|
+
const DIAGNOSTIC_GATES = {
|
|
657
|
+
ruleFailed: {
|
|
658
|
+
codes: ["rule-failed"],
|
|
659
|
+
default: true
|
|
660
|
+
},
|
|
661
|
+
ruleSkipped: {
|
|
662
|
+
codes: ["rule-skipped"],
|
|
663
|
+
default: false
|
|
664
|
+
},
|
|
665
|
+
emptyAnalysis: {
|
|
666
|
+
codes: ["no-modules-classified", "empty-project"],
|
|
667
|
+
default: false
|
|
668
|
+
},
|
|
669
|
+
emptyScope: {
|
|
670
|
+
codes: ["empty-scope"],
|
|
671
|
+
default: false
|
|
672
|
+
},
|
|
673
|
+
invalidOptions: {
|
|
674
|
+
codes: ["invalid-rule-options"],
|
|
675
|
+
default: true
|
|
676
|
+
},
|
|
677
|
+
invalidConfig: {
|
|
678
|
+
codes: ["invalid-config"],
|
|
679
|
+
default: true
|
|
680
|
+
},
|
|
681
|
+
deprecated: {
|
|
682
|
+
codes: ["rule-deprecated"],
|
|
683
|
+
default: false
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
const GATE_KEYS = Object.keys(DIAGNOSTIC_GATES);
|
|
687
|
+
/**
|
|
688
|
+
* Applies {@link DIAGNOSTIC_GATES}' defaults to whatever the user left unset.
|
|
689
|
+
*
|
|
690
|
+
* Spelled out key by key rather than mapped over `GATE_KEYS`, so that adding a gate is a
|
|
691
|
+
* compile error here until it is handled. The values still come from the one table; only the
|
|
692
|
+
* exhaustiveness is restated, and restating it is the thing being bought.
|
|
693
|
+
*/
|
|
694
|
+
function resolveFailOnDiagnostics(user) {
|
|
695
|
+
const gate = (key) => user?.[key] ?? DIAGNOSTIC_GATES[key].default;
|
|
696
|
+
return {
|
|
697
|
+
ruleFailed: gate("ruleFailed"),
|
|
698
|
+
ruleSkipped: gate("ruleSkipped"),
|
|
699
|
+
emptyAnalysis: gate("emptyAnalysis"),
|
|
700
|
+
emptyScope: gate("emptyScope"),
|
|
701
|
+
invalidOptions: gate("invalidOptions"),
|
|
702
|
+
invalidConfig: gate("invalidConfig"),
|
|
703
|
+
deprecated: gate("deprecated")
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
/** The diagnostic codes that should fail a run, given the resolved gates. */
|
|
707
|
+
function failingDiagnosticCodes(gates) {
|
|
708
|
+
return new Set(GATE_KEYS.filter((key) => gates[key]).flatMap((key) => DIAGNOSTIC_GATES[key].codes));
|
|
709
|
+
}
|
|
710
|
+
function defineConfig(config) {
|
|
711
|
+
return config;
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Everything under `sourceRoot`, deliberately — NOT an extension allow-list.
|
|
715
|
+
*
|
|
716
|
+
* `include`/`exclude` are applied to the graph, where the compiler has already decided what
|
|
717
|
+
* counts as a module. Re-filtering by extension there would silently drop every `.vue`,
|
|
718
|
+
* `.svelte`, `.astro`, and `.mts` module the host legitimately compiled. Deciding *which
|
|
719
|
+
* files to open and parse* belongs to the one surface that enumerates a directory tree: the
|
|
720
|
+
* CLI's scanner keeps its own list of extensions it can lex.
|
|
721
|
+
*/
|
|
722
|
+
const DEFAULT_INCLUDE = ["**"];
|
|
723
|
+
const DEFAULT_EXCLUDE = [
|
|
724
|
+
"**/node_modules/**",
|
|
725
|
+
"**/*.test.*",
|
|
726
|
+
"**/*.spec.*"
|
|
727
|
+
];
|
|
728
|
+
function configError(message, ruleId) {
|
|
729
|
+
return {
|
|
730
|
+
code: "invalid-config",
|
|
731
|
+
severity: "error",
|
|
732
|
+
...ruleId !== void 0 ? { ruleId } : {},
|
|
733
|
+
message
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Namespacing preset rules is what makes `presets: [a(), b()]` safe: without it two presets
|
|
738
|
+
* configuring the same rule collide on one key and shallow-merge their options.
|
|
739
|
+
*
|
|
740
|
+
* …which only works if the names are actually distinct. Two instances of one preset — the
|
|
741
|
+
* natural way to describe a monorepo — would produce identical ids, the same collision
|
|
742
|
+
* arrived at from the other direction, so a duplicate name is reported and the later preset
|
|
743
|
+
* is namespaced apart rather than silently merged.
|
|
744
|
+
*/
|
|
745
|
+
function withIds(presets, userRules, diagnostics) {
|
|
746
|
+
const namespaces = /* @__PURE__ */ new Map();
|
|
747
|
+
const preset = [];
|
|
748
|
+
for (const p of presets) {
|
|
749
|
+
const seen = namespaces.get(p.name) ?? 0;
|
|
750
|
+
namespaces.set(p.name, seen + 1);
|
|
751
|
+
let namespace = p.name;
|
|
752
|
+
if (seen > 0) {
|
|
753
|
+
namespace = `${p.name}#${seen + 1}`;
|
|
754
|
+
diagnostics.push(configError(`Two presets are both named "${p.name}", so their rules would collide on the same ids and silently merge their options. The later one's rules were namespaced "${namespace}/…" instead. Give it a distinct name, or configure its rules explicitly with their own \`id\`s.`));
|
|
755
|
+
}
|
|
756
|
+
for (const r of p.rules) preset.push({
|
|
757
|
+
configured: r,
|
|
758
|
+
id: r.id ?? `${namespace}/${r.rule.meta.name}`
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
const own = [];
|
|
762
|
+
for (const r of userRules) {
|
|
763
|
+
if (r.id !== void 0) {
|
|
764
|
+
own.push({
|
|
765
|
+
configured: r,
|
|
766
|
+
id: r.id
|
|
767
|
+
});
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
const name = r.rule.meta.name;
|
|
771
|
+
const fromPresets = preset.filter((p) => p.configured.rule.meta.name === name);
|
|
772
|
+
if (fromPresets.length === 1) own.push({
|
|
773
|
+
configured: r,
|
|
774
|
+
id: fromPresets[0].id
|
|
775
|
+
});
|
|
776
|
+
else if (fromPresets.length > 1) diagnostics.push(configError(`Rule "${name}" is configured by more than one preset (${fromPresets.map((p) => p.id).join(", ")}), so a bare rules[] entry is ambiguous and was dropped. Give it an explicit \`id\`, or use \`overrides\` to target one.`));
|
|
777
|
+
else own.push({
|
|
778
|
+
configured: r,
|
|
779
|
+
id: name
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
return [...preset, ...own];
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* THE options-merge policy. One rule, applied identically everywhere two option bags meet:
|
|
786
|
+
* preset over preset, `rules[]` over preset, and `overrides.options` over both.
|
|
787
|
+
*
|
|
788
|
+
* **Top-level keys replace. Nothing is deep-merged, and arrays are never concatenated.**
|
|
789
|
+
* Arrays here are values, not collections: `layers: ["ui", "domain"]` describes a total
|
|
790
|
+
* order and `forbid: [...]` a complete policy. Replacement is the only rule that lets an
|
|
791
|
+
* override *remove* something.
|
|
792
|
+
*/
|
|
793
|
+
function mergeRuleOptions(base, patch) {
|
|
794
|
+
return {
|
|
795
|
+
...base,
|
|
796
|
+
...patch
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Validates one rule's options against its `optionsSchema`, at CONFIG time.
|
|
801
|
+
*
|
|
802
|
+
* A bad options bag is a configuration mistake: it is known before any graph work happens,
|
|
803
|
+
* it cannot be fixed by re-running, and it should be reported once, as a diagnostic,
|
|
804
|
+
* alongside everything else wrong with the config.
|
|
805
|
+
*
|
|
806
|
+
* Schemas must validate synchronously. An async schema is not rejected as invalid — it is
|
|
807
|
+
* reported as unusable, which is a different and more accurate complaint.
|
|
808
|
+
*/
|
|
809
|
+
function validateOptions(rule, id, options) {
|
|
810
|
+
const schema = rule.meta.optionsSchema;
|
|
811
|
+
if (!schema) return { value: options };
|
|
812
|
+
const result = schema["~standard"].validate(options);
|
|
813
|
+
if (result instanceof Promise) return { diagnostic: {
|
|
814
|
+
code: "invalid-rule-options",
|
|
815
|
+
severity: "error",
|
|
816
|
+
ruleId: id,
|
|
817
|
+
message: `Rule "${id}" has an asynchronous \`optionsSchema\`, which cannot be evaluated while resolving configuration. Use a synchronous schema.`
|
|
818
|
+
} };
|
|
819
|
+
if (result.issues) return { diagnostic: {
|
|
820
|
+
code: "invalid-rule-options",
|
|
821
|
+
severity: "error",
|
|
822
|
+
ruleId: id,
|
|
823
|
+
message: `Invalid options for rule "${id}": ${result.issues.map((i) => i.message).join("; ")}`,
|
|
824
|
+
details: { issues: result.issues.map((i) => i.message) }
|
|
825
|
+
} };
|
|
826
|
+
return { value: result.value };
|
|
827
|
+
}
|
|
828
|
+
/** Splits materialized entries from string specs the loader was supposed to resolve. */
|
|
829
|
+
function materialized(specs, what, diagnostics) {
|
|
830
|
+
const out = [];
|
|
831
|
+
for (const spec of specs) {
|
|
832
|
+
if (typeof spec === "string" || Array.isArray(spec)) {
|
|
833
|
+
const name = typeof spec === "string" ? spec : String(spec[0]);
|
|
834
|
+
diagnostics.push(configError(`${what} "${name}" was given as a name, but nothing resolved it to a module. Named ${what.toLowerCase()}s are resolved when the config is loaded from a file; if you are calling resolveConfig() directly, pass the imported object instead.`));
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
out.push(spec);
|
|
838
|
+
}
|
|
839
|
+
return out;
|
|
840
|
+
}
|
|
841
|
+
function resolveConfig(user, opts) {
|
|
842
|
+
const cwd = opts?.cwd ?? process.cwd();
|
|
843
|
+
const diagnostics = [];
|
|
844
|
+
if ("root" in user) diagnostics.push(configError("`root` has been split into `repoRoot` (base for reported paths, SARIF, and fingerprints) and `sourceRoot` (base for include/exclude and classifier patterns). A config that used `root: \"src\"` almost certainly wants `sourceRoot: \"src\"` with `repoRoot` left at its default."));
|
|
845
|
+
if (user.extends !== void 0) diagnostics.push(configError("`extends` was not resolved. It is followed when the config is loaded from a file; resolveConfig() receives an already-flattened config."));
|
|
846
|
+
const presets = materialized(user.presets ?? [], "Preset", diagnostics);
|
|
847
|
+
const userRules = materialized(user.rules ?? [], "Rule", diagnostics);
|
|
848
|
+
const merged = /* @__PURE__ */ new Map();
|
|
849
|
+
for (const { configured, id } of withIds(presets, userRules, diagnostics)) {
|
|
850
|
+
const prev = merged.get(id);
|
|
851
|
+
const severity = configured.severity ?? prev?.severity;
|
|
852
|
+
const scope = configured.scope ?? prev?.scope;
|
|
853
|
+
const message = configured.message ?? prev?.message;
|
|
854
|
+
merged.set(id, {
|
|
855
|
+
rule: configured.rule,
|
|
856
|
+
options: mergeRuleOptions(prev?.options, configured.options),
|
|
857
|
+
...severity !== void 0 ? { severity } : {},
|
|
858
|
+
...scope !== void 0 ? { scope } : {},
|
|
859
|
+
...message !== void 0 ? { message } : {}
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
for (const [key, override] of Object.entries(user.overrides ?? {})) {
|
|
863
|
+
const targets = [...merged.entries()].filter(([id, entry]) => id === key || entry.rule.meta.name === key || matchesPattern(id, key));
|
|
864
|
+
if (targets.length === 0) {
|
|
865
|
+
const known = [...merged.keys()].sort().join(", ");
|
|
866
|
+
diagnostics.push(configError(`Override key "${key}" matches no configured rule and was ignored. Configured rules: ${known || "(none)"}.`));
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
const patch = typeof override === "string" ? { severity: override } : override;
|
|
870
|
+
for (const [, entry] of targets) {
|
|
871
|
+
if (patch.severity !== void 0) entry.severity = patch.severity;
|
|
872
|
+
if (patch.options !== void 0) entry.options = mergeRuleOptions(entry.options, patch.options);
|
|
873
|
+
if (patch.scope !== void 0) entry.scope = patch.scope;
|
|
874
|
+
if (patch.message !== void 0) entry.message = patch.message;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
const rules = [];
|
|
878
|
+
for (const [id, entry] of merged) {
|
|
879
|
+
const severity = entry.severity ?? entry.rule.meta.defaultSeverity;
|
|
880
|
+
if (severity === "off") continue;
|
|
881
|
+
const validated = validateOptions(entry.rule, id, entry.options);
|
|
882
|
+
if (validated.diagnostic !== void 0) {
|
|
883
|
+
diagnostics.push(validated.diagnostic);
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
rules.push({
|
|
887
|
+
rule: entry.rule,
|
|
888
|
+
id,
|
|
889
|
+
options: validated.value,
|
|
890
|
+
severity,
|
|
891
|
+
...entry.scope !== void 0 ? { scope: entry.scope } : {},
|
|
892
|
+
...entry.message !== void 0 ? { message: entry.message } : {}
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
const reporterSpecs = [];
|
|
896
|
+
for (const spec of [...user.reporters ?? ["console"], ...presets.flatMap((p) => p.reporters ?? [])]) {
|
|
897
|
+
const name = typeof spec === "string" ? spec : typeof spec.reporter === "string" ? spec.reporter : void 0;
|
|
898
|
+
if (name !== void 0 && !isBuiltinReporterName(name)) {
|
|
899
|
+
diagnostics.push(configError(`Reporter "${name}" is not a built-in (${BUILTIN_REPORTER_NAMES.join(", ")}) and nothing resolved it to a module, so it was dropped. Named reporters are resolved when the config is loaded from a file.`));
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
reporterSpecs.push(spec);
|
|
903
|
+
}
|
|
904
|
+
const repoRoot = path.resolve(cwd, user.repoRoot ?? ".");
|
|
905
|
+
return {
|
|
906
|
+
repoRoot,
|
|
907
|
+
sourceRoot: path.resolve(repoRoot, user.sourceRoot ?? "."),
|
|
908
|
+
include: user.include ?? [...DEFAULT_INCLUDE],
|
|
909
|
+
exclude: [...user.excludeDefaults === false ? [] : DEFAULT_EXCLUDE, ...user.exclude ?? []],
|
|
910
|
+
classifiers: [...presets.flatMap((p) => p.classifiers), ...user.classifiers ?? []],
|
|
911
|
+
transforms: [...presets.flatMap((p) => p.transforms ?? []), ...user.transforms ?? []],
|
|
912
|
+
rules,
|
|
913
|
+
reporterSpecs,
|
|
914
|
+
failOn: user.failOn ?? "error",
|
|
915
|
+
failOnDiagnostics: resolveFailOnDiagnostics(user.failOnDiagnostics),
|
|
916
|
+
diagnostics
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
//#endregion
|
|
920
|
+
//#region src/contracts/preset.ts
|
|
921
|
+
function definePreset(fn) {
|
|
922
|
+
return fn;
|
|
923
|
+
}
|
|
924
|
+
//#endregion
|
|
925
|
+
//#region src/contracts/reporter.ts
|
|
926
|
+
function defineReporter(reporter) {
|
|
927
|
+
return reporter;
|
|
928
|
+
}
|
|
929
|
+
//#endregion
|
|
930
|
+
//#region src/contracts/rule.ts
|
|
931
|
+
function defineRule(rule) {
|
|
932
|
+
const callable = (options, settings) => configureRule(callable, options, settings);
|
|
933
|
+
return Object.assign(callable, rule);
|
|
934
|
+
}
|
|
935
|
+
function configureRule(rule, options, settings) {
|
|
936
|
+
return {
|
|
937
|
+
rule,
|
|
938
|
+
options: options ?? {},
|
|
939
|
+
...settings?.id !== void 0 ? { id: settings.id } : {},
|
|
940
|
+
...settings?.severity !== void 0 ? { severity: settings.severity } : {},
|
|
941
|
+
...settings?.scope !== void 0 ? { scope: settings.scope } : {},
|
|
942
|
+
...settings?.message !== void 0 ? { message: settings.message } : {}
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
//#endregion
|
|
946
|
+
//#region src/contracts/transform.ts
|
|
947
|
+
function defineTransform(transform) {
|
|
948
|
+
return transform;
|
|
949
|
+
}
|
|
950
|
+
//#endregion
|
|
951
|
+
//#region src/engine/analyze.ts
|
|
952
|
+
/**
|
|
953
|
+
* The engine: prepare the graph (boundary → transforms → classify), then check it.
|
|
954
|
+
*
|
|
955
|
+
* Pure — no I/O, no reporter calls; reporters are driven by the run edge (integration-kit).
|
|
956
|
+
*/
|
|
957
|
+
async function analyze(graph, config) {
|
|
958
|
+
const started = performance.now();
|
|
959
|
+
assertIrCompatible(graph.irVersion);
|
|
960
|
+
const diagnostics = [...config.diagnostics];
|
|
961
|
+
const effective = new Set(graph.host.capabilities);
|
|
962
|
+
if (graph.delivery === "progressive") effective.delete("complete-graph");
|
|
963
|
+
const prepared = prepareGraph(graph, config, config.transforms, config.classifiers);
|
|
964
|
+
const classified = prepared.graph;
|
|
965
|
+
diagnostics.push(...prepared.diagnostics);
|
|
966
|
+
for (const c of prepared.provided) effective.add(c);
|
|
967
|
+
const query = new GraphQuery(classified);
|
|
968
|
+
const cache = new GraphComputationCache();
|
|
969
|
+
const relative = (file) => sourceRelative(config.sourceRoot, file);
|
|
970
|
+
const scopedQueries = /* @__PURE__ */ new Map();
|
|
971
|
+
const scopeKeyOf = (scope) => scope === void 0 ? "*" : JSON.stringify([
|
|
972
|
+
scope.include ?? null,
|
|
973
|
+
scope.exclude ?? null,
|
|
974
|
+
scope.tag ?? null
|
|
975
|
+
]);
|
|
976
|
+
const queryFor = (scope, key) => {
|
|
977
|
+
if (scope === void 0) return {
|
|
978
|
+
query,
|
|
979
|
+
size: classified.moduleCount
|
|
980
|
+
};
|
|
981
|
+
let scoped = scopedQueries.get(key);
|
|
982
|
+
if (!scoped) {
|
|
983
|
+
const ids = modulesInScope(classified, scope, config.sourceRoot);
|
|
984
|
+
scoped = {
|
|
985
|
+
query: query.scoped(ids),
|
|
986
|
+
size: ids.size
|
|
987
|
+
};
|
|
988
|
+
scopedQueries.set(key, scoped);
|
|
989
|
+
}
|
|
990
|
+
return scoped;
|
|
991
|
+
};
|
|
992
|
+
const violations = [];
|
|
993
|
+
const runs = [];
|
|
994
|
+
for (const resolved of config.rules) {
|
|
995
|
+
const { rule, id, options, severity, scope, message } = resolved;
|
|
996
|
+
const base = {
|
|
997
|
+
id,
|
|
998
|
+
name: rule.meta.name,
|
|
999
|
+
description: rule.meta.description,
|
|
1000
|
+
...rule.meta.docsUrl !== void 0 ? { docsUrl: rule.meta.docsUrl } : {},
|
|
1001
|
+
severity,
|
|
1002
|
+
...rule.meta.deprecated !== void 0 ? { deprecated: true } : {}
|
|
1003
|
+
};
|
|
1004
|
+
if (rule.meta.deprecated !== void 0) {
|
|
1005
|
+
const d = rule.meta.deprecated;
|
|
1006
|
+
diagnostics.push({
|
|
1007
|
+
code: "rule-deprecated",
|
|
1008
|
+
severity: "warn",
|
|
1009
|
+
ruleId: id,
|
|
1010
|
+
message: `Rule "${rule.meta.name}" is deprecated since ${d.since}` + (d.replacedBy !== void 0 ? `; use "${d.replacedBy}" instead` : "") + (d.reason !== void 0 ? `. ${d.reason}` : "."),
|
|
1011
|
+
details: {
|
|
1012
|
+
since: d.since,
|
|
1013
|
+
...d.replacedBy !== void 0 ? { replacedBy: d.replacedBy } : {}
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
const missing = (rule.meta.requiredCapabilities ?? []).filter((c) => !effective.has(c));
|
|
1018
|
+
if (missing.length > 0) {
|
|
1019
|
+
diagnostics.push({
|
|
1020
|
+
code: "rule-skipped",
|
|
1021
|
+
severity: "warn",
|
|
1022
|
+
ruleId: id,
|
|
1023
|
+
message: `Rule "${rule.meta.name}" needs capabilities [${missing.join(", ")}] that host "${graph.host.name}" cannot provide in this mode; the rule was skipped. Run via a host with these capabilities for full coverage.`,
|
|
1024
|
+
details: {
|
|
1025
|
+
missingCapabilities: missing,
|
|
1026
|
+
host: graph.host.name
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
runs.push({
|
|
1030
|
+
resolved,
|
|
1031
|
+
ctx: null,
|
|
1032
|
+
info: {
|
|
1033
|
+
...base,
|
|
1034
|
+
status: "skipped",
|
|
1035
|
+
violations: 0,
|
|
1036
|
+
durationMs: 0,
|
|
1037
|
+
missingCapabilities: missing
|
|
1038
|
+
},
|
|
1039
|
+
halted: true,
|
|
1040
|
+
crashed: false
|
|
1041
|
+
});
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
if (rule.visits === void 0 && rule.check === void 0) {
|
|
1045
|
+
diagnostics.push({
|
|
1046
|
+
code: "invalid-config",
|
|
1047
|
+
severity: "error",
|
|
1048
|
+
ruleId: id,
|
|
1049
|
+
message: `Rule "${rule.meta.name}" declares neither \`visits\` nor \`check\`, so it can never report anything. This is a bug in the rule.`
|
|
1050
|
+
});
|
|
1051
|
+
runs.push({
|
|
1052
|
+
resolved,
|
|
1053
|
+
ctx: null,
|
|
1054
|
+
info: {
|
|
1055
|
+
...base,
|
|
1056
|
+
status: "skipped",
|
|
1057
|
+
violations: 0,
|
|
1058
|
+
durationMs: 0
|
|
1059
|
+
},
|
|
1060
|
+
halted: true,
|
|
1061
|
+
crashed: false
|
|
1062
|
+
});
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
const templates = messageTemplates(rule.meta.messages, message);
|
|
1066
|
+
const { query: scopedQuery, size: scopeSize } = queryFor(scope, scopeKeyOf(scope));
|
|
1067
|
+
if (scope !== void 0 && scopeSize === 0) diagnostics.push({
|
|
1068
|
+
code: "empty-scope",
|
|
1069
|
+
severity: "warn",
|
|
1070
|
+
ruleId: id,
|
|
1071
|
+
message: `Rule "${id}" is scoped to 0 of ${classified.moduleCount} modules, so it cannot report anything. Check \`scope\` — path patterns are matched relative to \`sourceRoot\` ("${config.sourceRoot}"), and \`tag\` requires the module to already be classified.`,
|
|
1072
|
+
details: {
|
|
1073
|
+
scope,
|
|
1074
|
+
totalModules: classified.moduleCount
|
|
1075
|
+
}
|
|
1076
|
+
});
|
|
1077
|
+
const ctx = {
|
|
1078
|
+
options,
|
|
1079
|
+
graph: scopedQuery,
|
|
1080
|
+
sourceRoot: config.sourceRoot,
|
|
1081
|
+
repoRoot: config.repoRoot,
|
|
1082
|
+
relative,
|
|
1083
|
+
display: displayModuleId,
|
|
1084
|
+
compute: (c) => cache.get(c, scopedQuery),
|
|
1085
|
+
report: (v) => {
|
|
1086
|
+
const locations = locationsOf(v);
|
|
1087
|
+
const template = v.messageId !== void 0 ? templates[v.messageId] : void 0;
|
|
1088
|
+
let text;
|
|
1089
|
+
if (v.message !== void 0) text = v.message;
|
|
1090
|
+
else if (template !== void 0) text = renderMessage(template, v.data);
|
|
1091
|
+
else {
|
|
1092
|
+
text = `${rule.meta.name}: ${v.messageId ?? "(no message)"}`;
|
|
1093
|
+
diagnostics.push({
|
|
1094
|
+
code: "invalid-config",
|
|
1095
|
+
severity: "error",
|
|
1096
|
+
ruleId: id,
|
|
1097
|
+
message: `Rule "${rule.meta.name}" reported messageId "${v.messageId ?? ""}" but no template is defined for it, in either \`meta.messages\` or the instance's \`message\`.`
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
violations.push({
|
|
1101
|
+
ruleName: rule.meta.name,
|
|
1102
|
+
ruleId: id,
|
|
1103
|
+
severity: v.severity ?? severity,
|
|
1104
|
+
message: text,
|
|
1105
|
+
...v.messageId !== void 0 ? { messageId: v.messageId } : {},
|
|
1106
|
+
...v.data !== void 0 ? { data: v.data } : {},
|
|
1107
|
+
locations,
|
|
1108
|
+
...v.explanation !== void 0 ? { explanation: v.explanation } : {},
|
|
1109
|
+
fingerprint: fingerprintOf(config.repoRoot, id, v)
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
};
|
|
1113
|
+
runs.push({
|
|
1114
|
+
resolved,
|
|
1115
|
+
ctx,
|
|
1116
|
+
info: {
|
|
1117
|
+
...base,
|
|
1118
|
+
status: "ran",
|
|
1119
|
+
violations: 0,
|
|
1120
|
+
durationMs: 0
|
|
1121
|
+
},
|
|
1122
|
+
halted: false,
|
|
1123
|
+
crashed: false
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
const active = runs.filter((r) => !r.halted);
|
|
1127
|
+
dispatchVisitors(active, diagnostics, scopeKeyOf);
|
|
1128
|
+
for (const run of active) {
|
|
1129
|
+
if (run.halted || run.resolved.rule.check === void 0) continue;
|
|
1130
|
+
const startedRule = performance.now();
|
|
1131
|
+
try {
|
|
1132
|
+
await run.resolved.rule.check(run.ctx);
|
|
1133
|
+
} catch (err) {
|
|
1134
|
+
markFailed(run, err, diagnostics);
|
|
1135
|
+
}
|
|
1136
|
+
run.info.durationMs += performance.now() - startedRule;
|
|
1137
|
+
}
|
|
1138
|
+
const crashed = new Set(runs.filter((r) => r.crashed).map((r) => r.info.id));
|
|
1139
|
+
const kept = crashed.size === 0 ? violations : violations.filter((v) => !crashed.has(v.ruleId));
|
|
1140
|
+
const perRule = /* @__PURE__ */ new Map();
|
|
1141
|
+
for (const v of kept) perRule.set(v.ruleId, (perRule.get(v.ruleId) ?? 0) + 1);
|
|
1142
|
+
for (const run of runs) run.info.violations = perRule.get(run.info.id) ?? 0;
|
|
1143
|
+
diagnostics.push(...auditClassification(classified));
|
|
1144
|
+
return {
|
|
1145
|
+
violations: kept.sort(compareViolations),
|
|
1146
|
+
diagnostics,
|
|
1147
|
+
rules: runs.map((r) => r.info),
|
|
1148
|
+
repoRoot: config.repoRoot,
|
|
1149
|
+
host: graph.host,
|
|
1150
|
+
delivery: graph.delivery,
|
|
1151
|
+
stats: {
|
|
1152
|
+
moduleCount: classified.moduleCount,
|
|
1153
|
+
edgeCount: classified.edgeCount,
|
|
1154
|
+
durationMs: performance.now() - started
|
|
1155
|
+
}
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Runs every declared-interest rule, one traversal per distinct (scope, filter) pair.
|
|
1160
|
+
*
|
|
1161
|
+
* The filtered slice is materialized once and shared by every rule that asked for it, which
|
|
1162
|
+
* is what makes the cost O(distinct slices + total visits) rather than O(rules × graph).
|
|
1163
|
+
* Rules that want the whole edge list with no filter share the graph's own array and copy
|
|
1164
|
+
* nothing at all.
|
|
1165
|
+
*
|
|
1166
|
+
* Isolation is per rule, not per visit: the try/catch wraps a rule's entire pass over the
|
|
1167
|
+
* slice, so a rule that throws stops and is marked failed while the other thirty-nine keep
|
|
1168
|
+
* their results — without paying for exception handling on every edge.
|
|
1169
|
+
*/
|
|
1170
|
+
function dispatchVisitors(runs, diagnostics, scopeKeyOf) {
|
|
1171
|
+
const edgeBuckets = /* @__PURE__ */ new Map();
|
|
1172
|
+
const moduleBuckets = /* @__PURE__ */ new Map();
|
|
1173
|
+
for (const run of runs) {
|
|
1174
|
+
const visits = run.resolved.rule.visits;
|
|
1175
|
+
if (visits === void 0) continue;
|
|
1176
|
+
const scopeKey = scopeKeyOf(run.resolved.scope);
|
|
1177
|
+
const query = run.ctx.graph;
|
|
1178
|
+
const edgeSpec = visits.edges;
|
|
1179
|
+
if (edgeSpec !== void 0) try {
|
|
1180
|
+
const filter = edgeSpec.filter?.(run.ctx.options);
|
|
1181
|
+
const key = `${scopeKey}|e|${filterKey(filter)}`;
|
|
1182
|
+
let bucket = edgeBuckets.get(key);
|
|
1183
|
+
if (bucket === void 0) {
|
|
1184
|
+
bucket = {
|
|
1185
|
+
slice: () => query.edges(filter),
|
|
1186
|
+
members: []
|
|
1187
|
+
};
|
|
1188
|
+
edgeBuckets.set(key, bucket);
|
|
1189
|
+
}
|
|
1190
|
+
bucket.members.push({
|
|
1191
|
+
run,
|
|
1192
|
+
visit: edgeSpec.visit
|
|
1193
|
+
});
|
|
1194
|
+
} catch (err) {
|
|
1195
|
+
markFailed(run, err, diagnostics);
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1198
|
+
const moduleSpec = visits.modules;
|
|
1199
|
+
if (moduleSpec !== void 0) try {
|
|
1200
|
+
const filter = moduleSpec.filter?.(run.ctx.options);
|
|
1201
|
+
const key = `${scopeKey}|m|${filterKey(filter)}`;
|
|
1202
|
+
let bucket = moduleBuckets.get(key);
|
|
1203
|
+
if (bucket === void 0) {
|
|
1204
|
+
bucket = {
|
|
1205
|
+
slice: () => query.modules(filter).toArray(),
|
|
1206
|
+
members: []
|
|
1207
|
+
};
|
|
1208
|
+
moduleBuckets.set(key, bucket);
|
|
1209
|
+
}
|
|
1210
|
+
bucket.members.push({
|
|
1211
|
+
run,
|
|
1212
|
+
visit: moduleSpec.visit
|
|
1213
|
+
});
|
|
1214
|
+
} catch (err) {
|
|
1215
|
+
markFailed(run, err, diagnostics);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
const drain = (buckets) => {
|
|
1219
|
+
for (const bucket of buckets.values()) {
|
|
1220
|
+
const items = bucket.slice();
|
|
1221
|
+
for (const { run, visit } of bucket.members) {
|
|
1222
|
+
if (run.halted) continue;
|
|
1223
|
+
const startedRule = performance.now();
|
|
1224
|
+
try {
|
|
1225
|
+
for (const item of items) visit(item, run.ctx);
|
|
1226
|
+
} catch (err) {
|
|
1227
|
+
markFailed(run, err, diagnostics);
|
|
1228
|
+
}
|
|
1229
|
+
run.info.durationMs += performance.now() - startedRule;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
};
|
|
1233
|
+
drain(edgeBuckets);
|
|
1234
|
+
drain(moduleBuckets);
|
|
1235
|
+
}
|
|
1236
|
+
function markFailed(run, err, diagnostics) {
|
|
1237
|
+
run.halted = true;
|
|
1238
|
+
run.crashed = true;
|
|
1239
|
+
run.info.status = "failed";
|
|
1240
|
+
diagnostics.push({
|
|
1241
|
+
code: "rule-failed",
|
|
1242
|
+
severity: "error",
|
|
1243
|
+
ruleId: run.resolved.id,
|
|
1244
|
+
message: `Rule "${run.resolved.id}" threw and produced no results: ${err instanceof Error ? err.message : String(err)}`,
|
|
1245
|
+
...err instanceof Error && err.stack !== void 0 ? { details: { stack: err.stack } } : {}
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* The instance's message templates over the rule's own.
|
|
1250
|
+
*
|
|
1251
|
+
* A bare string retargets a single-message rule; a record retargets by id. Anything the
|
|
1252
|
+
* instance does not mention keeps the rule's wording.
|
|
1253
|
+
*/
|
|
1254
|
+
function messageTemplates(own, override) {
|
|
1255
|
+
const base = { ...own ?? {} };
|
|
1256
|
+
if (override === void 0) return base;
|
|
1257
|
+
if (typeof override === "string") {
|
|
1258
|
+
const ids = Object.keys(base);
|
|
1259
|
+
for (const id of ids.length > 0 ? ids : ["default"]) base[id] = override;
|
|
1260
|
+
return base;
|
|
1261
|
+
}
|
|
1262
|
+
return {
|
|
1263
|
+
...base,
|
|
1264
|
+
...override
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* Resolves a {@link RuleScope} to the concrete set of modules a scoped rule is about.
|
|
1269
|
+
*
|
|
1270
|
+
* Path patterns are matched source-root-relative — the same base `include`/`exclude` and
|
|
1271
|
+
* classifier patterns use. A module with no file (a builtin, a virtual module) can never be
|
|
1272
|
+
* *in* a path scope, but it remains reachable as an edge target, which is where a scoped
|
|
1273
|
+
* rule actually needs to see it.
|
|
1274
|
+
*/
|
|
1275
|
+
function modulesInScope(graph, scope, sourceRoot) {
|
|
1276
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1277
|
+
for (const m of graph.modules()) {
|
|
1278
|
+
if (scope.tag !== void 0) {
|
|
1279
|
+
if (!Object.entries(scope.tag).every(([k, v]) => m.tags.get(k) === v)) continue;
|
|
1280
|
+
}
|
|
1281
|
+
if (scope.include !== void 0 || scope.exclude !== void 0) {
|
|
1282
|
+
if (m.file === null) continue;
|
|
1283
|
+
const rel = sourceRelative(sourceRoot, m.file);
|
|
1284
|
+
if (rel === null) continue;
|
|
1285
|
+
if (scope.include !== void 0 && !scope.include.some((p) => matchesPattern(rel, p))) continue;
|
|
1286
|
+
if (scope.exclude !== void 0 && scope.exclude.some((p) => matchesPattern(rel, p))) continue;
|
|
1287
|
+
}
|
|
1288
|
+
ids.add(m.id);
|
|
1289
|
+
}
|
|
1290
|
+
return ids;
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* The tool's most dangerous property is that its failure mode is *silence*: every rule
|
|
1294
|
+
* ignores modules it cannot classify, so a misconfigured `sourceRoot` tags nothing, matches
|
|
1295
|
+
* nothing, reports nothing, and passes. These diagnostics are the difference between "your
|
|
1296
|
+
* architecture is clean" and "ArchWall never looked at your code".
|
|
1297
|
+
*/
|
|
1298
|
+
function auditClassification(graph) {
|
|
1299
|
+
let source = 0;
|
|
1300
|
+
let tagged = 0;
|
|
1301
|
+
for (const m of graph.modules()) {
|
|
1302
|
+
if (m.kind !== "source") continue;
|
|
1303
|
+
source++;
|
|
1304
|
+
if (m.tags.size > 0) tagged++;
|
|
1305
|
+
}
|
|
1306
|
+
if (source === 0) return [{
|
|
1307
|
+
code: "empty-project",
|
|
1308
|
+
severity: "warn",
|
|
1309
|
+
message: "No source modules were analysed — every module was external or filtered out by the project boundary. Check `sourceRoot`, `include`, and `exclude`.",
|
|
1310
|
+
details: { sourceModules: 0 }
|
|
1311
|
+
}];
|
|
1312
|
+
if (tagged === 0) return [{
|
|
1313
|
+
code: "no-modules-classified",
|
|
1314
|
+
severity: "warn",
|
|
1315
|
+
message: `0 of ${source} source modules were classified, so every tag-based rule matched nothing and this run cannot have found anything. This almost always means \`sourceRoot\` points somewhere other than your source tree, or that no classifier or preset is configured.`,
|
|
1316
|
+
details: {
|
|
1317
|
+
sourceModules: source,
|
|
1318
|
+
classifiedModules: 0
|
|
1319
|
+
}
|
|
1320
|
+
}];
|
|
1321
|
+
return [];
|
|
1322
|
+
}
|
|
1323
|
+
//#endregion
|
|
1324
|
+
//#region src/transforms/drop-self-edges.ts
|
|
1325
|
+
/**
|
|
1326
|
+
* Removes edges from a module to itself.
|
|
1327
|
+
*
|
|
1328
|
+
* This is a *semantic policy*, and it belongs in shared code a host opts into rather than
|
|
1329
|
+
* inside one adapter: HMR instrumentation adds self-edges (React Fast Refresh makes every
|
|
1330
|
+
* transformed component module import itself), and that reasoning is not Vite-specific —
|
|
1331
|
+
* the moment another bundler's HMR does the same thing, an adapter-local fix has to be
|
|
1332
|
+
* written a second time, and the two can then disagree.
|
|
1333
|
+
*
|
|
1334
|
+
* Deliberately NOT on by default: a genuine self-import is a real finding, and build mode
|
|
1335
|
+
* sees the real graph. A host applies this only where it knows its own instrumentation
|
|
1336
|
+
* created the edges.
|
|
1337
|
+
*/
|
|
1338
|
+
function dropSelfEdges() {
|
|
1339
|
+
return defineTransform({
|
|
1340
|
+
name: "drop-self-edges",
|
|
1341
|
+
transform(graph) {
|
|
1342
|
+
graph.removeEdges((e) => e.from === e.to);
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
//#endregion
|
|
1347
|
+
export { ArchWallError, BUILTIN_REPORTER_NAMES, DIAGNOSTIC_GATES, FINGERPRINT_SCHEME, FIRST_PARTY_KINDS, GraphQuery, IR_VERSION, IrVersionMismatchError, MODULE_ID_SCHEMES, ProjectGraph, THIRD_PARTY_KINDS, analyze, assertIrCompatible, compareViolations, configureRule, consoleReporter, countBySeverity, defaultIO, defineClassifier, defineConfig, defineGraphComputation, definePreset, defineReporter, defineRule, defineTransform, displayModuleId, dropSelfEdges, failingDiagnosticCodes, fingerprintOf, formatViolation, irMajor, isBuiltinReporterName, isFirstParty, isThirdParty, jsonReporter, locationsOf, matchCaptures, matchesPattern, parseModuleId, pathClassifier, primaryEdge, primaryModule, primarySourceLocation, renderMessage, resolveConfig, resolveFailOnDiagnostics, resolveReporters, sarifReporter, stronglyConnectedComponents };
|
|
1348
|
+
|
|
1349
|
+
//# sourceMappingURL=index.mjs.map
|