oxlint-architecture-rules 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/build/dts/adapters/cli/main.d.ts +3 -0
- package/build/dts/adapters/cli/main.d.ts.map +1 -0
- package/build/dts/adapters/cli/run.d.ts +19 -0
- package/build/dts/adapters/cli/run.d.ts.map +1 -0
- package/build/dts/adapters/cli/source-facts.d.ts +9 -0
- package/build/dts/adapters/cli/source-facts.d.ts.map +1 -0
- package/build/dts/adapters/cli/source-files.d.ts +2 -0
- package/build/dts/adapters/cli/source-files.d.ts.map +1 -0
- package/build/dts/adapters/oxlint/config-loader.d.ts +23 -0
- package/build/dts/adapters/oxlint/config-loader.d.ts.map +1 -0
- package/build/dts/adapters/oxlint/exports-rule.d.ts +4 -0
- package/build/dts/adapters/oxlint/exports-rule.d.ts.map +1 -0
- package/build/dts/adapters/oxlint/imports-rule.d.ts +4 -0
- package/build/dts/adapters/oxlint/imports-rule.d.ts.map +1 -0
- package/build/dts/adapters/oxlint/members-rule.d.ts +4 -0
- package/build/dts/adapters/oxlint/members-rule.d.ts.map +1 -0
- package/build/dts/adapters/oxlint/oxlint-api.d.ts +19 -0
- package/build/dts/adapters/oxlint/oxlint-api.d.ts.map +1 -0
- package/build/dts/adapters/oxlint/plugin.d.ts +15 -0
- package/build/dts/adapters/oxlint/plugin.d.ts.map +1 -0
- package/build/dts/adapters/oxlint/structure-rule.d.ts +4 -0
- package/build/dts/adapters/oxlint/structure-rule.d.ts.map +1 -0
- package/build/dts/core/baseline.d.ts +16 -0
- package/build/dts/core/baseline.d.ts.map +1 -0
- package/build/dts/core/exports.d.ts +39 -0
- package/build/dts/core/exports.d.ts.map +1 -0
- package/build/dts/core/imports.d.ts +27 -0
- package/build/dts/core/imports.d.ts.map +1 -0
- package/build/dts/core/members.d.ts +28 -0
- package/build/dts/core/members.d.ts.map +1 -0
- package/build/dts/core/patterns.d.ts +19 -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/domain/architecture-config.d.ts +177 -0
- package/build/dts/domain/architecture-config.d.ts.map +1 -0
- package/build/dts/domain/architecture-error.d.ts +27 -0
- package/build/dts/domain/architecture-error.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 +10 -0
- package/build/dts/index.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/module-resolver-fake.d.ts +3 -0
- package/build/dts/infrastructure/module-resolver-fake.d.ts.map +1 -0
- package/build/dts/infrastructure/module-resolver-live.d.ts +4 -0
- package/build/dts/infrastructure/module-resolver-live.d.ts.map +1 -0
- package/build/dts/manifest/compile.d.ts +16 -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 +96 -0
- package/build/dts/manifest/manifest.d.ts.map +1 -0
- package/build/dts/ports/file-system.d.ts +4 -0
- package/build/dts/ports/file-system.d.ts.map +1 -0
- package/build/dts/ports/module-resolver.d.ts +11 -0
- package/build/dts/ports/module-resolver.d.ts.map +1 -0
- package/build/esm/adapters/cli/main.js +17 -0
- package/build/esm/adapters/cli/main.js.map +1 -0
- package/build/esm/adapters/cli/run.js +188 -0
- package/build/esm/adapters/cli/run.js.map +1 -0
- package/build/esm/adapters/cli/source-facts.js +117 -0
- package/build/esm/adapters/cli/source-facts.js.map +1 -0
- package/build/esm/adapters/cli/source-files.js +40 -0
- package/build/esm/adapters/cli/source-files.js.map +1 -0
- package/build/esm/adapters/oxlint/config-loader.js +81 -0
- package/build/esm/adapters/oxlint/config-loader.js.map +1 -0
- package/build/esm/adapters/oxlint/exports-rule.js +104 -0
- package/build/esm/adapters/oxlint/exports-rule.js.map +1 -0
- package/build/esm/adapters/oxlint/imports-rule.js +55 -0
- package/build/esm/adapters/oxlint/imports-rule.js.map +1 -0
- package/build/esm/adapters/oxlint/members-rule.js +75 -0
- package/build/esm/adapters/oxlint/members-rule.js.map +1 -0
- package/build/esm/adapters/oxlint/oxlint-api.js +7 -0
- package/build/esm/adapters/oxlint/oxlint-api.js.map +1 -0
- package/build/esm/adapters/oxlint/plugin.js +23 -0
- package/build/esm/adapters/oxlint/plugin.js.map +1 -0
- package/build/esm/adapters/oxlint/structure-rule.js +37 -0
- package/build/esm/adapters/oxlint/structure-rule.js.map +1 -0
- package/build/esm/core/baseline.js +30 -0
- package/build/esm/core/baseline.js.map +1 -0
- package/build/esm/core/exports.js +96 -0
- package/build/esm/core/exports.js.map +1 -0
- package/build/esm/core/imports.js +85 -0
- package/build/esm/core/imports.js.map +1 -0
- package/build/esm/core/members.js +84 -0
- package/build/esm/core/members.js.map +1 -0
- package/build/esm/core/patterns.js +78 -0
- package/build/esm/core/patterns.js.map +1 -0
- package/build/esm/core/structure.js +256 -0
- package/build/esm/core/structure.js.map +1 -0
- package/build/esm/domain/architecture-config.js +172 -0
- package/build/esm/domain/architecture-config.js.map +1 -0
- package/build/esm/domain/architecture-error.js +29 -0
- package/build/esm/domain/architecture-error.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 +8 -0
- package/build/esm/index.js.map +1 -0
- package/build/esm/infrastructure/file-system-fake.js +5 -0
- package/build/esm/infrastructure/file-system-fake.js.map +1 -0
- package/build/esm/infrastructure/file-system-live.js +19 -0
- package/build/esm/infrastructure/file-system-live.js.map +1 -0
- package/build/esm/infrastructure/module-resolver-fake.js +23 -0
- package/build/esm/infrastructure/module-resolver-fake.js.map +1 -0
- package/build/esm/infrastructure/module-resolver-live.js +71 -0
- package/build/esm/infrastructure/module-resolver-live.js.map +1 -0
- package/build/esm/manifest/compile.js +503 -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 +120 -0
- package/build/esm/manifest/manifest.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/module-resolver.js +2 -0
- package/build/esm/ports/module-resolver.js.map +1 -0
- package/package.json +73 -0
- package/src/adapters/cli/main.ts +20 -0
- package/src/adapters/cli/run.ts +270 -0
- package/src/adapters/cli/source-facts.ts +137 -0
- package/src/adapters/cli/source-files.ts +42 -0
- package/src/adapters/oxlint/config-loader.ts +136 -0
- package/src/adapters/oxlint/exports-rule.ts +144 -0
- package/src/adapters/oxlint/imports-rule.ts +67 -0
- package/src/adapters/oxlint/members-rule.ts +114 -0
- package/src/adapters/oxlint/oxlint-api.ts +25 -0
- package/src/adapters/oxlint/plugin.ts +35 -0
- package/src/adapters/oxlint/structure-rule.ts +39 -0
- package/src/core/baseline.ts +62 -0
- package/src/core/exports.ts +164 -0
- package/src/core/imports.ts +138 -0
- package/src/core/members.ts +131 -0
- package/src/core/patterns.ts +122 -0
- package/src/core/structure.ts +348 -0
- package/src/domain/architecture-config.ts +206 -0
- package/src/domain/architecture-error.ts +43 -0
- package/src/domain/violation.ts +24 -0
- package/src/index.ts +28 -0
- package/src/infrastructure/file-system-fake.ts +6 -0
- package/src/infrastructure/file-system-live.ts +20 -0
- package/src/infrastructure/module-resolver-fake.ts +29 -0
- package/src/infrastructure/module-resolver-live.ts +100 -0
- package/src/manifest/compile.ts +736 -0
- package/src/manifest/glob.ts +108 -0
- package/src/manifest/manifest.ts +178 -0
- package/src/ports/file-system.ts +5 -0
- package/src/ports/module-resolver.ts +23 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import * as Result from "effect/Result";
|
|
2
|
+
|
|
3
|
+
import type { BindingKind, ExportProbe, ExportRule } 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 { ModuleResolver } from "../ports/module-resolver.js";
|
|
7
|
+
import {
|
|
8
|
+
compilePatterns,
|
|
9
|
+
firstFromMatch,
|
|
10
|
+
sourcesOf,
|
|
11
|
+
targetAllowed,
|
|
12
|
+
validateTargetPatterns,
|
|
13
|
+
} from "./patterns.js";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_KINDS: ReadonlyArray<BindingKind> = ["named"];
|
|
16
|
+
|
|
17
|
+
export type CompiledExportRule = {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly message: string;
|
|
20
|
+
readonly from: ReadonlyArray<RegExp>;
|
|
21
|
+
readonly fromNot: ReadonlyArray<RegExp>;
|
|
22
|
+
readonly to: ReadonlyArray<string>;
|
|
23
|
+
readonly toNot: ReadonlyArray<string>;
|
|
24
|
+
readonly symbols: ReadonlySet<string> | null;
|
|
25
|
+
readonly kinds: ReadonlyArray<BindingKind>;
|
|
26
|
+
readonly fix: "subpath-namespace-import" | null;
|
|
27
|
+
readonly probe: ExportProbe;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const compileExportRule = (
|
|
31
|
+
rule: ExportRule,
|
|
32
|
+
): Result.Result<CompiledExportRule, PatternInvalid> => {
|
|
33
|
+
const from = compilePatterns(rule.name, "from", rule.from);
|
|
34
|
+
if (Result.isFailure(from)) return Result.fail(from.failure);
|
|
35
|
+
const fromNot = compilePatterns(rule.name, "fromNot", rule.fromNot);
|
|
36
|
+
if (Result.isFailure(fromNot)) return Result.fail(fromNot.failure);
|
|
37
|
+
|
|
38
|
+
const targets = validateTargetPatterns(rule.name, [
|
|
39
|
+
["to", rule.to],
|
|
40
|
+
["toNot", rule.toNot],
|
|
41
|
+
]);
|
|
42
|
+
if (Result.isFailure(targets)) return Result.fail(targets.failure);
|
|
43
|
+
|
|
44
|
+
return Result.succeed({
|
|
45
|
+
name: rule.name,
|
|
46
|
+
message: rule.message,
|
|
47
|
+
probe: rule.probe,
|
|
48
|
+
from: from.success,
|
|
49
|
+
fromNot: fromNot.success,
|
|
50
|
+
to: sourcesOf(rule.to),
|
|
51
|
+
toNot: sourcesOf(rule.toNot),
|
|
52
|
+
symbols: rule.symbols === undefined ? null : new Set(rule.symbols),
|
|
53
|
+
kinds: rule.kinds ?? DEFAULT_KINDS,
|
|
54
|
+
fix: rule.fix ?? null,
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const compileExportRules = (
|
|
59
|
+
rules: ReadonlyArray<ExportRule>,
|
|
60
|
+
): Result.Result<ReadonlyArray<CompiledExportRule>, PatternInvalid> => {
|
|
61
|
+
const compiled: Array<CompiledExportRule> = [];
|
|
62
|
+
for (const rule of rules) {
|
|
63
|
+
const one = compileExportRule(rule);
|
|
64
|
+
if (Result.isFailure(one)) return Result.fail(one.failure);
|
|
65
|
+
compiled.push(one.success);
|
|
66
|
+
}
|
|
67
|
+
return Result.succeed(compiled);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// One name pulled across one import edge: `import { makeCommandBus } from "…"`
|
|
71
|
+
// is a single binding, and so is the `Effect` in `import { Effect } from "effect"`.
|
|
72
|
+
export type Binding = {
|
|
73
|
+
readonly symbol: string;
|
|
74
|
+
readonly kind: BindingKind;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type BindingEdge = {
|
|
78
|
+
readonly importer: string;
|
|
79
|
+
readonly specifier: string;
|
|
80
|
+
readonly bindings: ReadonlyArray<Binding>;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export type SelectedExportRule = readonly [CompiledExportRule, RegExpExecArray];
|
|
84
|
+
|
|
85
|
+
export const exportRulesSelecting = (
|
|
86
|
+
rules: ReadonlyArray<CompiledExportRule>,
|
|
87
|
+
importer: string,
|
|
88
|
+
): ReadonlyArray<SelectedExportRule> => {
|
|
89
|
+
const selected: Array<SelectedExportRule> = [];
|
|
90
|
+
for (const rule of rules) {
|
|
91
|
+
const captures = firstFromMatch(rule, importer);
|
|
92
|
+
if (captures !== null) selected.push([rule, captures]);
|
|
93
|
+
}
|
|
94
|
+
return selected;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const covers = (rule: CompiledExportRule, binding: Binding): boolean =>
|
|
98
|
+
rule.kinds.includes(binding.kind) && (rule.symbols === null || rule.symbols.has(binding.symbol));
|
|
99
|
+
|
|
100
|
+
export type ExportViolation = {
|
|
101
|
+
readonly violation: Violation;
|
|
102
|
+
readonly rule: CompiledExportRule;
|
|
103
|
+
readonly bindings: ReadonlyArray<Binding>;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const evaluateSelectedBindings = (
|
|
107
|
+
selected: ReadonlyArray<SelectedExportRule>,
|
|
108
|
+
resolver: ModuleResolver,
|
|
109
|
+
edge: BindingEdge,
|
|
110
|
+
): Result.Result<ReadonlyArray<ExportViolation>, ImportUnresolved> => {
|
|
111
|
+
if (selected.length === 0 || edge.bindings.length === 0) return Result.succeed([]);
|
|
112
|
+
|
|
113
|
+
const resolved = resolver.resolve(edge.importer, edge.specifier);
|
|
114
|
+
if (Result.isFailure(resolved)) return Result.fail(resolved.failure);
|
|
115
|
+
const target = resolved.success;
|
|
116
|
+
|
|
117
|
+
const violations: Array<ExportViolation> = [];
|
|
118
|
+
for (const [rule, captures] of selected) {
|
|
119
|
+
if (!targetAllowed(rule, captures, target.path)) continue;
|
|
120
|
+
|
|
121
|
+
const offending = edge.bindings.filter((binding) => covers(rule, binding));
|
|
122
|
+
if (offending.length === 0) continue;
|
|
123
|
+
|
|
124
|
+
// A rule carrying a fix reports once for the whole declaration, because the
|
|
125
|
+
// fix rewrites the declaration as a unit; one without reports per symbol, so
|
|
126
|
+
// the diagnostic sits on the name that is actually restricted.
|
|
127
|
+
const groups = rule.fix === null ? offending.map((binding) => [binding]) : [offending];
|
|
128
|
+
for (const bindings of groups) {
|
|
129
|
+
violations.push({
|
|
130
|
+
rule,
|
|
131
|
+
bindings,
|
|
132
|
+
violation: {
|
|
133
|
+
kind: "export",
|
|
134
|
+
ruleName: rule.name,
|
|
135
|
+
message: rule.message,
|
|
136
|
+
file: edge.importer,
|
|
137
|
+
subject: `${target.path}#${bindings.map((binding) => binding.symbol).join(",")}`,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return Result.succeed(violations);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export const evaluateBindingEdge = (
|
|
147
|
+
rules: ReadonlyArray<CompiledExportRule>,
|
|
148
|
+
resolver: ModuleResolver,
|
|
149
|
+
edge: BindingEdge,
|
|
150
|
+
): Result.Result<ReadonlyArray<ExportViolation>, ImportUnresolved> =>
|
|
151
|
+
evaluateSelectedBindings(exportRulesSelecting(rules, edge.importer), resolver, edge);
|
|
152
|
+
|
|
153
|
+
export const exportRulesFailingTheirProbe = (
|
|
154
|
+
rules: ReadonlyArray<CompiledExportRule>,
|
|
155
|
+
): ReadonlyArray<CompiledExportRule> =>
|
|
156
|
+
rules.filter((rule) => {
|
|
157
|
+
const captures = firstFromMatch(rule, rule.probe.from);
|
|
158
|
+
if (captures === null) return true;
|
|
159
|
+
if (!targetAllowed(rule, captures, rule.probe.to)) return true;
|
|
160
|
+
return !covers(rule, {
|
|
161
|
+
symbol: rule.probe.symbol,
|
|
162
|
+
kind: rule.probe.kind ?? "named",
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import * as Result from "effect/Result";
|
|
2
|
+
|
|
3
|
+
import type { ImportProbe, 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 } from "../ports/module-resolver.js";
|
|
7
|
+
import {
|
|
8
|
+
compilePatterns,
|
|
9
|
+
firstFromMatch,
|
|
10
|
+
kindOfPath,
|
|
11
|
+
sourcesOf,
|
|
12
|
+
targetAllowed,
|
|
13
|
+
validateTargetPatterns,
|
|
14
|
+
} from "./patterns.js";
|
|
15
|
+
|
|
16
|
+
export type CompiledImportRule = {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly message: string;
|
|
19
|
+
readonly from: ReadonlyArray<RegExp>;
|
|
20
|
+
readonly fromNot: ReadonlyArray<RegExp>;
|
|
21
|
+
// Kept as source strings: a `$1` in a target pattern is substituted from the
|
|
22
|
+
// `from` match before it is compiled, which is how a rule says "any module but
|
|
23
|
+
// my own" without naming every module.
|
|
24
|
+
readonly to: ReadonlyArray<string>;
|
|
25
|
+
readonly toNot: ReadonlyArray<string>;
|
|
26
|
+
readonly dependencyKind: DependencyKind | null;
|
|
27
|
+
readonly probe: ImportProbe;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const compileImportRule = (
|
|
31
|
+
rule: ImportRule,
|
|
32
|
+
): Result.Result<CompiledImportRule, PatternInvalid> => {
|
|
33
|
+
const from = compilePatterns(rule.name, "from", rule.from);
|
|
34
|
+
if (Result.isFailure(from)) return Result.fail(from.failure);
|
|
35
|
+
const fromNot = compilePatterns(rule.name, "fromNot", rule.fromNot);
|
|
36
|
+
if (Result.isFailure(fromNot)) return Result.fail(fromNot.failure);
|
|
37
|
+
|
|
38
|
+
const targets = validateTargetPatterns(rule.name, [
|
|
39
|
+
["to", rule.to],
|
|
40
|
+
["toNot", rule.toNot],
|
|
41
|
+
]);
|
|
42
|
+
if (Result.isFailure(targets)) return Result.fail(targets.failure);
|
|
43
|
+
|
|
44
|
+
return Result.succeed({
|
|
45
|
+
name: rule.name,
|
|
46
|
+
message: rule.message,
|
|
47
|
+
probe: rule.probe,
|
|
48
|
+
from: from.success,
|
|
49
|
+
fromNot: fromNot.success,
|
|
50
|
+
to: sourcesOf(rule.to),
|
|
51
|
+
toNot: sourcesOf(rule.toNot),
|
|
52
|
+
dependencyKind: rule.dependencyKind ?? null,
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const compileImportRules = (
|
|
57
|
+
rules: ReadonlyArray<ImportRule>,
|
|
58
|
+
): Result.Result<ReadonlyArray<CompiledImportRule>, PatternInvalid> => {
|
|
59
|
+
const compiled: Array<CompiledImportRule> = [];
|
|
60
|
+
for (const rule of rules) {
|
|
61
|
+
const one = compileImportRule(rule);
|
|
62
|
+
if (Result.isFailure(one)) return Result.fail(one.failure);
|
|
63
|
+
compiled.push(one.success);
|
|
64
|
+
}
|
|
65
|
+
return Result.succeed(compiled);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type ImportEdge = {
|
|
69
|
+
readonly importer: string;
|
|
70
|
+
readonly specifier: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type SelectedRule = readonly [CompiledImportRule, RegExpExecArray];
|
|
74
|
+
|
|
75
|
+
// Returns the rules whose `from` side selects this file. A file no rule selects
|
|
76
|
+
// never needs its imports resolved, which is what keeps resolution off the hot
|
|
77
|
+
// path for the bulk of the repo.
|
|
78
|
+
export const rulesSelecting = (
|
|
79
|
+
rules: ReadonlyArray<CompiledImportRule>,
|
|
80
|
+
importer: string,
|
|
81
|
+
): ReadonlyArray<SelectedRule> => {
|
|
82
|
+
const selected: Array<SelectedRule> = [];
|
|
83
|
+
for (const rule of rules) {
|
|
84
|
+
const captures = firstFromMatch(rule, importer);
|
|
85
|
+
if (captures !== null) selected.push([rule, captures]);
|
|
86
|
+
}
|
|
87
|
+
return selected;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const evaluateSelectedEdge = (
|
|
91
|
+
selected: ReadonlyArray<SelectedRule>,
|
|
92
|
+
resolver: ModuleResolver,
|
|
93
|
+
edge: ImportEdge,
|
|
94
|
+
): Result.Result<ReadonlyArray<Violation>, ImportUnresolved> => {
|
|
95
|
+
if (selected.length === 0) return Result.succeed([]);
|
|
96
|
+
|
|
97
|
+
const resolved = resolver.resolve(edge.importer, edge.specifier);
|
|
98
|
+
if (Result.isFailure(resolved)) return Result.fail(resolved.failure);
|
|
99
|
+
const target = resolved.success;
|
|
100
|
+
|
|
101
|
+
const violations: Array<Violation> = [];
|
|
102
|
+
for (const [rule, captures] of selected) {
|
|
103
|
+
if (rule.dependencyKind !== null && rule.dependencyKind !== target.kind) continue;
|
|
104
|
+
if (targetAllowed(rule, captures, target.path)) {
|
|
105
|
+
violations.push({
|
|
106
|
+
kind: "import",
|
|
107
|
+
ruleName: rule.name,
|
|
108
|
+
message: rule.message,
|
|
109
|
+
file: edge.importer,
|
|
110
|
+
subject: target.path,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return Result.succeed(violations);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const evaluateImportEdge = (
|
|
119
|
+
rules: ReadonlyArray<CompiledImportRule>,
|
|
120
|
+
resolver: ModuleResolver,
|
|
121
|
+
edge: ImportEdge,
|
|
122
|
+
): Result.Result<ReadonlyArray<Violation>, ImportUnresolved> =>
|
|
123
|
+
evaluateSelectedEdge(rulesSelecting(rules, edge.importer), resolver, edge);
|
|
124
|
+
|
|
125
|
+
// Every rule must report its own probe. A rule that does not is enforcing
|
|
126
|
+
// nothing while still looking configured and still passing a clean lint run —
|
|
127
|
+
// the one failure mode a linter cannot surface on its own.
|
|
128
|
+
export const rulesFailingTheirProbe = (
|
|
129
|
+
rules: ReadonlyArray<CompiledImportRule>,
|
|
130
|
+
): ReadonlyArray<CompiledImportRule> =>
|
|
131
|
+
rules.filter((rule) => {
|
|
132
|
+
const captures = firstFromMatch(rule, rule.probe.from);
|
|
133
|
+
if (captures === null) return true;
|
|
134
|
+
if (rule.dependencyKind !== null && rule.dependencyKind !== kindOfPath(rule.probe.to)) {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
return !targetAllowed(rule, captures, rule.probe.to);
|
|
138
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as Result from "effect/Result";
|
|
2
|
+
|
|
3
|
+
import type { MemberProbe, MemberRule, MemberSubject } from "../domain/architecture-config.js";
|
|
4
|
+
import type { PatternInvalid } from "../domain/architecture-error.js";
|
|
5
|
+
import type { Violation } from "../domain/violation.js";
|
|
6
|
+
import { compilePatterns, firstFromMatch } from "./patterns.js";
|
|
7
|
+
|
|
8
|
+
export type CompiledMemberRule = {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly message: string;
|
|
11
|
+
readonly from: ReadonlyArray<RegExp>;
|
|
12
|
+
readonly fromNot: ReadonlyArray<RegExp>;
|
|
13
|
+
readonly subject: MemberSubject;
|
|
14
|
+
readonly in: ReadonlyArray<RegExp>;
|
|
15
|
+
readonly match: ReadonlyArray<RegExp>;
|
|
16
|
+
readonly matchNot: ReadonlyArray<RegExp>;
|
|
17
|
+
readonly allow: ReadonlyArray<RegExp>;
|
|
18
|
+
readonly probe: MemberProbe;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const compileMemberRule = (
|
|
22
|
+
rule: MemberRule,
|
|
23
|
+
): Result.Result<CompiledMemberRule, PatternInvalid> => {
|
|
24
|
+
const fields = [
|
|
25
|
+
["from", rule.from],
|
|
26
|
+
["fromNot", rule.fromNot],
|
|
27
|
+
["in", rule.in],
|
|
28
|
+
["match", rule.match],
|
|
29
|
+
["matchNot", rule.matchNot],
|
|
30
|
+
["allow", rule.allow],
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
const compiled: Array<ReadonlyArray<RegExp>> = [];
|
|
34
|
+
for (const [field, patterns] of fields) {
|
|
35
|
+
const one = compilePatterns(rule.name, field, patterns);
|
|
36
|
+
if (Result.isFailure(one)) return Result.fail(one.failure);
|
|
37
|
+
compiled.push(one.success);
|
|
38
|
+
}
|
|
39
|
+
const [from, fromNot, inside, match, matchNot, allow] = compiled as [
|
|
40
|
+
ReadonlyArray<RegExp>,
|
|
41
|
+
ReadonlyArray<RegExp>,
|
|
42
|
+
ReadonlyArray<RegExp>,
|
|
43
|
+
ReadonlyArray<RegExp>,
|
|
44
|
+
ReadonlyArray<RegExp>,
|
|
45
|
+
ReadonlyArray<RegExp>,
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
return Result.succeed({
|
|
49
|
+
name: rule.name,
|
|
50
|
+
message: rule.message,
|
|
51
|
+
probe: rule.probe,
|
|
52
|
+
subject: rule.subject,
|
|
53
|
+
from,
|
|
54
|
+
fromNot,
|
|
55
|
+
in: inside,
|
|
56
|
+
match,
|
|
57
|
+
matchNot,
|
|
58
|
+
allow,
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const compileMemberRules = (
|
|
63
|
+
rules: ReadonlyArray<MemberRule>,
|
|
64
|
+
): Result.Result<ReadonlyArray<CompiledMemberRule>, PatternInvalid> => {
|
|
65
|
+
const compiled: Array<CompiledMemberRule> = [];
|
|
66
|
+
for (const rule of rules) {
|
|
67
|
+
const one = compileMemberRule(rule);
|
|
68
|
+
if (Result.isFailure(one)) return Result.fail(one.failure);
|
|
69
|
+
compiled.push(one.success);
|
|
70
|
+
}
|
|
71
|
+
return Result.succeed(compiled);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// One declared or called name, and the declaration it sits in (a type alias's
|
|
75
|
+
// name for `type-members`, absent for `calls`).
|
|
76
|
+
export type MemberSite = {
|
|
77
|
+
readonly file: string;
|
|
78
|
+
readonly subject: MemberSubject;
|
|
79
|
+
readonly name: string;
|
|
80
|
+
readonly in?: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const anyMatches = (patterns: ReadonlyArray<RegExp>, value: string): boolean =>
|
|
84
|
+
patterns.some((pattern) => pattern.test(value));
|
|
85
|
+
|
|
86
|
+
export const memberRulesSelecting = (
|
|
87
|
+
rules: ReadonlyArray<CompiledMemberRule>,
|
|
88
|
+
file: string,
|
|
89
|
+
): ReadonlyArray<CompiledMemberRule> => rules.filter((rule) => firstFromMatch(rule, file) !== null);
|
|
90
|
+
|
|
91
|
+
const governs = (rule: CompiledMemberRule, site: MemberSite): boolean => {
|
|
92
|
+
if (rule.subject !== site.subject) return false;
|
|
93
|
+
if (rule.in.length > 0 && (site.in === undefined || !anyMatches(rule.in, site.in))) return false;
|
|
94
|
+
if (rule.match.length > 0 && !anyMatches(rule.match, site.name)) return false;
|
|
95
|
+
if (rule.matchNot.length > 0 && anyMatches(rule.matchNot, site.name)) return false;
|
|
96
|
+
return true;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const evaluateMemberSite = (
|
|
100
|
+
selected: ReadonlyArray<CompiledMemberRule>,
|
|
101
|
+
site: MemberSite,
|
|
102
|
+
): ReadonlyArray<Violation> => {
|
|
103
|
+
const violations: Array<Violation> = [];
|
|
104
|
+
for (const rule of selected) {
|
|
105
|
+
if (!governs(rule, site)) continue;
|
|
106
|
+
if (anyMatches(rule.allow, site.name)) continue;
|
|
107
|
+
|
|
108
|
+
violations.push({
|
|
109
|
+
kind: "member",
|
|
110
|
+
ruleName: rule.name,
|
|
111
|
+
message: rule.message,
|
|
112
|
+
file: site.file,
|
|
113
|
+
subject: site.name,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return violations;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const memberRulesFailingTheirProbe = (
|
|
120
|
+
rules: ReadonlyArray<CompiledMemberRule>,
|
|
121
|
+
): ReadonlyArray<CompiledMemberRule> =>
|
|
122
|
+
rules.filter((rule) => {
|
|
123
|
+
const site: MemberSite = {
|
|
124
|
+
file: rule.probe.from,
|
|
125
|
+
subject: rule.subject,
|
|
126
|
+
name: rule.probe.name,
|
|
127
|
+
...(rule.probe.in === undefined ? {} : { in: rule.probe.in }),
|
|
128
|
+
};
|
|
129
|
+
if (firstFromMatch(rule, site.file) === null) return true;
|
|
130
|
+
return evaluateMemberSite([rule], site).length === 0;
|
|
131
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import * as Result from "effect/Result";
|
|
2
|
+
|
|
3
|
+
import { patternsOf } from "../domain/architecture-config.js";
|
|
4
|
+
import { PatternInvalid } from "../domain/architecture-error.js";
|
|
5
|
+
import type { DependencyKind } from "../ports/module-resolver.js";
|
|
6
|
+
|
|
7
|
+
const BACKREFERENCE = /\$([1-9])/g;
|
|
8
|
+
|
|
9
|
+
// Validation-only stand-in so `new RegExp` sees a syntactically complete pattern
|
|
10
|
+
// where a backreference will later be spliced.
|
|
11
|
+
const withPlaceholderCaptures = (pattern: string): string => pattern.replaceAll(BACKREFERENCE, "x");
|
|
12
|
+
|
|
13
|
+
const compilePattern = (
|
|
14
|
+
ruleName: string,
|
|
15
|
+
field: string,
|
|
16
|
+
pattern: string,
|
|
17
|
+
): Result.Result<RegExp, PatternInvalid> => {
|
|
18
|
+
try {
|
|
19
|
+
return Result.succeed(new RegExp(pattern));
|
|
20
|
+
} catch (cause) {
|
|
21
|
+
return Result.fail(new PatternInvalid({ ruleName, field, pattern, detail: String(cause) }));
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const compilePatterns = (
|
|
26
|
+
ruleName: string,
|
|
27
|
+
field: string,
|
|
28
|
+
patterns: string | ReadonlyArray<string> | undefined,
|
|
29
|
+
): Result.Result<ReadonlyArray<RegExp>, PatternInvalid> => {
|
|
30
|
+
const sources = patterns === undefined ? [] : patternsOf(patterns);
|
|
31
|
+
const compiled: Array<RegExp> = [];
|
|
32
|
+
for (const source of sources) {
|
|
33
|
+
const one = compilePattern(ruleName, field, source);
|
|
34
|
+
if (Result.isFailure(one)) return Result.fail(one.failure);
|
|
35
|
+
compiled.push(one.success);
|
|
36
|
+
}
|
|
37
|
+
return Result.succeed(compiled);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Target patterns are compiled per edge (a `$1` is only known once the `from`
|
|
41
|
+
// side has matched), so they are validated separately at load. A typo in a
|
|
42
|
+
// `toNot` must fail the config, not lie dormant until some file happens to
|
|
43
|
+
// select the rule.
|
|
44
|
+
export const validateTargetPatterns = (
|
|
45
|
+
ruleName: string,
|
|
46
|
+
fields: ReadonlyArray<readonly [string, string | ReadonlyArray<string> | undefined]>,
|
|
47
|
+
): Result.Result<void, PatternInvalid> => {
|
|
48
|
+
for (const [field, patterns] of fields) {
|
|
49
|
+
const validated = compilePatterns(
|
|
50
|
+
ruleName,
|
|
51
|
+
field,
|
|
52
|
+
patterns === undefined ? undefined : patternsOf(patterns).map(withPlaceholderCaptures),
|
|
53
|
+
);
|
|
54
|
+
if (Result.isFailure(validated)) return Result.fail(validated.failure);
|
|
55
|
+
}
|
|
56
|
+
return Result.succeed(undefined);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const sourcesOf = (
|
|
60
|
+
patterns: string | ReadonlyArray<string> | undefined,
|
|
61
|
+
): ReadonlyArray<string> => (patterns === undefined ? [] : [...patternsOf(patterns)]);
|
|
62
|
+
|
|
63
|
+
// A capture is one path segment from the importer, spliced into a target
|
|
64
|
+
// pattern as `$1`. It is data, not pattern syntax: a folder named `my.module`
|
|
65
|
+
// must match itself, not "my" plus any character plus "module".
|
|
66
|
+
const REGEX_METACHARACTER = /[.*+?^${}()|[\]\\]/g;
|
|
67
|
+
|
|
68
|
+
const substitute = (pattern: string, captures: RegExpExecArray): string =>
|
|
69
|
+
pattern.replaceAll(BACKREFERENCE, (whole, index: string) => {
|
|
70
|
+
const capture = captures[Number(index)];
|
|
71
|
+
return capture === undefined ? whole : capture.replace(REGEX_METACHARACTER, "\\$&");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const targetCache = new Map<string, RegExp>();
|
|
75
|
+
|
|
76
|
+
const compiledTarget = (pattern: string): RegExp => {
|
|
77
|
+
const cached = targetCache.get(pattern);
|
|
78
|
+
if (cached !== undefined) return cached;
|
|
79
|
+
const compiled = new RegExp(pattern);
|
|
80
|
+
targetCache.set(pattern, compiled);
|
|
81
|
+
return compiled;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const matchesAny = (
|
|
85
|
+
patterns: ReadonlyArray<string>,
|
|
86
|
+
captures: RegExpExecArray,
|
|
87
|
+
value: string,
|
|
88
|
+
): boolean => patterns.some((pattern) => compiledTarget(substitute(pattern, captures)).test(value));
|
|
89
|
+
|
|
90
|
+
export type Selectable = {
|
|
91
|
+
readonly from: ReadonlyArray<RegExp>;
|
|
92
|
+
readonly fromNot: ReadonlyArray<RegExp>;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const firstFromMatch = (rule: Selectable, importer: string): RegExpExecArray | null => {
|
|
96
|
+
if (rule.fromNot.some((pattern) => pattern.test(importer))) return null;
|
|
97
|
+
for (const pattern of rule.from) {
|
|
98
|
+
const captures = pattern.exec(importer);
|
|
99
|
+
if (captures !== null) return captures;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// A probe states its target as a resolved path, so its dependency kind is read
|
|
105
|
+
// back off that path rather than restated in the config.
|
|
106
|
+
export const kindOfPath = (path: string): DependencyKind =>
|
|
107
|
+
path.startsWith("node:") ? "builtin" : path.includes("node_modules/") ? "external" : "local";
|
|
108
|
+
|
|
109
|
+
export type Targeted = Selectable & {
|
|
110
|
+
readonly to: ReadonlyArray<string>;
|
|
111
|
+
readonly toNot: ReadonlyArray<string>;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const targetAllowed = (
|
|
115
|
+
rule: Targeted,
|
|
116
|
+
captures: RegExpExecArray,
|
|
117
|
+
targetPath: string,
|
|
118
|
+
): boolean => {
|
|
119
|
+
if (rule.to.length > 0 && !matchesAny(rule.to, captures, targetPath)) return false;
|
|
120
|
+
if (rule.toNot.length > 0 && matchesAny(rule.toNot, captures, targetPath)) return false;
|
|
121
|
+
return true;
|
|
122
|
+
};
|