@goodbones/core 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/LICENSE +21 -0
  2. package/build/dts/core/baseline.d.ts +16 -0
  3. package/build/dts/core/baseline.d.ts.map +1 -0
  4. package/build/dts/core/coverage.d.ts +47 -0
  5. package/build/dts/core/coverage.d.ts.map +1 -0
  6. package/build/dts/core/exports.d.ts +38 -0
  7. package/build/dts/core/exports.d.ts.map +1 -0
  8. package/build/dts/core/graph.d.ts +40 -0
  9. package/build/dts/core/graph.d.ts.map +1 -0
  10. package/build/dts/core/imports.d.ts +29 -0
  11. package/build/dts/core/imports.d.ts.map +1 -0
  12. package/build/dts/core/members.d.ts +26 -0
  13. package/build/dts/core/members.d.ts.map +1 -0
  14. package/build/dts/core/patterns.d.ts +17 -0
  15. package/build/dts/core/patterns.d.ts.map +1 -0
  16. package/build/dts/core/structure.d.ts +48 -0
  17. package/build/dts/core/structure.d.ts.map +1 -0
  18. package/build/dts/core/surface.d.ts +39 -0
  19. package/build/dts/core/surface.d.ts.map +1 -0
  20. package/build/dts/domain/architecture-config.d.ts +322 -0
  21. package/build/dts/domain/architecture-config.d.ts.map +1 -0
  22. package/build/dts/domain/architecture-error.d.ts +35 -0
  23. package/build/dts/domain/architecture-error.d.ts.map +1 -0
  24. package/build/dts/domain/facts.d.ts +26 -0
  25. package/build/dts/domain/facts.d.ts.map +1 -0
  26. package/build/dts/domain/violation.d.ts +11 -0
  27. package/build/dts/domain/violation.d.ts.map +1 -0
  28. package/build/dts/index.d.ts +23 -0
  29. package/build/dts/index.d.ts.map +1 -0
  30. package/build/dts/infrastructure/fact-extractor-fake.d.ts +4 -0
  31. package/build/dts/infrastructure/fact-extractor-fake.d.ts.map +1 -0
  32. package/build/dts/infrastructure/file-system-fake.d.ts +3 -0
  33. package/build/dts/infrastructure/file-system-fake.d.ts.map +1 -0
  34. package/build/dts/infrastructure/file-system-live.d.ts +3 -0
  35. package/build/dts/infrastructure/file-system-live.d.ts.map +1 -0
  36. package/build/dts/infrastructure/manifest-file.d.ts +3 -0
  37. package/build/dts/infrastructure/manifest-file.d.ts.map +1 -0
  38. package/build/dts/infrastructure/module-resolver-fake.d.ts +3 -0
  39. package/build/dts/infrastructure/module-resolver-fake.d.ts.map +1 -0
  40. package/build/dts/infrastructure/walk.d.ts +4 -0
  41. package/build/dts/infrastructure/walk.d.ts.map +1 -0
  42. package/build/dts/load/policy.d.ts +42 -0
  43. package/build/dts/load/policy.d.ts.map +1 -0
  44. package/build/dts/manifest/compile.d.ts +26 -0
  45. package/build/dts/manifest/compile.d.ts.map +1 -0
  46. package/build/dts/manifest/glob.d.ts +14 -0
  47. package/build/dts/manifest/glob.d.ts.map +1 -0
  48. package/build/dts/manifest/manifest.d.ts +208 -0
  49. package/build/dts/manifest/manifest.d.ts.map +1 -0
  50. package/build/dts/ports/fact-extractor.d.ts +5 -0
  51. package/build/dts/ports/fact-extractor.d.ts.map +1 -0
  52. package/build/dts/ports/file-system.d.ts +5 -0
  53. package/build/dts/ports/file-system.d.ts.map +1 -0
  54. package/build/dts/ports/language.d.ts +14 -0
  55. package/build/dts/ports/language.d.ts.map +1 -0
  56. package/build/dts/ports/module-resolver.d.ts +12 -0
  57. package/build/dts/ports/module-resolver.d.ts.map +1 -0
  58. package/build/dts/testing.d.ts +4 -0
  59. package/build/dts/testing.d.ts.map +1 -0
  60. package/build/esm/core/baseline.js +30 -0
  61. package/build/esm/core/baseline.js.map +1 -0
  62. package/build/esm/core/coverage.js +75 -0
  63. package/build/esm/core/coverage.js.map +1 -0
  64. package/build/esm/core/exports.js +102 -0
  65. package/build/esm/core/exports.js.map +1 -0
  66. package/build/esm/core/graph.js +259 -0
  67. package/build/esm/core/graph.js.map +1 -0
  68. package/build/esm/core/imports.js +103 -0
  69. package/build/esm/core/imports.js.map +1 -0
  70. package/build/esm/core/members.js +106 -0
  71. package/build/esm/core/members.js.map +1 -0
  72. package/build/esm/core/patterns.js +75 -0
  73. package/build/esm/core/patterns.js.map +1 -0
  74. package/build/esm/core/structure.js +257 -0
  75. package/build/esm/core/structure.js.map +1 -0
  76. package/build/esm/core/surface.js +137 -0
  77. package/build/esm/core/surface.js.map +1 -0
  78. package/build/esm/domain/architecture-config.js +312 -0
  79. package/build/esm/domain/architecture-config.js.map +1 -0
  80. package/build/esm/domain/architecture-error.js +41 -0
  81. package/build/esm/domain/architecture-error.js.map +1 -0
  82. package/build/esm/domain/facts.js +2 -0
  83. package/build/esm/domain/facts.js.map +1 -0
  84. package/build/esm/domain/violation.js +8 -0
  85. package/build/esm/domain/violation.js.map +1 -0
  86. package/build/esm/index.js +22 -0
  87. package/build/esm/index.js.map +1 -0
  88. package/build/esm/infrastructure/fact-extractor-fake.js +24 -0
  89. package/build/esm/infrastructure/fact-extractor-fake.js.map +1 -0
  90. package/build/esm/infrastructure/file-system-fake.js +10 -0
  91. package/build/esm/infrastructure/file-system-fake.js.map +1 -0
  92. package/build/esm/infrastructure/file-system-live.js +27 -0
  93. package/build/esm/infrastructure/file-system-live.js.map +1 -0
  94. package/build/esm/infrastructure/manifest-file.js +15 -0
  95. package/build/esm/infrastructure/manifest-file.js.map +1 -0
  96. package/build/esm/infrastructure/module-resolver-fake.js +33 -0
  97. package/build/esm/infrastructure/module-resolver-fake.js.map +1 -0
  98. package/build/esm/infrastructure/walk.js +45 -0
  99. package/build/esm/infrastructure/walk.js.map +1 -0
  100. package/build/esm/load/policy.js +207 -0
  101. package/build/esm/load/policy.js.map +1 -0
  102. package/build/esm/manifest/compile.js +706 -0
  103. package/build/esm/manifest/compile.js.map +1 -0
  104. package/build/esm/manifest/glob.js +77 -0
  105. package/build/esm/manifest/glob.js.map +1 -0
  106. package/build/esm/manifest/manifest.js +303 -0
  107. package/build/esm/manifest/manifest.js.map +1 -0
  108. package/build/esm/ports/fact-extractor.js +2 -0
  109. package/build/esm/ports/fact-extractor.js.map +1 -0
  110. package/build/esm/ports/file-system.js +2 -0
  111. package/build/esm/ports/file-system.js.map +1 -0
  112. package/build/esm/ports/language.js +2 -0
  113. package/build/esm/ports/language.js.map +1 -0
  114. package/build/esm/ports/module-resolver.js +2 -0
  115. package/build/esm/ports/module-resolver.js.map +1 -0
  116. package/build/esm/testing.js +7 -0
  117. package/build/esm/testing.js.map +1 -0
  118. package/package.json +61 -0
  119. package/src/core/baseline.ts +62 -0
  120. package/src/core/coverage.ts +154 -0
  121. package/src/core/exports.ts +171 -0
  122. package/src/core/graph.ts +331 -0
  123. package/src/core/imports.ts +160 -0
  124. package/src/core/members.ts +161 -0
  125. package/src/core/patterns.ts +116 -0
  126. package/src/core/structure.ts +349 -0
  127. package/src/core/surface.ts +181 -0
  128. package/src/domain/architecture-config.ts +370 -0
  129. package/src/domain/architecture-error.ts +59 -0
  130. package/src/domain/facts.ts +47 -0
  131. package/src/domain/violation.ts +24 -0
  132. package/src/index.ts +136 -0
  133. package/src/infrastructure/fact-extractor-fake.ts +28 -0
  134. package/src/infrastructure/file-system-fake.ts +14 -0
  135. package/src/infrastructure/file-system-live.ts +27 -0
  136. package/src/infrastructure/manifest-file.ts +17 -0
  137. package/src/infrastructure/module-resolver-fake.ts +39 -0
  138. package/src/infrastructure/walk.ts +54 -0
  139. package/src/load/policy.ts +344 -0
  140. package/src/manifest/compile.ts +1011 -0
  141. package/src/manifest/glob.ts +108 -0
  142. package/src/manifest/manifest.ts +408 -0
  143. package/src/ports/fact-extractor.ts +12 -0
  144. package/src/ports/file-system.ts +8 -0
  145. package/src/ports/language.ts +39 -0
  146. package/src/ports/module-resolver.ts +28 -0
  147. package/src/testing.ts +6 -0
@@ -0,0 +1,331 @@
1
+ import * as Result from "effect/Result";
2
+
3
+ import type {
4
+ GraphConfig,
5
+ GraphCycleRule,
6
+ GraphOrphanRule,
7
+ GraphProbe,
8
+ GraphReachRule,
9
+ } from "../domain/architecture-config.js";
10
+ import type { PatternInvalid } from "../domain/architecture-error.js";
11
+ import type { Violation } from "../domain/violation.js";
12
+ import { compilePatterns } from "./patterns.js";
13
+
14
+ // The import graph, as facts: every file the policy walked, and for each the
15
+ // files it resolves to. Externals and builtins are not nodes — a rule about
16
+ // the shape of the repository is about the repository's own files.
17
+ export type Graph = {
18
+ readonly files: ReadonlyArray<string>;
19
+ readonly edges: ReadonlyMap<string, ReadonlyArray<string>>;
20
+ };
21
+
22
+ export type CompiledGraphCycleRule = {
23
+ readonly name: string;
24
+ readonly message: string;
25
+ readonly within: ReadonlyArray<RegExp>;
26
+ readonly withinNot: ReadonlyArray<RegExp>;
27
+ readonly probe: GraphProbe;
28
+ };
29
+
30
+ export type CompiledGraphOrphanRule = CompiledGraphCycleRule & {
31
+ readonly entry: ReadonlyArray<RegExp>;
32
+ };
33
+
34
+ export type CompiledGraphReachRule = {
35
+ readonly name: string;
36
+ readonly message: string;
37
+ readonly from: ReadonlyArray<RegExp>;
38
+ readonly fromNot: ReadonlyArray<RegExp>;
39
+ readonly to: ReadonlyArray<RegExp>;
40
+ readonly toNot: ReadonlyArray<RegExp>;
41
+ readonly via: ReadonlyArray<RegExp>;
42
+ readonly probe: GraphProbe;
43
+ };
44
+
45
+ export type CompiledGraph = {
46
+ readonly cycles: ReadonlyArray<CompiledGraphCycleRule>;
47
+ readonly orphans: ReadonlyArray<CompiledGraphOrphanRule>;
48
+ readonly reach: ReadonlyArray<CompiledGraphReachRule>;
49
+ };
50
+
51
+ export const EMPTY_GRAPH_RULES: CompiledGraph = { cycles: [], orphans: [], reach: [] };
52
+
53
+ export const hasGraphRules = (rules: CompiledGraph): boolean =>
54
+ rules.cycles.length + rules.orphans.length + rules.reach.length > 0;
55
+
56
+ type Fields = ReadonlyArray<readonly [string, string | ReadonlyArray<string> | undefined]>;
57
+
58
+ const compileFields = (
59
+ ruleName: string,
60
+ fields: Fields,
61
+ ): Result.Result<ReadonlyArray<ReadonlyArray<RegExp>>, PatternInvalid> => {
62
+ const compiled: Array<ReadonlyArray<RegExp>> = [];
63
+ for (const [field, patterns] of fields) {
64
+ const one = compilePatterns(ruleName, field, patterns);
65
+ if (Result.isFailure(one)) return Result.fail(one.failure);
66
+ compiled.push(one.success);
67
+ }
68
+ return Result.succeed(compiled);
69
+ };
70
+
71
+ const compileCycle = (
72
+ rule: GraphCycleRule,
73
+ ): Result.Result<CompiledGraphCycleRule, PatternInvalid> => {
74
+ const fields = compileFields(rule.name, [
75
+ ["within", rule.within],
76
+ ["withinNot", rule.withinNot],
77
+ ]);
78
+ if (Result.isFailure(fields)) return Result.fail(fields.failure);
79
+ const [within = [], withinNot = []] = fields.success;
80
+ return Result.succeed({
81
+ name: rule.name,
82
+ message: rule.message,
83
+ probe: rule.probe,
84
+ within,
85
+ withinNot,
86
+ });
87
+ };
88
+
89
+ const compileOrphan = (
90
+ rule: GraphOrphanRule,
91
+ ): Result.Result<CompiledGraphOrphanRule, PatternInvalid> => {
92
+ const fields = compileFields(rule.name, [
93
+ ["within", rule.within],
94
+ ["withinNot", rule.withinNot],
95
+ ["entry", rule.entry],
96
+ ]);
97
+ if (Result.isFailure(fields)) return Result.fail(fields.failure);
98
+ const [within = [], withinNot = [], entry = []] = fields.success;
99
+ return Result.succeed({
100
+ name: rule.name,
101
+ message: rule.message,
102
+ probe: rule.probe,
103
+ within,
104
+ withinNot,
105
+ entry,
106
+ });
107
+ };
108
+
109
+ const compileReach = (
110
+ rule: GraphReachRule,
111
+ ): Result.Result<CompiledGraphReachRule, PatternInvalid> => {
112
+ const fields = compileFields(rule.name, [
113
+ ["from", rule.from],
114
+ ["fromNot", rule.fromNot],
115
+ ["to", rule.to],
116
+ ["toNot", rule.toNot],
117
+ ["via", rule.via],
118
+ ]);
119
+ if (Result.isFailure(fields)) return Result.fail(fields.failure);
120
+ const [from = [], fromNot = [], to = [], toNot = [], via = []] = fields.success;
121
+ return Result.succeed({
122
+ name: rule.name,
123
+ message: rule.message,
124
+ probe: rule.probe,
125
+ from,
126
+ fromNot,
127
+ to,
128
+ toNot,
129
+ via,
130
+ });
131
+ };
132
+
133
+ export const compileGraphRules = (
134
+ config: GraphConfig | undefined,
135
+ ): Result.Result<CompiledGraph, PatternInvalid> => {
136
+ const cycles: Array<CompiledGraphCycleRule> = [];
137
+ for (const rule of config?.cycles ?? []) {
138
+ const one = compileCycle(rule);
139
+ if (Result.isFailure(one)) return Result.fail(one.failure);
140
+ cycles.push(one.success);
141
+ }
142
+ const orphans: Array<CompiledGraphOrphanRule> = [];
143
+ for (const rule of config?.orphans ?? []) {
144
+ const one = compileOrphan(rule);
145
+ if (Result.isFailure(one)) return Result.fail(one.failure);
146
+ orphans.push(one.success);
147
+ }
148
+ const reach: Array<CompiledGraphReachRule> = [];
149
+ for (const rule of config?.reach ?? []) {
150
+ const one = compileReach(rule);
151
+ if (Result.isFailure(one)) return Result.fail(one.failure);
152
+ reach.push(one.success);
153
+ }
154
+ return Result.succeed({ cycles, orphans, reach });
155
+ };
156
+
157
+ const anyMatches = (patterns: ReadonlyArray<RegExp>, value: string): boolean =>
158
+ patterns.some((pattern) => pattern.test(value));
159
+
160
+ const inScope = (
161
+ scope: { readonly within: ReadonlyArray<RegExp>; readonly withinNot: ReadonlyArray<RegExp> },
162
+ file: string,
163
+ ): boolean => anyMatches(scope.within, file) && !anyMatches(scope.withinNot, file);
164
+
165
+ // Sorted adjacency, so every traversal below is deterministic and a subject
166
+ // rendered from it is the same on every run.
167
+ const neighboursOf = (graph: Graph, file: string): ReadonlyArray<string> =>
168
+ [...(graph.edges.get(file) ?? [])].sort();
169
+
170
+ // Tarjan's strongly connected components over the subgraph a rule is about.
171
+ // A component of one node is a cycle only if that node imports itself.
172
+ const stronglyConnected = (
173
+ nodes: ReadonlyArray<string>,
174
+ graph: Graph,
175
+ ): ReadonlyArray<ReadonlyArray<string>> => {
176
+ const members = new Set(nodes);
177
+ const index = new Map<string, number>();
178
+ const low = new Map<string, number>();
179
+ const onStack = new Set<string>();
180
+ const stack: Array<string> = [];
181
+ const components: Array<ReadonlyArray<string>> = [];
182
+ let next = 0;
183
+
184
+ const visit = (node: string): void => {
185
+ index.set(node, next);
186
+ low.set(node, next);
187
+ next += 1;
188
+ stack.push(node);
189
+ onStack.add(node);
190
+
191
+ for (const target of neighboursOf(graph, node)) {
192
+ if (!members.has(target)) continue;
193
+ if (!index.has(target)) {
194
+ visit(target);
195
+ low.set(node, Math.min(low.get(node) ?? 0, low.get(target) ?? 0));
196
+ } else if (onStack.has(target)) {
197
+ low.set(node, Math.min(low.get(node) ?? 0, index.get(target) ?? 0));
198
+ }
199
+ }
200
+
201
+ if (low.get(node) === index.get(node)) {
202
+ const component: Array<string> = [];
203
+ let popped: string | undefined;
204
+ do {
205
+ popped = stack.pop();
206
+ if (popped === undefined) break;
207
+ onStack.delete(popped);
208
+ component.push(popped);
209
+ } while (popped !== node);
210
+ components.push(component.sort());
211
+ }
212
+ };
213
+
214
+ for (const node of [...nodes].sort()) if (!index.has(node)) visit(node);
215
+ return components;
216
+ };
217
+
218
+ const evaluateCycles = (rule: CompiledGraphCycleRule, graph: Graph): ReadonlyArray<Violation> => {
219
+ const nodes = graph.files.filter((file) => inScope(rule, file));
220
+ const violations: Array<Violation> = [];
221
+ for (const component of stronglyConnected(nodes, graph)) {
222
+ const [first] = component;
223
+ if (first === undefined) continue;
224
+ const isCycle = component.length > 1 || (graph.edges.get(first) ?? []).includes(first);
225
+ if (!isCycle) continue;
226
+ // The subject is the component as a set, so the entry survives an edge
227
+ // added or removed elsewhere in the same cycle.
228
+ violations.push({
229
+ kind: "graph",
230
+ ruleName: rule.name,
231
+ message: rule.message,
232
+ file: first,
233
+ subject: component.join(" ↔ "),
234
+ });
235
+ }
236
+ return violations;
237
+ };
238
+
239
+ const evaluateOrphans = (rule: CompiledGraphOrphanRule, graph: Graph): ReadonlyArray<Violation> => {
240
+ const imported = new Set<string>();
241
+ for (const targets of graph.edges.values()) for (const target of targets) imported.add(target);
242
+
243
+ return graph.files
244
+ .filter((file) => inScope(rule, file) && !anyMatches(rule.entry, file) && !imported.has(file))
245
+ .map((file) => ({
246
+ kind: "graph",
247
+ ruleName: rule.name,
248
+ message: rule.message,
249
+ file,
250
+ subject: null,
251
+ }));
252
+ };
253
+
254
+ // Breadth-first from each `from` file, never stepping onto a `via` node, so a
255
+ // `to` reached is one reached without passing through the tier that was
256
+ // supposed to mediate. The route is put in the message, not the subject: the
257
+ // fingerprint is (from, to), which survives the route changing.
258
+ const evaluateReach = (rule: CompiledGraphReachRule, graph: Graph): ReadonlyArray<Violation> => {
259
+ const violations: Array<Violation> = [];
260
+ const isTarget = (file: string) => anyMatches(rule.to, file) && !anyMatches(rule.toNot, file);
261
+
262
+ for (const origin of graph.files) {
263
+ if (!anyMatches(rule.from, origin) || anyMatches(rule.fromNot, origin)) continue;
264
+
265
+ const parent = new Map<string, string>();
266
+ const queue: Array<string> = [origin];
267
+ const seen = new Set<string>([origin]);
268
+ const reported = new Set<string>();
269
+
270
+ while (queue.length > 0) {
271
+ const node = queue.shift();
272
+ if (node === undefined) break;
273
+ for (const target of neighboursOf(graph, node)) {
274
+ if (seen.has(target)) continue;
275
+ if (anyMatches(rule.via, target)) continue;
276
+ seen.add(target);
277
+ parent.set(target, node);
278
+ if (isTarget(target) && !reported.has(target)) {
279
+ reported.add(target);
280
+ const route: Array<string> = [target];
281
+ let step: string | undefined = target;
282
+ while (step !== undefined && step !== origin) {
283
+ step = parent.get(step);
284
+ if (step !== undefined) route.unshift(step);
285
+ }
286
+ violations.push({
287
+ kind: "graph",
288
+ ruleName: rule.name,
289
+ message: `${rule.message} (route: ${route.join(" → ")})`,
290
+ file: origin,
291
+ subject: target,
292
+ });
293
+ }
294
+ queue.push(target);
295
+ }
296
+ }
297
+ }
298
+ return violations;
299
+ };
300
+
301
+ export const evaluateGraph = (rules: CompiledGraph, graph: Graph): ReadonlyArray<Violation> => [
302
+ ...rules.cycles.flatMap((rule) => evaluateCycles(rule, graph)),
303
+ ...rules.orphans.flatMap((rule) => evaluateOrphans(rule, graph)),
304
+ ...rules.reach.flatMap((rule) => evaluateReach(rule, graph)),
305
+ ];
306
+
307
+ // A probe is a small synthetic graph the rule must report on.
308
+ export const graphOf = (probe: GraphProbe): Graph => {
309
+ const edges = new Map<string, Array<string>>();
310
+ const files = new Set<string>(probe.files ?? []);
311
+ for (const [from, to] of probe.edges) {
312
+ files.add(from);
313
+ files.add(to);
314
+ const targets = edges.get(from) ?? [];
315
+ targets.push(to);
316
+ edges.set(from, targets);
317
+ }
318
+ return { files: [...files].sort(), edges };
319
+ };
320
+
321
+ export const graphRulesFailingTheirProbe = (rules: CompiledGraph): ReadonlyArray<string> => [
322
+ ...rules.cycles
323
+ .filter((rule) => evaluateCycles(rule, graphOf(rule.probe)).length === 0)
324
+ .map((rule) => rule.name),
325
+ ...rules.orphans
326
+ .filter((rule) => evaluateOrphans(rule, graphOf(rule.probe)).length === 0)
327
+ .map((rule) => rule.name),
328
+ ...rules.reach
329
+ .filter((rule) => evaluateReach(rule, graphOf(rule.probe)).length === 0)
330
+ .map((rule) => rule.name),
331
+ ];
@@ -0,0 +1,160 @@
1
+ import * as Result from "effect/Result";
2
+
3
+ import type { ImportProbe, ImportProbeTarget, ImportRule } from "../domain/architecture-config.js";
4
+ import type { ImportUnresolved, PatternInvalid } from "../domain/architecture-error.js";
5
+ import type { Violation } from "../domain/violation.js";
6
+ import type { DependencyKind, ModuleResolver, ResolvedTarget } from "../ports/module-resolver.js";
7
+ import {
8
+ compilePatterns,
9
+ firstFromMatch,
10
+ sourcesOf,
11
+ targetAllowed,
12
+ validateTargetPatterns,
13
+ } from "./patterns.js";
14
+
15
+ export type CompiledImportRule = {
16
+ readonly name: string;
17
+ readonly message: string;
18
+ readonly from: ReadonlyArray<RegExp>;
19
+ readonly fromNot: ReadonlyArray<RegExp>;
20
+ // Kept as source strings: a `$1` in a target pattern is substituted from the
21
+ // `from` match before it is compiled, which is how a rule says "any module but
22
+ // my own" without naming every module.
23
+ readonly to: ReadonlyArray<string>;
24
+ readonly toNot: ReadonlyArray<string>;
25
+ // Third-party packages the rule permits, by name. Judged before the path
26
+ // patterns, so where a language keeps its packages never reaches a rule.
27
+ readonly externals: ReadonlySet<string>;
28
+ readonly dependencyKind: DependencyKind | null;
29
+ readonly probe: ImportProbe;
30
+ };
31
+
32
+ export const compileImportRule = (
33
+ rule: ImportRule,
34
+ ): Result.Result<CompiledImportRule, PatternInvalid> => {
35
+ const from = compilePatterns(rule.name, "from", rule.from);
36
+ if (Result.isFailure(from)) return Result.fail(from.failure);
37
+ const fromNot = compilePatterns(rule.name, "fromNot", rule.fromNot);
38
+ if (Result.isFailure(fromNot)) return Result.fail(fromNot.failure);
39
+
40
+ const targets = validateTargetPatterns(rule.name, [
41
+ ["to", rule.to],
42
+ ["toNot", rule.toNot],
43
+ ]);
44
+ if (Result.isFailure(targets)) return Result.fail(targets.failure);
45
+
46
+ return Result.succeed({
47
+ name: rule.name,
48
+ message: rule.message,
49
+ probe: rule.probe,
50
+ from: from.success,
51
+ fromNot: fromNot.success,
52
+ to: sourcesOf(rule.to),
53
+ toNot: sourcesOf(rule.toNot),
54
+ externals: new Set(rule.externals ?? []),
55
+ dependencyKind: rule.dependencyKind ?? null,
56
+ });
57
+ };
58
+
59
+ export const compileImportRules = (
60
+ rules: ReadonlyArray<ImportRule>,
61
+ ): Result.Result<ReadonlyArray<CompiledImportRule>, PatternInvalid> => {
62
+ const compiled: Array<CompiledImportRule> = [];
63
+ for (const rule of rules) {
64
+ const one = compileImportRule(rule);
65
+ if (Result.isFailure(one)) return Result.fail(one.failure);
66
+ compiled.push(one.success);
67
+ }
68
+ return Result.succeed(compiled);
69
+ };
70
+
71
+ export type ImportEdge = {
72
+ readonly importer: string;
73
+ readonly specifier: string;
74
+ };
75
+
76
+ export type SelectedRule = readonly [CompiledImportRule, RegExpExecArray];
77
+
78
+ // Returns the rules whose `from` side selects this file. A file no rule selects
79
+ // never needs its imports resolved, which is what keeps resolution off the hot
80
+ // path for the bulk of the repo.
81
+ export const rulesSelecting = (
82
+ rules: ReadonlyArray<CompiledImportRule>,
83
+ importer: string,
84
+ ): ReadonlyArray<SelectedRule> => {
85
+ const selected: Array<SelectedRule> = [];
86
+ for (const rule of rules) {
87
+ const captures = firstFromMatch(rule, importer);
88
+ if (captures !== null) selected.push([rule, captures]);
89
+ }
90
+ return selected;
91
+ };
92
+
93
+ // Whether the rule reports this target. The kind comes from the resolver, an
94
+ // external is judged by its package name, and only then do the path patterns
95
+ // speak — so a rule never has to know where a language keeps its packages.
96
+ const reports = (rule: CompiledImportRule, captures: RegExpExecArray, target: ResolvedTarget) => {
97
+ if (rule.dependencyKind !== null && rule.dependencyKind !== target.kind) return false;
98
+ if (
99
+ target.kind === "external" &&
100
+ target.package !== undefined &&
101
+ rule.externals.has(target.package)
102
+ ) {
103
+ return false;
104
+ }
105
+ return targetAllowed(rule, captures, target.path);
106
+ };
107
+
108
+ export const evaluateSelectedEdge = (
109
+ selected: ReadonlyArray<SelectedRule>,
110
+ resolver: ModuleResolver,
111
+ edge: ImportEdge,
112
+ ): Result.Result<ReadonlyArray<Violation>, ImportUnresolved> => {
113
+ if (selected.length === 0) return Result.succeed([]);
114
+
115
+ const resolved = resolver.resolve(edge.importer, edge.specifier);
116
+ if (Result.isFailure(resolved)) return Result.fail(resolved.failure);
117
+ const target = resolved.success;
118
+
119
+ const violations: Array<Violation> = [];
120
+ for (const [rule, captures] of selected) {
121
+ if (reports(rule, captures, target)) {
122
+ violations.push({
123
+ kind: "import",
124
+ ruleName: rule.name,
125
+ message: rule.message,
126
+ file: edge.importer,
127
+ subject: target.path,
128
+ });
129
+ }
130
+ }
131
+
132
+ return Result.succeed(violations);
133
+ };
134
+
135
+ export const evaluateImportEdge = (
136
+ rules: ReadonlyArray<CompiledImportRule>,
137
+ resolver: ModuleResolver,
138
+ edge: ImportEdge,
139
+ ): Result.Result<ReadonlyArray<Violation>, ImportUnresolved> =>
140
+ evaluateSelectedEdge(rulesSelecting(rules, edge.importer), resolver, edge);
141
+
142
+ // A probe states its target in one of three forms, and the form is the kind —
143
+ // so the probe never has to know how a language spells an external's path.
144
+ export const probeTargetOf = (to: ImportProbeTarget): ResolvedTarget => {
145
+ if (typeof to === "string") return { path: to, kind: "local" };
146
+ if ("external" in to) return { path: to.external, kind: "external", package: to.external };
147
+ return { path: to.builtin, kind: "builtin" };
148
+ };
149
+
150
+ // Every rule must report its own probe. A rule that does not is enforcing
151
+ // nothing while still looking configured and still passing a clean lint run —
152
+ // the one failure mode a linter cannot surface on its own.
153
+ export const rulesFailingTheirProbe = (
154
+ rules: ReadonlyArray<CompiledImportRule>,
155
+ ): ReadonlyArray<CompiledImportRule> =>
156
+ rules.filter((rule) => {
157
+ const captures = firstFromMatch(rule, rule.probe.from);
158
+ if (captures === null) return true;
159
+ return !reports(rule, captures, probeTargetOf(rule.probe.to));
160
+ });
@@ -0,0 +1,161 @@
1
+ import * as Result from "effect/Result";
2
+
3
+ import type {
4
+ DeclarationKind,
5
+ MemberProbe,
6
+ MemberRule,
7
+ MemberSubject,
8
+ } from "../domain/architecture-config.js";
9
+ import type { PatternInvalid } from "../domain/architecture-error.js";
10
+ import type { MemberSite } from "../domain/facts.js";
11
+ import type { Violation } from "../domain/violation.js";
12
+ import type { FactExtractor } from "../ports/fact-extractor.js";
13
+ import { compilePatterns, firstFromMatch } from "./patterns.js";
14
+
15
+ export type { MemberSite } from "../domain/facts.js";
16
+
17
+ export type CompiledMemberRule = {
18
+ readonly name: string;
19
+ readonly message: string;
20
+ readonly from: ReadonlyArray<RegExp>;
21
+ readonly fromNot: ReadonlyArray<RegExp>;
22
+ readonly subject: MemberSubject;
23
+ readonly in: ReadonlyArray<RegExp>;
24
+ // Which declarations a `members` rule speaks to; `null` for every kind.
25
+ readonly declares: ReadonlyArray<DeclarationKind> | null;
26
+ readonly match: ReadonlyArray<RegExp>;
27
+ readonly matchNot: ReadonlyArray<RegExp>;
28
+ readonly allow: ReadonlyArray<RegExp>;
29
+ readonly probe: MemberProbe;
30
+ };
31
+
32
+ export const compileMemberRule = (
33
+ rule: MemberRule,
34
+ ): Result.Result<CompiledMemberRule, PatternInvalid> => {
35
+ const fields = [
36
+ ["from", rule.from],
37
+ ["fromNot", rule.fromNot],
38
+ ["in", rule.in],
39
+ ["match", rule.match],
40
+ ["matchNot", rule.matchNot],
41
+ ["allow", rule.allow],
42
+ ] as const;
43
+
44
+ const compiled: Array<ReadonlyArray<RegExp>> = [];
45
+ for (const [field, patterns] of fields) {
46
+ const one = compilePatterns(rule.name, field, patterns);
47
+ if (Result.isFailure(one)) return Result.fail(one.failure);
48
+ compiled.push(one.success);
49
+ }
50
+ const [from, fromNot, inside, match, matchNot, allow] = compiled as [
51
+ ReadonlyArray<RegExp>,
52
+ ReadonlyArray<RegExp>,
53
+ ReadonlyArray<RegExp>,
54
+ ReadonlyArray<RegExp>,
55
+ ReadonlyArray<RegExp>,
56
+ ReadonlyArray<RegExp>,
57
+ ];
58
+
59
+ return Result.succeed({
60
+ name: rule.name,
61
+ message: rule.message,
62
+ probe: rule.probe,
63
+ subject: rule.subject,
64
+ from,
65
+ fromNot,
66
+ in: inside,
67
+ declares: rule.declares === undefined ? null : [...rule.declares],
68
+ match,
69
+ matchNot,
70
+ allow,
71
+ });
72
+ };
73
+
74
+ export const compileMemberRules = (
75
+ rules: ReadonlyArray<MemberRule>,
76
+ ): Result.Result<ReadonlyArray<CompiledMemberRule>, PatternInvalid> => {
77
+ const compiled: Array<CompiledMemberRule> = [];
78
+ for (const rule of rules) {
79
+ const one = compileMemberRule(rule);
80
+ if (Result.isFailure(one)) return Result.fail(one.failure);
81
+ compiled.push(one.success);
82
+ }
83
+ return Result.succeed(compiled);
84
+ };
85
+
86
+ const anyMatches = (patterns: ReadonlyArray<RegExp>, value: string): boolean =>
87
+ patterns.some((pattern) => pattern.test(value));
88
+
89
+ export const memberRulesSelecting = (
90
+ rules: ReadonlyArray<CompiledMemberRule>,
91
+ file: string,
92
+ ): ReadonlyArray<CompiledMemberRule> => rules.filter((rule) => firstFromMatch(rule, file) !== null);
93
+
94
+ const governs = (rule: CompiledMemberRule, site: MemberSite): boolean => {
95
+ if (rule.subject !== site.subject) return false;
96
+ if (rule.in.length > 0 && (site.in === undefined || !anyMatches(rule.in, site.in))) return false;
97
+ if (
98
+ rule.declares !== null &&
99
+ (site.declares === undefined || !rule.declares.includes(site.declares))
100
+ ) {
101
+ return false;
102
+ }
103
+ if (rule.match.length > 0 && !anyMatches(rule.match, site.name)) return false;
104
+ if (rule.matchNot.length > 0 && anyMatches(rule.matchNot, site.name)) return false;
105
+ return true;
106
+ };
107
+
108
+ export const evaluateMemberSite = (
109
+ selected: ReadonlyArray<CompiledMemberRule>,
110
+ site: MemberSite,
111
+ ): ReadonlyArray<Violation> => {
112
+ const violations: Array<Violation> = [];
113
+ for (const rule of selected) {
114
+ if (!governs(rule, site)) continue;
115
+ if (anyMatches(rule.allow, site.name)) continue;
116
+
117
+ violations.push({
118
+ kind: "member",
119
+ ruleName: rule.name,
120
+ message: rule.message,
121
+ file: site.file,
122
+ subject: site.name,
123
+ });
124
+ }
125
+ return violations;
126
+ };
127
+
128
+ // A probe with a `source` is checked through the parser: the snippet must
129
+ // yield a site named `probe.name` that the rule reports. That is the check a
130
+ // synthetic site cannot make — a rule about a declaration shape the extractor
131
+ // does not read passes its synthetic probe and reports nothing. A probe
132
+ // without a source is checked against the rule's patterns alone, as a site
133
+ // declared in the first kind the rule speaks to.
134
+ export const memberRulesFailingTheirProbe = (
135
+ rules: ReadonlyArray<CompiledMemberRule>,
136
+ extractor: FactExtractor,
137
+ ): ReadonlyArray<CompiledMemberRule> =>
138
+ rules.filter((rule) => {
139
+ if (firstFromMatch(rule, rule.probe.from) === null) return true;
140
+
141
+ const sites: ReadonlyArray<MemberSite> =
142
+ rule.probe.source === undefined
143
+ ? [
144
+ {
145
+ file: rule.probe.from,
146
+ subject: rule.subject,
147
+ name: rule.probe.name,
148
+ ...(rule.probe.in === undefined ? {} : { in: rule.probe.in }),
149
+ ...(rule.probe.declares === undefined
150
+ ? rule.declares === null
151
+ ? {}
152
+ : { declares: rule.declares[0] }
153
+ : { declares: rule.probe.declares }),
154
+ },
155
+ ]
156
+ : extractor
157
+ .factsOf(rule.probe.from, rule.probe.source)
158
+ .memberSites.filter((site) => site.name === rule.probe.name);
159
+
160
+ return !sites.some((site) => evaluateMemberSite([rule], site).length > 0);
161
+ });