@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.
- package/LICENSE +21 -0
- package/build/dts/core/baseline.d.ts +16 -0
- package/build/dts/core/baseline.d.ts.map +1 -0
- package/build/dts/core/coverage.d.ts +47 -0
- package/build/dts/core/coverage.d.ts.map +1 -0
- package/build/dts/core/exports.d.ts +38 -0
- package/build/dts/core/exports.d.ts.map +1 -0
- package/build/dts/core/graph.d.ts +40 -0
- package/build/dts/core/graph.d.ts.map +1 -0
- package/build/dts/core/imports.d.ts +29 -0
- package/build/dts/core/imports.d.ts.map +1 -0
- package/build/dts/core/members.d.ts +26 -0
- package/build/dts/core/members.d.ts.map +1 -0
- package/build/dts/core/patterns.d.ts +17 -0
- package/build/dts/core/patterns.d.ts.map +1 -0
- package/build/dts/core/structure.d.ts +48 -0
- package/build/dts/core/structure.d.ts.map +1 -0
- package/build/dts/core/surface.d.ts +39 -0
- package/build/dts/core/surface.d.ts.map +1 -0
- package/build/dts/domain/architecture-config.d.ts +322 -0
- package/build/dts/domain/architecture-config.d.ts.map +1 -0
- package/build/dts/domain/architecture-error.d.ts +35 -0
- package/build/dts/domain/architecture-error.d.ts.map +1 -0
- package/build/dts/domain/facts.d.ts +26 -0
- package/build/dts/domain/facts.d.ts.map +1 -0
- package/build/dts/domain/violation.d.ts +11 -0
- package/build/dts/domain/violation.d.ts.map +1 -0
- package/build/dts/index.d.ts +23 -0
- package/build/dts/index.d.ts.map +1 -0
- package/build/dts/infrastructure/fact-extractor-fake.d.ts +4 -0
- package/build/dts/infrastructure/fact-extractor-fake.d.ts.map +1 -0
- package/build/dts/infrastructure/file-system-fake.d.ts +3 -0
- package/build/dts/infrastructure/file-system-fake.d.ts.map +1 -0
- package/build/dts/infrastructure/file-system-live.d.ts +3 -0
- package/build/dts/infrastructure/file-system-live.d.ts.map +1 -0
- package/build/dts/infrastructure/manifest-file.d.ts +3 -0
- package/build/dts/infrastructure/manifest-file.d.ts.map +1 -0
- package/build/dts/infrastructure/module-resolver-fake.d.ts +3 -0
- package/build/dts/infrastructure/module-resolver-fake.d.ts.map +1 -0
- package/build/dts/infrastructure/walk.d.ts +4 -0
- package/build/dts/infrastructure/walk.d.ts.map +1 -0
- package/build/dts/load/policy.d.ts +42 -0
- package/build/dts/load/policy.d.ts.map +1 -0
- package/build/dts/manifest/compile.d.ts +26 -0
- package/build/dts/manifest/compile.d.ts.map +1 -0
- package/build/dts/manifest/glob.d.ts +14 -0
- package/build/dts/manifest/glob.d.ts.map +1 -0
- package/build/dts/manifest/manifest.d.ts +208 -0
- package/build/dts/manifest/manifest.d.ts.map +1 -0
- package/build/dts/ports/fact-extractor.d.ts +5 -0
- package/build/dts/ports/fact-extractor.d.ts.map +1 -0
- package/build/dts/ports/file-system.d.ts +5 -0
- package/build/dts/ports/file-system.d.ts.map +1 -0
- package/build/dts/ports/language.d.ts +14 -0
- package/build/dts/ports/language.d.ts.map +1 -0
- package/build/dts/ports/module-resolver.d.ts +12 -0
- package/build/dts/ports/module-resolver.d.ts.map +1 -0
- package/build/dts/testing.d.ts +4 -0
- package/build/dts/testing.d.ts.map +1 -0
- package/build/esm/core/baseline.js +30 -0
- package/build/esm/core/baseline.js.map +1 -0
- package/build/esm/core/coverage.js +75 -0
- package/build/esm/core/coverage.js.map +1 -0
- package/build/esm/core/exports.js +102 -0
- package/build/esm/core/exports.js.map +1 -0
- package/build/esm/core/graph.js +259 -0
- package/build/esm/core/graph.js.map +1 -0
- package/build/esm/core/imports.js +103 -0
- package/build/esm/core/imports.js.map +1 -0
- package/build/esm/core/members.js +106 -0
- package/build/esm/core/members.js.map +1 -0
- package/build/esm/core/patterns.js +75 -0
- package/build/esm/core/patterns.js.map +1 -0
- package/build/esm/core/structure.js +257 -0
- package/build/esm/core/structure.js.map +1 -0
- package/build/esm/core/surface.js +137 -0
- package/build/esm/core/surface.js.map +1 -0
- package/build/esm/domain/architecture-config.js +312 -0
- package/build/esm/domain/architecture-config.js.map +1 -0
- package/build/esm/domain/architecture-error.js +41 -0
- package/build/esm/domain/architecture-error.js.map +1 -0
- package/build/esm/domain/facts.js +2 -0
- package/build/esm/domain/facts.js.map +1 -0
- package/build/esm/domain/violation.js +8 -0
- package/build/esm/domain/violation.js.map +1 -0
- package/build/esm/index.js +22 -0
- package/build/esm/index.js.map +1 -0
- package/build/esm/infrastructure/fact-extractor-fake.js +24 -0
- package/build/esm/infrastructure/fact-extractor-fake.js.map +1 -0
- package/build/esm/infrastructure/file-system-fake.js +10 -0
- package/build/esm/infrastructure/file-system-fake.js.map +1 -0
- package/build/esm/infrastructure/file-system-live.js +27 -0
- package/build/esm/infrastructure/file-system-live.js.map +1 -0
- package/build/esm/infrastructure/manifest-file.js +15 -0
- package/build/esm/infrastructure/manifest-file.js.map +1 -0
- package/build/esm/infrastructure/module-resolver-fake.js +33 -0
- package/build/esm/infrastructure/module-resolver-fake.js.map +1 -0
- package/build/esm/infrastructure/walk.js +45 -0
- package/build/esm/infrastructure/walk.js.map +1 -0
- package/build/esm/load/policy.js +207 -0
- package/build/esm/load/policy.js.map +1 -0
- package/build/esm/manifest/compile.js +706 -0
- package/build/esm/manifest/compile.js.map +1 -0
- package/build/esm/manifest/glob.js +77 -0
- package/build/esm/manifest/glob.js.map +1 -0
- package/build/esm/manifest/manifest.js +303 -0
- package/build/esm/manifest/manifest.js.map +1 -0
- package/build/esm/ports/fact-extractor.js +2 -0
- package/build/esm/ports/fact-extractor.js.map +1 -0
- package/build/esm/ports/file-system.js +2 -0
- package/build/esm/ports/file-system.js.map +1 -0
- package/build/esm/ports/language.js +2 -0
- package/build/esm/ports/language.js.map +1 -0
- package/build/esm/ports/module-resolver.js +2 -0
- package/build/esm/ports/module-resolver.js.map +1 -0
- package/build/esm/testing.js +7 -0
- package/build/esm/testing.js.map +1 -0
- package/package.json +61 -0
- package/src/core/baseline.ts +62 -0
- package/src/core/coverage.ts +154 -0
- package/src/core/exports.ts +171 -0
- package/src/core/graph.ts +331 -0
- package/src/core/imports.ts +160 -0
- package/src/core/members.ts +161 -0
- package/src/core/patterns.ts +116 -0
- package/src/core/structure.ts +349 -0
- package/src/core/surface.ts +181 -0
- package/src/domain/architecture-config.ts +370 -0
- package/src/domain/architecture-error.ts +59 -0
- package/src/domain/facts.ts +47 -0
- package/src/domain/violation.ts +24 -0
- package/src/index.ts +136 -0
- package/src/infrastructure/fact-extractor-fake.ts +28 -0
- package/src/infrastructure/file-system-fake.ts +14 -0
- package/src/infrastructure/file-system-live.ts +27 -0
- package/src/infrastructure/manifest-file.ts +17 -0
- package/src/infrastructure/module-resolver-fake.ts +39 -0
- package/src/infrastructure/walk.ts +54 -0
- package/src/load/policy.ts +344 -0
- package/src/manifest/compile.ts +1011 -0
- package/src/manifest/glob.ts +108 -0
- package/src/manifest/manifest.ts +408 -0
- package/src/ports/fact-extractor.ts +12 -0
- package/src/ports/file-system.ts +8 -0
- package/src/ports/language.ts +39 -0
- package/src/ports/module-resolver.ts +28 -0
- 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
|
+
});
|