@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/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