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.
Files changed (152) hide show
  1. package/LICENSE +21 -0
  2. package/build/dts/adapters/cli/main.d.ts +3 -0
  3. package/build/dts/adapters/cli/main.d.ts.map +1 -0
  4. package/build/dts/adapters/cli/run.d.ts +19 -0
  5. package/build/dts/adapters/cli/run.d.ts.map +1 -0
  6. package/build/dts/adapters/cli/source-facts.d.ts +9 -0
  7. package/build/dts/adapters/cli/source-facts.d.ts.map +1 -0
  8. package/build/dts/adapters/cli/source-files.d.ts +2 -0
  9. package/build/dts/adapters/cli/source-files.d.ts.map +1 -0
  10. package/build/dts/adapters/oxlint/config-loader.d.ts +23 -0
  11. package/build/dts/adapters/oxlint/config-loader.d.ts.map +1 -0
  12. package/build/dts/adapters/oxlint/exports-rule.d.ts +4 -0
  13. package/build/dts/adapters/oxlint/exports-rule.d.ts.map +1 -0
  14. package/build/dts/adapters/oxlint/imports-rule.d.ts +4 -0
  15. package/build/dts/adapters/oxlint/imports-rule.d.ts.map +1 -0
  16. package/build/dts/adapters/oxlint/members-rule.d.ts +4 -0
  17. package/build/dts/adapters/oxlint/members-rule.d.ts.map +1 -0
  18. package/build/dts/adapters/oxlint/oxlint-api.d.ts +19 -0
  19. package/build/dts/adapters/oxlint/oxlint-api.d.ts.map +1 -0
  20. package/build/dts/adapters/oxlint/plugin.d.ts +15 -0
  21. package/build/dts/adapters/oxlint/plugin.d.ts.map +1 -0
  22. package/build/dts/adapters/oxlint/structure-rule.d.ts +4 -0
  23. package/build/dts/adapters/oxlint/structure-rule.d.ts.map +1 -0
  24. package/build/dts/core/baseline.d.ts +16 -0
  25. package/build/dts/core/baseline.d.ts.map +1 -0
  26. package/build/dts/core/exports.d.ts +39 -0
  27. package/build/dts/core/exports.d.ts.map +1 -0
  28. package/build/dts/core/imports.d.ts +27 -0
  29. package/build/dts/core/imports.d.ts.map +1 -0
  30. package/build/dts/core/members.d.ts +28 -0
  31. package/build/dts/core/members.d.ts.map +1 -0
  32. package/build/dts/core/patterns.d.ts +19 -0
  33. package/build/dts/core/patterns.d.ts.map +1 -0
  34. package/build/dts/core/structure.d.ts +48 -0
  35. package/build/dts/core/structure.d.ts.map +1 -0
  36. package/build/dts/domain/architecture-config.d.ts +177 -0
  37. package/build/dts/domain/architecture-config.d.ts.map +1 -0
  38. package/build/dts/domain/architecture-error.d.ts +27 -0
  39. package/build/dts/domain/architecture-error.d.ts.map +1 -0
  40. package/build/dts/domain/violation.d.ts +11 -0
  41. package/build/dts/domain/violation.d.ts.map +1 -0
  42. package/build/dts/index.d.ts +10 -0
  43. package/build/dts/index.d.ts.map +1 -0
  44. package/build/dts/infrastructure/file-system-fake.d.ts +3 -0
  45. package/build/dts/infrastructure/file-system-fake.d.ts.map +1 -0
  46. package/build/dts/infrastructure/file-system-live.d.ts +3 -0
  47. package/build/dts/infrastructure/file-system-live.d.ts.map +1 -0
  48. package/build/dts/infrastructure/module-resolver-fake.d.ts +3 -0
  49. package/build/dts/infrastructure/module-resolver-fake.d.ts.map +1 -0
  50. package/build/dts/infrastructure/module-resolver-live.d.ts +4 -0
  51. package/build/dts/infrastructure/module-resolver-live.d.ts.map +1 -0
  52. package/build/dts/manifest/compile.d.ts +16 -0
  53. package/build/dts/manifest/compile.d.ts.map +1 -0
  54. package/build/dts/manifest/glob.d.ts +14 -0
  55. package/build/dts/manifest/glob.d.ts.map +1 -0
  56. package/build/dts/manifest/manifest.d.ts +96 -0
  57. package/build/dts/manifest/manifest.d.ts.map +1 -0
  58. package/build/dts/ports/file-system.d.ts +4 -0
  59. package/build/dts/ports/file-system.d.ts.map +1 -0
  60. package/build/dts/ports/module-resolver.d.ts +11 -0
  61. package/build/dts/ports/module-resolver.d.ts.map +1 -0
  62. package/build/esm/adapters/cli/main.js +17 -0
  63. package/build/esm/adapters/cli/main.js.map +1 -0
  64. package/build/esm/adapters/cli/run.js +188 -0
  65. package/build/esm/adapters/cli/run.js.map +1 -0
  66. package/build/esm/adapters/cli/source-facts.js +117 -0
  67. package/build/esm/adapters/cli/source-facts.js.map +1 -0
  68. package/build/esm/adapters/cli/source-files.js +40 -0
  69. package/build/esm/adapters/cli/source-files.js.map +1 -0
  70. package/build/esm/adapters/oxlint/config-loader.js +81 -0
  71. package/build/esm/adapters/oxlint/config-loader.js.map +1 -0
  72. package/build/esm/adapters/oxlint/exports-rule.js +104 -0
  73. package/build/esm/adapters/oxlint/exports-rule.js.map +1 -0
  74. package/build/esm/adapters/oxlint/imports-rule.js +55 -0
  75. package/build/esm/adapters/oxlint/imports-rule.js.map +1 -0
  76. package/build/esm/adapters/oxlint/members-rule.js +75 -0
  77. package/build/esm/adapters/oxlint/members-rule.js.map +1 -0
  78. package/build/esm/adapters/oxlint/oxlint-api.js +7 -0
  79. package/build/esm/adapters/oxlint/oxlint-api.js.map +1 -0
  80. package/build/esm/adapters/oxlint/plugin.js +23 -0
  81. package/build/esm/adapters/oxlint/plugin.js.map +1 -0
  82. package/build/esm/adapters/oxlint/structure-rule.js +37 -0
  83. package/build/esm/adapters/oxlint/structure-rule.js.map +1 -0
  84. package/build/esm/core/baseline.js +30 -0
  85. package/build/esm/core/baseline.js.map +1 -0
  86. package/build/esm/core/exports.js +96 -0
  87. package/build/esm/core/exports.js.map +1 -0
  88. package/build/esm/core/imports.js +85 -0
  89. package/build/esm/core/imports.js.map +1 -0
  90. package/build/esm/core/members.js +84 -0
  91. package/build/esm/core/members.js.map +1 -0
  92. package/build/esm/core/patterns.js +78 -0
  93. package/build/esm/core/patterns.js.map +1 -0
  94. package/build/esm/core/structure.js +256 -0
  95. package/build/esm/core/structure.js.map +1 -0
  96. package/build/esm/domain/architecture-config.js +172 -0
  97. package/build/esm/domain/architecture-config.js.map +1 -0
  98. package/build/esm/domain/architecture-error.js +29 -0
  99. package/build/esm/domain/architecture-error.js.map +1 -0
  100. package/build/esm/domain/violation.js +8 -0
  101. package/build/esm/domain/violation.js.map +1 -0
  102. package/build/esm/index.js +8 -0
  103. package/build/esm/index.js.map +1 -0
  104. package/build/esm/infrastructure/file-system-fake.js +5 -0
  105. package/build/esm/infrastructure/file-system-fake.js.map +1 -0
  106. package/build/esm/infrastructure/file-system-live.js +19 -0
  107. package/build/esm/infrastructure/file-system-live.js.map +1 -0
  108. package/build/esm/infrastructure/module-resolver-fake.js +23 -0
  109. package/build/esm/infrastructure/module-resolver-fake.js.map +1 -0
  110. package/build/esm/infrastructure/module-resolver-live.js +71 -0
  111. package/build/esm/infrastructure/module-resolver-live.js.map +1 -0
  112. package/build/esm/manifest/compile.js +503 -0
  113. package/build/esm/manifest/compile.js.map +1 -0
  114. package/build/esm/manifest/glob.js +77 -0
  115. package/build/esm/manifest/glob.js.map +1 -0
  116. package/build/esm/manifest/manifest.js +120 -0
  117. package/build/esm/manifest/manifest.js.map +1 -0
  118. package/build/esm/ports/file-system.js +2 -0
  119. package/build/esm/ports/file-system.js.map +1 -0
  120. package/build/esm/ports/module-resolver.js +2 -0
  121. package/build/esm/ports/module-resolver.js.map +1 -0
  122. package/package.json +73 -0
  123. package/src/adapters/cli/main.ts +20 -0
  124. package/src/adapters/cli/run.ts +270 -0
  125. package/src/adapters/cli/source-facts.ts +137 -0
  126. package/src/adapters/cli/source-files.ts +42 -0
  127. package/src/adapters/oxlint/config-loader.ts +136 -0
  128. package/src/adapters/oxlint/exports-rule.ts +144 -0
  129. package/src/adapters/oxlint/imports-rule.ts +67 -0
  130. package/src/adapters/oxlint/members-rule.ts +114 -0
  131. package/src/adapters/oxlint/oxlint-api.ts +25 -0
  132. package/src/adapters/oxlint/plugin.ts +35 -0
  133. package/src/adapters/oxlint/structure-rule.ts +39 -0
  134. package/src/core/baseline.ts +62 -0
  135. package/src/core/exports.ts +164 -0
  136. package/src/core/imports.ts +138 -0
  137. package/src/core/members.ts +131 -0
  138. package/src/core/patterns.ts +122 -0
  139. package/src/core/structure.ts +348 -0
  140. package/src/domain/architecture-config.ts +206 -0
  141. package/src/domain/architecture-error.ts +43 -0
  142. package/src/domain/violation.ts +24 -0
  143. package/src/index.ts +28 -0
  144. package/src/infrastructure/file-system-fake.ts +6 -0
  145. package/src/infrastructure/file-system-live.ts +20 -0
  146. package/src/infrastructure/module-resolver-fake.ts +29 -0
  147. package/src/infrastructure/module-resolver-live.ts +100 -0
  148. package/src/manifest/compile.ts +736 -0
  149. package/src/manifest/glob.ts +108 -0
  150. package/src/manifest/manifest.ts +178 -0
  151. package/src/ports/file-system.ts +5 -0
  152. package/src/ports/module-resolver.ts +23 -0
@@ -0,0 +1,348 @@
1
+ import * as Result from "effect/Result";
2
+
3
+ import type {
4
+ StructureConfig,
5
+ StructureFolder,
6
+ StructureNaming,
7
+ StructureParity,
8
+ StructureRoot,
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 type { FileSystem } from "../ports/file-system.js";
13
+ import { compilePatterns } from "./patterns.js";
14
+
15
+ export type CompiledStructureRoot = {
16
+ readonly name: string;
17
+ readonly message: string;
18
+ readonly path: ReadonlyArray<RegExp>;
19
+ readonly probe: string;
20
+ };
21
+
22
+ export type CompiledStructureFolder = {
23
+ readonly name: string;
24
+ readonly message: string;
25
+ readonly folder: ReadonlyArray<RegExp>;
26
+ readonly files: ReadonlyArray<RegExp>;
27
+ readonly probe: string;
28
+ };
29
+
30
+ export type CompiledStructureParity = {
31
+ readonly name: string;
32
+ readonly message: string;
33
+ readonly file: ReadonlyArray<RegExp>;
34
+ readonly fileNot: ReadonlyArray<RegExp>;
35
+ readonly requires: ReadonlyArray<string>;
36
+ readonly probe: string;
37
+ };
38
+
39
+ export type CompiledStructureNaming = {
40
+ readonly name: string;
41
+ readonly message: string;
42
+ readonly file: ReadonlyArray<RegExp>;
43
+ readonly fileNot: ReadonlyArray<RegExp>;
44
+ readonly subject: number;
45
+ readonly convention: RegExp | null;
46
+ readonly sameAs: number | null;
47
+ readonly probe: string;
48
+ };
49
+
50
+ export type CompiledStructure = {
51
+ readonly roots: ReadonlyArray<CompiledStructureRoot>;
52
+ readonly folders: ReadonlyArray<CompiledStructureFolder>;
53
+ readonly parity: ReadonlyArray<CompiledStructureParity>;
54
+ readonly naming: ReadonlyArray<CompiledStructureNaming>;
55
+ };
56
+
57
+ export const EMPTY_STRUCTURE: CompiledStructure = {
58
+ roots: [],
59
+ folders: [],
60
+ parity: [],
61
+ naming: [],
62
+ };
63
+
64
+ const compileRoot = (rule: StructureRoot): Result.Result<CompiledStructureRoot, PatternInvalid> => {
65
+ const path = compilePatterns(rule.name, "path", rule.path);
66
+ if (Result.isFailure(path)) return Result.fail(path.failure);
67
+ return Result.succeed({
68
+ name: rule.name,
69
+ message: rule.message,
70
+ path: path.success,
71
+ probe: rule.probe.path,
72
+ });
73
+ };
74
+
75
+ const compileFolder = (
76
+ rule: StructureFolder,
77
+ ): Result.Result<CompiledStructureFolder, PatternInvalid> => {
78
+ const folder = compilePatterns(rule.name, "folder", rule.folder);
79
+ if (Result.isFailure(folder)) return Result.fail(folder.failure);
80
+ const files = compilePatterns(rule.name, "files", rule.files);
81
+ if (Result.isFailure(files)) return Result.fail(files.failure);
82
+ return Result.succeed({
83
+ name: rule.name,
84
+ message: rule.message,
85
+ folder: folder.success,
86
+ files: files.success,
87
+ probe: rule.probe.path,
88
+ });
89
+ };
90
+
91
+ const compileParity = (
92
+ rule: StructureParity,
93
+ ): Result.Result<CompiledStructureParity, PatternInvalid> => {
94
+ const file = compilePatterns(rule.name, "file", rule.file);
95
+ if (Result.isFailure(file)) return Result.fail(file.failure);
96
+ const fileNot = compilePatterns(rule.name, "fileNot", rule.fileNot);
97
+ if (Result.isFailure(fileNot)) return Result.fail(fileNot.failure);
98
+ return Result.succeed({
99
+ name: rule.name,
100
+ message: rule.message,
101
+ file: file.success,
102
+ fileNot: fileNot.success,
103
+ requires: [...rule.requires],
104
+ probe: rule.probe.path,
105
+ });
106
+ };
107
+
108
+ const compileNaming = (
109
+ rule: StructureNaming,
110
+ ): Result.Result<CompiledStructureNaming, PatternInvalid> => {
111
+ const file = compilePatterns(rule.name, "file", rule.file);
112
+ if (Result.isFailure(file)) return Result.fail(file.failure);
113
+ const fileNot = compilePatterns(rule.name, "fileNot", rule.fileNot);
114
+ if (Result.isFailure(fileNot)) return Result.fail(fileNot.failure);
115
+ const convention = compilePatterns(rule.name, "convention", rule.convention);
116
+ if (Result.isFailure(convention)) return Result.fail(convention.failure);
117
+ return Result.succeed({
118
+ name: rule.name,
119
+ message: rule.message,
120
+ file: file.success,
121
+ fileNot: fileNot.success,
122
+ subject: rule.subject,
123
+ convention: convention.success[0] ?? null,
124
+ sameAs: rule.sameAs ?? null,
125
+ probe: rule.probe.path,
126
+ });
127
+ };
128
+
129
+ const compileAll = <A, B>(
130
+ items: ReadonlyArray<A>,
131
+ compile: (item: A) => Result.Result<B, PatternInvalid>,
132
+ ): Result.Result<ReadonlyArray<B>, PatternInvalid> => {
133
+ const compiled: Array<B> = [];
134
+ for (const item of items) {
135
+ const one = compile(item);
136
+ if (Result.isFailure(one)) return Result.fail(one.failure);
137
+ compiled.push(one.success);
138
+ }
139
+ return Result.succeed(compiled);
140
+ };
141
+
142
+ export const compileStructure = (
143
+ config: StructureConfig | undefined,
144
+ ): Result.Result<CompiledStructure, PatternInvalid> => {
145
+ if (config === undefined) return Result.succeed(EMPTY_STRUCTURE);
146
+
147
+ const roots = compileAll(config.roots ?? [], compileRoot);
148
+ if (Result.isFailure(roots)) return Result.fail(roots.failure);
149
+ const folders = compileAll(config.folders ?? [], compileFolder);
150
+ if (Result.isFailure(folders)) return Result.fail(folders.failure);
151
+ const parity = compileAll(config.parity ?? [], compileParity);
152
+ if (Result.isFailure(parity)) return Result.fail(parity.failure);
153
+ const naming = compileAll(config.naming ?? [], compileNaming);
154
+ if (Result.isFailure(naming)) return Result.fail(naming.failure);
155
+
156
+ return Result.succeed({
157
+ roots: roots.success,
158
+ folders: folders.success,
159
+ parity: parity.success,
160
+ naming: naming.success,
161
+ });
162
+ };
163
+
164
+ const anyMatches = (patterns: ReadonlyArray<RegExp>, value: string): boolean =>
165
+ patterns.some((pattern) => pattern.test(value));
166
+
167
+ const dirnameOf = (file: string): string => {
168
+ const cut = file.lastIndexOf("/");
169
+ return cut === -1 ? "" : file.slice(0, cut);
170
+ };
171
+
172
+ const basenameOf = (file: string): string => file.slice(file.lastIndexOf("/") + 1);
173
+
174
+ // `{base}` is the filename minus its FINAL extension, so `create-todo.handler.ts`
175
+ // yields `create-todo.handler` and `{base}.test.ts` names its sibling test. The
176
+ // dot-delimited stereotype stays part of the base on purpose.
177
+ const baseOf = (basename: string): string => {
178
+ const cut = basename.lastIndexOf(".");
179
+ return cut <= 0 ? basename : basename.slice(0, cut);
180
+ };
181
+
182
+ // A `../` in a required path is resolved against the file's own folder, which is
183
+ // how a port three folders from its adapters names them.
184
+ const resolveSibling = (folder: string, relative: string): string => {
185
+ const segments = folder === "" ? [] : folder.split("/");
186
+ for (const segment of relative.split("/")) {
187
+ if (segment === "..") segments.pop();
188
+ else if (segment !== "." && segment !== "") segments.push(segment);
189
+ }
190
+ return segments.join("/");
191
+ };
192
+
193
+ export const requiredSiblingsOf = (
194
+ rule: CompiledStructureParity,
195
+ file: string,
196
+ ): ReadonlyArray<string> => {
197
+ const base = baseOf(basenameOf(file));
198
+ const folder = dirnameOf(file);
199
+ return rule.requires.map((template) =>
200
+ resolveSibling(folder, template.replaceAll("{base}", base)),
201
+ );
202
+ };
203
+
204
+ type NamingMatch = {
205
+ readonly subject: string;
206
+ readonly expected: string | null;
207
+ // Whether the rule asked for a comparison at all. Without this a `sameAs`
208
+ // naming a group the pattern never fills would fall back to "no convention"
209
+ // and admit every name — vacuous, in the one family added to stop that.
210
+ readonly comparing: boolean;
211
+ };
212
+
213
+ // The rule's pattern carries capture groups; `subject` says which one holds the
214
+ // name being judged, and `sameAs` which one it has to equal.
215
+ const firstNamingMatch = (rule: CompiledStructureNaming, file: string): NamingMatch | null => {
216
+ for (const pattern of rule.file) {
217
+ const found = pattern.exec(file);
218
+ if (found === null) continue;
219
+ const subject = found[rule.subject];
220
+ if (subject === undefined) continue;
221
+ return {
222
+ subject,
223
+ expected: rule.sameAs === null ? null : (found[rule.sameAs] ?? null),
224
+ comparing: rule.sameAs !== null,
225
+ };
226
+ }
227
+ return null;
228
+ };
229
+
230
+ const namingSatisfied = (rule: CompiledStructureNaming, named: NamingMatch): boolean =>
231
+ named.comparing
232
+ ? named.expected !== null && named.subject === named.expected
233
+ : rule.convention === null || rule.convention.test(named.subject);
234
+
235
+ export const evaluateStructure = (
236
+ structure: CompiledStructure,
237
+ fileSystem: FileSystem,
238
+ file: string,
239
+ ): ReadonlyArray<Violation> => {
240
+ const violations: Array<Violation> = [];
241
+ const folder = dirnameOf(file);
242
+ const basename = basenameOf(file);
243
+
244
+ for (const rule of structure.parity) {
245
+ if (!anyMatches(rule.file, file) || anyMatches(rule.fileNot, file)) continue;
246
+ for (const sibling of requiredSiblingsOf(rule, file)) {
247
+ if (fileSystem.exists(sibling)) continue;
248
+ violations.push({
249
+ kind: "structure",
250
+ ruleName: rule.name,
251
+ message: rule.message,
252
+ file,
253
+ subject: sibling,
254
+ });
255
+ }
256
+ }
257
+
258
+ for (const rule of structure.naming) {
259
+ if (anyMatches(rule.fileNot, file)) continue;
260
+ const named = firstNamingMatch(rule, file);
261
+ if (named === null || namingSatisfied(rule, named)) continue;
262
+ violations.push({
263
+ kind: "structure",
264
+ ruleName: rule.name,
265
+ message: rule.message,
266
+ file,
267
+ subject: named.subject,
268
+ });
269
+ }
270
+
271
+ const governing = structure.folders.filter((rule) => anyMatches(rule.folder, folder));
272
+
273
+ if (governing.length > 0) {
274
+ if (!governing.some((rule) => anyMatches(rule.files, basename))) {
275
+ const rule = governing[0];
276
+ if (rule !== undefined) {
277
+ violations.push({
278
+ kind: "structure",
279
+ ruleName: rule.name,
280
+ message: rule.message,
281
+ file,
282
+ subject: basename,
283
+ });
284
+ }
285
+ }
286
+ return violations;
287
+ }
288
+
289
+ // No folder rule governs this folder at all. Inside a taxonomy root that means
290
+ // the folder itself is not part of the taxonomy — the stray-folder case a
291
+ // per-file check would otherwise miss entirely.
292
+ const root = structure.roots.find((candidate) => anyMatches(candidate.path, file));
293
+ if (root !== undefined) {
294
+ violations.push({
295
+ kind: "structure",
296
+ ruleName: root.name,
297
+ message: root.message,
298
+ file,
299
+ subject: folder,
300
+ });
301
+ }
302
+
303
+ return violations;
304
+ };
305
+
306
+ // A structure rule proves itself the same way the other families do: on a path
307
+ // it must reject. Parity is checked structurally — that the rule still selects
308
+ // its probe and still renders a sibling to demand — because asking the real
309
+ // filesystem at config-load time would make the guard depend on a fixture.
310
+ export const structureRulesFailingTheirProbe = (
311
+ structure: CompiledStructure,
312
+ ): ReadonlyArray<string> => {
313
+ const failing: Array<string> = [];
314
+
315
+ for (const rule of structure.naming) {
316
+ const named = firstNamingMatch(rule, rule.probe);
317
+ if (named === null || namingSatisfied(rule, named) || anyMatches(rule.fileNot, rule.probe)) {
318
+ failing.push(rule.name);
319
+ }
320
+ }
321
+
322
+ for (const rule of structure.parity) {
323
+ const selects = anyMatches(rule.file, rule.probe) && !anyMatches(rule.fileNot, rule.probe);
324
+ const demands = requiredSiblingsOf(rule, rule.probe).some((sibling) => sibling.length > 0);
325
+ if (!selects || !demands) failing.push(rule.name);
326
+ }
327
+
328
+ const layoutOnly = { ...structure, parity: [] };
329
+ const reported = (file: string): ReadonlyArray<string> =>
330
+ evaluateStructure(layoutOnly, { exists: () => true }, file).map(
331
+ (violation) => violation.ruleName,
332
+ );
333
+
334
+ for (const rule of structure.folders) {
335
+ // A folder that admits any name claims its folder but states no policy, so
336
+ // there is nothing for a probe to demonstrate.
337
+ if (rule.files.some((pattern) => pattern.source === "^.*$")) continue;
338
+ // The rule must govern its probe's folder AND the folder's admitted set —
339
+ // the union of every governing rule — must still reject the basename.
340
+ const governs = anyMatches(rule.folder, dirnameOf(rule.probe));
341
+ if (!governs || reported(rule.probe).length === 0) failing.push(rule.name);
342
+ }
343
+ for (const rule of structure.roots) {
344
+ if (!reported(rule.probe).includes(rule.name)) failing.push(rule.name);
345
+ }
346
+
347
+ return failing;
348
+ };
@@ -0,0 +1,206 @@
1
+ import * as Schema from "effect/Schema";
2
+
3
+ // Every pattern in this config is a JavaScript regular-expression source string
4
+ // matched against a repo-relative, forward-slash path — the same vocabulary
5
+ // dependency-cruiser rules are written in, so a rule ported from there keeps its
6
+ // pattern character-for-character. A list matches when ANY member matches.
7
+ const PatternList = Schema.Union([Schema.String, Schema.Array(Schema.String)]);
8
+
9
+ // An import edge is a violation when the importer matches `from` (and not
10
+ // `fromNot`) AND the resolved target matches `to` (and not `toNot`). Omitting
11
+ // `to` means "any target", which is how a rule expresses "this folder may not
12
+ // import anything outside its allowlist" as a single `toNot`.
13
+ // A synthetic edge this rule must report. Every rule carries one: a configured
14
+ // rule that reports nothing is indistinguishable from a clean codebase, and that
15
+ // is the failure this package exists to make impossible. `from` is a repo-relative
16
+ // importer path, `to` a repo-relative RESOLVED target (a `node_modules/...` path
17
+ // for an external, `node:<name>` for a builtin).
18
+ const ImportProbe = Schema.Struct({
19
+ from: Schema.String,
20
+ to: Schema.String,
21
+ });
22
+
23
+ export const ImportRule = Schema.Struct({
24
+ name: Schema.String,
25
+ message: Schema.String,
26
+ probe: ImportProbe,
27
+ from: PatternList,
28
+ fromNot: Schema.optionalKey(PatternList),
29
+ to: Schema.optionalKey(PatternList),
30
+ toNot: Schema.optionalKey(PatternList),
31
+ // `external` is a target inside node_modules, `builtin` a node: module,
32
+ // `local` anything else. Replaces dependency-cruiser's `dependencyTypes`,
33
+ // whose finer npm grades no rule in this repo distinguishes.
34
+ dependencyKind: Schema.optionalKey(Schema.Literals(["external", "local", "builtin"])),
35
+ });
36
+
37
+ // Resolution is tsconfig-driven, and this monorepo needs two: the web/components
38
+ // pass resolves `@/*` and `@org/components/*` that the server pass does not.
39
+ const ResolveScope = Schema.Struct({
40
+ files: Schema.String,
41
+ tsconfig: Schema.String,
42
+ });
43
+
44
+ export const ResolveConfig = Schema.Struct({
45
+ scopes: Schema.Array(ResolveScope),
46
+ extensions: Schema.optionalKey(Schema.Array(Schema.String)),
47
+ conditionNames: Schema.optionalKey(Schema.Array(Schema.String)),
48
+ mainFields: Schema.optionalKey(Schema.Array(Schema.String)),
49
+ // An edge nobody can resolve is an edge no rule can police, which is the
50
+ // silent-vacuity failure this whole package exists to prevent. Default loud.
51
+ unresolved: Schema.optionalKey(Schema.Literals(["error", "off"])),
52
+ ignoreUnresolved: Schema.optionalKey(Schema.Array(Schema.String)),
53
+ });
54
+
55
+ // Which binding form an import site used. A rule that fences off a factory
56
+ // function cares about `named`; one steering people to namespace subpath imports
57
+ // cares that `named` was used at all.
58
+ const BindingKind = Schema.Literals(["named", "default", "namespace"]);
59
+
60
+ const ExportProbe = Schema.Struct({
61
+ from: Schema.String,
62
+ to: Schema.String,
63
+ symbol: Schema.String,
64
+ kind: Schema.optionalKey(BindingKind),
65
+ });
66
+
67
+ // Where a given *exported symbol* may be imported. `imports` asks whether one
68
+ // file may reach another at all; this asks which names it may pull across when
69
+ // it does — the distinction a path rule cannot make, because every importer of a
70
+ // barrel resolves to the same file.
71
+ export const ExportRule = Schema.Struct({
72
+ name: Schema.String,
73
+ message: Schema.String,
74
+ probe: ExportProbe,
75
+ from: PatternList,
76
+ fromNot: Schema.optionalKey(PatternList),
77
+ to: PatternList,
78
+ toNot: Schema.optionalKey(PatternList),
79
+ // Exact exported names. Omit to mean "any name", which is how a rule bans a
80
+ // whole binding form (every named import from a package barrel, say).
81
+ symbols: Schema.optionalKey(Schema.Array(Schema.String)),
82
+ // Defaults to ["named"] — the discriminating form for every rule of this shape
83
+ // written so far.
84
+ kinds: Schema.optionalKey(Schema.Array(BindingKind)),
85
+ // A named autofix strategy. `subpath-namespace-import` rewrites
86
+ // `import { A, B as C } from "pkg"` into `import * as A from "pkg/A"` /
87
+ // `import * as C from "pkg/B"`, for packages that publish each module as its own
88
+ // subpath. A rule carrying a fix reports once per declaration rather than once
89
+ // per symbol, because the fix rewrites the whole declaration.
90
+ fix: Schema.optionalKey(Schema.Literals(["subpath-namespace-import"])),
91
+ });
92
+
93
+ // What kind of declared name a rule is about. `type-members` are the members of
94
+ // a type alias (a port's method vocabulary); `calls` are called identifiers (the
95
+ // hooks a tier may reach for).
96
+ const MemberSubject = Schema.Literals(["type-members", "calls"]);
97
+
98
+ const MemberProbe = Schema.Struct({
99
+ from: Schema.String,
100
+ name: Schema.String,
101
+ in: Schema.optionalKey(Schema.String),
102
+ });
103
+
104
+ // Which names a file is allowed to declare or call. This is the one family that
105
+ // needs no module resolution: it is about the vocabulary inside a file, not the
106
+ // edges leaving it.
107
+ export const MemberRule = Schema.Struct({
108
+ name: Schema.String,
109
+ message: Schema.String,
110
+ probe: MemberProbe,
111
+ from: PatternList,
112
+ fromNot: Schema.optionalKey(PatternList),
113
+ subject: MemberSubject,
114
+ // `type-members` only: which declaration's members are governed.
115
+ in: Schema.optionalKey(PatternList),
116
+ // Which names the rule speaks to at all. Omit for "every one".
117
+ match: Schema.optionalKey(PatternList),
118
+ matchNot: Schema.optionalKey(PatternList),
119
+ // Names that are fine. A name the rule speaks to and this does not admit is
120
+ // the violation.
121
+ allow: Schema.optionalKey(PatternList),
122
+ });
123
+
124
+ const PathProbe = Schema.Struct({ path: Schema.String });
125
+
126
+ // The file taxonomy, as three questions rather than one nested tree.
127
+ //
128
+ // `roots` marks the regions where layout is deny-by-default. `folders` says
129
+ // which basenames each folder admits. `parity` says which siblings a file owes.
130
+ // Keeping them apart is what removes the nested config's most fragile rule —
131
+ // that a specific pattern must beat a `*` catch-all — because an exemption is
132
+ // now a `fileNot` on the parity rule that would otherwise fire.
133
+ const StructureRoot = Schema.Struct({
134
+ name: Schema.String,
135
+ message: Schema.String,
136
+ probe: PathProbe,
137
+ // A file under this path whose folder no `folders` rule governs is a file in a
138
+ // folder the taxonomy does not know about.
139
+ path: PatternList,
140
+ });
141
+
142
+ const StructureFolder = Schema.Struct({
143
+ name: Schema.String,
144
+ message: Schema.String,
145
+ probe: PathProbe,
146
+ // Matched against the file's folder, repo-relative, with no trailing slash.
147
+ folder: PatternList,
148
+ // Basenames this folder admits. Anything else is the violation.
149
+ files: PatternList,
150
+ });
151
+
152
+ const StructureParity = Schema.Struct({
153
+ name: Schema.String,
154
+ message: Schema.String,
155
+ probe: PathProbe,
156
+ file: PatternList,
157
+ fileNot: Schema.optionalKey(PatternList),
158
+ // Paths that must exist, relative to the file's own folder. `{base}` is the
159
+ // filename minus its final extension, so `{base}.test.ts` beside
160
+ // `create-todo.handler.ts` means `create-todo.handler.test.ts`.
161
+ requires: Schema.Array(Schema.String),
162
+ });
163
+
164
+ // What shape the variable part of a name may take. `folders` says which
165
+ // stereotypes a folder admits; this says what the concept name in front of the
166
+ // stereotype may look like — the degree of freedom a taxonomy alone leaves open.
167
+ const StructureNaming = Schema.Struct({
168
+ name: Schema.String,
169
+ message: Schema.String,
170
+ probe: PathProbe,
171
+ // Matched against the whole repo-relative path, and carrying capture groups:
172
+ // `subject` says which of them holds the name being judged.
173
+ file: PatternList,
174
+ fileNot: Schema.optionalKey(PatternList),
175
+ subject: Schema.Finite,
176
+ // The shape the subject must have. Exactly one of these.
177
+ convention: Schema.optionalKey(Schema.String),
178
+ // A capture group the subject must equal, for "named after its folder".
179
+ sameAs: Schema.optionalKey(Schema.Finite),
180
+ });
181
+
182
+ const StructureConfig = Schema.Struct({
183
+ roots: Schema.optionalKey(Schema.Array(StructureRoot)),
184
+ folders: Schema.optionalKey(Schema.Array(StructureFolder)),
185
+ parity: Schema.optionalKey(Schema.Array(StructureParity)),
186
+ naming: Schema.optionalKey(Schema.Array(StructureNaming)),
187
+ });
188
+
189
+ export type ImportRule = (typeof ImportRule)["Type"];
190
+ export type ResolveConfig = (typeof ResolveConfig)["Type"];
191
+ export type ResolveScope = (typeof ResolveScope)["Type"];
192
+ export type ImportProbe = (typeof ImportProbe)["Type"];
193
+ export type ExportRule = (typeof ExportRule)["Type"];
194
+ export type ExportProbe = (typeof ExportProbe)["Type"];
195
+ export type BindingKind = (typeof BindingKind)["Type"];
196
+ export type MemberRule = (typeof MemberRule)["Type"];
197
+ export type MemberProbe = (typeof MemberProbe)["Type"];
198
+ export type MemberSubject = (typeof MemberSubject)["Type"];
199
+ export type StructureConfig = (typeof StructureConfig)["Type"];
200
+ export type StructureRoot = (typeof StructureRoot)["Type"];
201
+ export type StructureFolder = (typeof StructureFolder)["Type"];
202
+ export type StructureParity = (typeof StructureParity)["Type"];
203
+ export type StructureNaming = (typeof StructureNaming)["Type"];
204
+
205
+ export const patternsOf = (patterns: string | ReadonlyArray<string>): ReadonlyArray<string> =>
206
+ typeof patterns === "string" ? [patterns] : patterns;
@@ -0,0 +1,43 @@
1
+ import * as Schema from "effect/Schema";
2
+
3
+ // These errors surface through oxlint's plugin loader, which prints a stack and
4
+ // nothing else. The `message` override is what turns "ConfigInvalid" into an
5
+ // instruction the reader can act on.
6
+
7
+ export class ConfigInvalid extends Schema.TaggedErrorClass<ConfigInvalid>("ConfigInvalid")(
8
+ "ConfigInvalid",
9
+ { configPath: Schema.String, detail: Schema.String },
10
+ ) {
11
+ override get message(): string {
12
+ return `${this.configPath}: ${this.detail}`;
13
+ }
14
+ }
15
+
16
+ export class PatternInvalid extends Schema.TaggedErrorClass<PatternInvalid>("PatternInvalid")(
17
+ "PatternInvalid",
18
+ {
19
+ ruleName: Schema.String,
20
+ field: Schema.String,
21
+ pattern: Schema.String,
22
+ detail: Schema.String,
23
+ },
24
+ ) {
25
+ override get message(): string {
26
+ return `rule "${this.ruleName}" has an uncompilable ${this.field} pattern ${JSON.stringify(
27
+ this.pattern,
28
+ )}: ${this.detail}`;
29
+ }
30
+ }
31
+
32
+ export class ImportUnresolved extends Schema.TaggedErrorClass<ImportUnresolved>("ImportUnresolved")(
33
+ "ImportUnresolved",
34
+ {
35
+ fromFile: Schema.String,
36
+ specifier: Schema.String,
37
+ detail: Schema.String,
38
+ },
39
+ ) {
40
+ override get message(): string {
41
+ return `${this.fromFile} imports "${this.specifier}", which does not resolve: ${this.detail}`;
42
+ }
43
+ }
@@ -0,0 +1,24 @@
1
+ export type ViolationKind = "import" | "export" | "structure" | "member";
2
+
3
+ export type Violation = {
4
+ readonly kind: ViolationKind;
5
+ readonly ruleName: string;
6
+ readonly message: string;
7
+ // Repo-relative, forward slashes.
8
+ readonly file: string;
9
+ // The other end of the violated relationship: the resolved import target, the
10
+ // restricted symbol, the missing sibling, the disallowed member. `null` when
11
+ // the file alone is the violation.
12
+ readonly subject: string | null;
13
+ };
14
+
15
+ // Deliberately excludes line and column so an entry survives ordinary edits to
16
+ // the file it names — a baseline keyed on positions would go stale on the first
17
+ // reformat and silently re-admit a violation it was never meant to cover.
18
+ export const fingerprintOf = (violation: Violation): string =>
19
+ [violation.kind, violation.ruleName, violation.file, violation.subject ?? ""].join("|");
20
+
21
+ // `{name}` in a message is replaced with the subject, so a rule can quote the
22
+ // offending symbol without the config needing a callback.
23
+ export const formatMessage = (violation: Violation): string =>
24
+ `[${violation.ruleName}] ${violation.message.replaceAll("{name}", violation.subject ?? "")}`;
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ export {
2
+ type CompiledImportRule,
3
+ compileImportRule,
4
+ compileImportRules,
5
+ evaluateImportEdge,
6
+ type ImportEdge,
7
+ } from "./core/imports.js";
8
+ export {
9
+ type ImportRule,
10
+ type ResolveConfig,
11
+ type ResolveScope,
12
+ } from "./domain/architecture-config.js";
13
+ export { ConfigInvalid, ImportUnresolved, PatternInvalid } from "./domain/architecture-error.js";
14
+ export {
15
+ fingerprintOf,
16
+ formatMessage,
17
+ type Violation,
18
+ type ViolationKind,
19
+ } from "./domain/violation.js";
20
+ export { makeModuleResolverFake } from "./infrastructure/module-resolver-fake.js";
21
+ export { makeModuleResolverLive } from "./infrastructure/module-resolver-live.js";
22
+ export { type LoweredRules, lowerManifest } from "./manifest/compile.js";
23
+ export {
24
+ type Manifest,
25
+ type ManifestNode,
26
+ Manifest as ManifestSchema,
27
+ } from "./manifest/manifest.js";
28
+ export { type ModuleResolver, type ResolvedTarget } from "./ports/module-resolver.js";
@@ -0,0 +1,6 @@
1
+ import type { FileSystem } from "../ports/file-system.js";
2
+
3
+ export const makeFileSystemFake = (present: Iterable<string>): FileSystem => {
4
+ const paths = new Set(present);
5
+ return { exists: (repoRelativePath) => paths.has(repoRelativePath) };
6
+ };
@@ -0,0 +1,20 @@
1
+ import { existsSync } from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ import type { FileSystem } from "../ports/file-system.js";
5
+
6
+ // One lint run asks about the same sibling many times — every handler in a
7
+ // folder resolves the same `../../infrastructure/...` shape — so the answers are
8
+ // memoised for the life of the run.
9
+ export const makeFileSystemLive = (repoRoot: string): FileSystem => {
10
+ const cache = new Map<string, boolean>();
11
+ return {
12
+ exists: (repoRelativePath) => {
13
+ const cached = cache.get(repoRelativePath);
14
+ if (cached !== undefined) return cached;
15
+ const answer = existsSync(path.resolve(repoRoot, repoRelativePath));
16
+ cache.set(repoRelativePath, answer);
17
+ return answer;
18
+ },
19
+ };
20
+ };