@goodbones/core 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/LICENSE +21 -0
  2. package/build/dts/core/baseline.d.ts +16 -0
  3. package/build/dts/core/baseline.d.ts.map +1 -0
  4. package/build/dts/core/coverage.d.ts +47 -0
  5. package/build/dts/core/coverage.d.ts.map +1 -0
  6. package/build/dts/core/exports.d.ts +38 -0
  7. package/build/dts/core/exports.d.ts.map +1 -0
  8. package/build/dts/core/graph.d.ts +40 -0
  9. package/build/dts/core/graph.d.ts.map +1 -0
  10. package/build/dts/core/imports.d.ts +29 -0
  11. package/build/dts/core/imports.d.ts.map +1 -0
  12. package/build/dts/core/members.d.ts +26 -0
  13. package/build/dts/core/members.d.ts.map +1 -0
  14. package/build/dts/core/patterns.d.ts +17 -0
  15. package/build/dts/core/patterns.d.ts.map +1 -0
  16. package/build/dts/core/structure.d.ts +48 -0
  17. package/build/dts/core/structure.d.ts.map +1 -0
  18. package/build/dts/core/surface.d.ts +39 -0
  19. package/build/dts/core/surface.d.ts.map +1 -0
  20. package/build/dts/domain/architecture-config.d.ts +322 -0
  21. package/build/dts/domain/architecture-config.d.ts.map +1 -0
  22. package/build/dts/domain/architecture-error.d.ts +35 -0
  23. package/build/dts/domain/architecture-error.d.ts.map +1 -0
  24. package/build/dts/domain/facts.d.ts +26 -0
  25. package/build/dts/domain/facts.d.ts.map +1 -0
  26. package/build/dts/domain/violation.d.ts +11 -0
  27. package/build/dts/domain/violation.d.ts.map +1 -0
  28. package/build/dts/index.d.ts +23 -0
  29. package/build/dts/index.d.ts.map +1 -0
  30. package/build/dts/infrastructure/fact-extractor-fake.d.ts +4 -0
  31. package/build/dts/infrastructure/fact-extractor-fake.d.ts.map +1 -0
  32. package/build/dts/infrastructure/file-system-fake.d.ts +3 -0
  33. package/build/dts/infrastructure/file-system-fake.d.ts.map +1 -0
  34. package/build/dts/infrastructure/file-system-live.d.ts +3 -0
  35. package/build/dts/infrastructure/file-system-live.d.ts.map +1 -0
  36. package/build/dts/infrastructure/manifest-file.d.ts +3 -0
  37. package/build/dts/infrastructure/manifest-file.d.ts.map +1 -0
  38. package/build/dts/infrastructure/module-resolver-fake.d.ts +3 -0
  39. package/build/dts/infrastructure/module-resolver-fake.d.ts.map +1 -0
  40. package/build/dts/infrastructure/walk.d.ts +4 -0
  41. package/build/dts/infrastructure/walk.d.ts.map +1 -0
  42. package/build/dts/load/policy.d.ts +42 -0
  43. package/build/dts/load/policy.d.ts.map +1 -0
  44. package/build/dts/manifest/compile.d.ts +26 -0
  45. package/build/dts/manifest/compile.d.ts.map +1 -0
  46. package/build/dts/manifest/glob.d.ts +14 -0
  47. package/build/dts/manifest/glob.d.ts.map +1 -0
  48. package/build/dts/manifest/manifest.d.ts +208 -0
  49. package/build/dts/manifest/manifest.d.ts.map +1 -0
  50. package/build/dts/ports/fact-extractor.d.ts +5 -0
  51. package/build/dts/ports/fact-extractor.d.ts.map +1 -0
  52. package/build/dts/ports/file-system.d.ts +5 -0
  53. package/build/dts/ports/file-system.d.ts.map +1 -0
  54. package/build/dts/ports/language.d.ts +14 -0
  55. package/build/dts/ports/language.d.ts.map +1 -0
  56. package/build/dts/ports/module-resolver.d.ts +12 -0
  57. package/build/dts/ports/module-resolver.d.ts.map +1 -0
  58. package/build/dts/testing.d.ts +4 -0
  59. package/build/dts/testing.d.ts.map +1 -0
  60. package/build/esm/core/baseline.js +30 -0
  61. package/build/esm/core/baseline.js.map +1 -0
  62. package/build/esm/core/coverage.js +75 -0
  63. package/build/esm/core/coverage.js.map +1 -0
  64. package/build/esm/core/exports.js +102 -0
  65. package/build/esm/core/exports.js.map +1 -0
  66. package/build/esm/core/graph.js +259 -0
  67. package/build/esm/core/graph.js.map +1 -0
  68. package/build/esm/core/imports.js +103 -0
  69. package/build/esm/core/imports.js.map +1 -0
  70. package/build/esm/core/members.js +106 -0
  71. package/build/esm/core/members.js.map +1 -0
  72. package/build/esm/core/patterns.js +75 -0
  73. package/build/esm/core/patterns.js.map +1 -0
  74. package/build/esm/core/structure.js +257 -0
  75. package/build/esm/core/structure.js.map +1 -0
  76. package/build/esm/core/surface.js +137 -0
  77. package/build/esm/core/surface.js.map +1 -0
  78. package/build/esm/domain/architecture-config.js +312 -0
  79. package/build/esm/domain/architecture-config.js.map +1 -0
  80. package/build/esm/domain/architecture-error.js +41 -0
  81. package/build/esm/domain/architecture-error.js.map +1 -0
  82. package/build/esm/domain/facts.js +2 -0
  83. package/build/esm/domain/facts.js.map +1 -0
  84. package/build/esm/domain/violation.js +8 -0
  85. package/build/esm/domain/violation.js.map +1 -0
  86. package/build/esm/index.js +22 -0
  87. package/build/esm/index.js.map +1 -0
  88. package/build/esm/infrastructure/fact-extractor-fake.js +24 -0
  89. package/build/esm/infrastructure/fact-extractor-fake.js.map +1 -0
  90. package/build/esm/infrastructure/file-system-fake.js +10 -0
  91. package/build/esm/infrastructure/file-system-fake.js.map +1 -0
  92. package/build/esm/infrastructure/file-system-live.js +27 -0
  93. package/build/esm/infrastructure/file-system-live.js.map +1 -0
  94. package/build/esm/infrastructure/manifest-file.js +15 -0
  95. package/build/esm/infrastructure/manifest-file.js.map +1 -0
  96. package/build/esm/infrastructure/module-resolver-fake.js +33 -0
  97. package/build/esm/infrastructure/module-resolver-fake.js.map +1 -0
  98. package/build/esm/infrastructure/walk.js +45 -0
  99. package/build/esm/infrastructure/walk.js.map +1 -0
  100. package/build/esm/load/policy.js +207 -0
  101. package/build/esm/load/policy.js.map +1 -0
  102. package/build/esm/manifest/compile.js +706 -0
  103. package/build/esm/manifest/compile.js.map +1 -0
  104. package/build/esm/manifest/glob.js +77 -0
  105. package/build/esm/manifest/glob.js.map +1 -0
  106. package/build/esm/manifest/manifest.js +303 -0
  107. package/build/esm/manifest/manifest.js.map +1 -0
  108. package/build/esm/ports/fact-extractor.js +2 -0
  109. package/build/esm/ports/fact-extractor.js.map +1 -0
  110. package/build/esm/ports/file-system.js +2 -0
  111. package/build/esm/ports/file-system.js.map +1 -0
  112. package/build/esm/ports/language.js +2 -0
  113. package/build/esm/ports/language.js.map +1 -0
  114. package/build/esm/ports/module-resolver.js +2 -0
  115. package/build/esm/ports/module-resolver.js.map +1 -0
  116. package/build/esm/testing.js +7 -0
  117. package/build/esm/testing.js.map +1 -0
  118. package/package.json +61 -0
  119. package/src/core/baseline.ts +62 -0
  120. package/src/core/coverage.ts +154 -0
  121. package/src/core/exports.ts +171 -0
  122. package/src/core/graph.ts +331 -0
  123. package/src/core/imports.ts +160 -0
  124. package/src/core/members.ts +161 -0
  125. package/src/core/patterns.ts +116 -0
  126. package/src/core/structure.ts +349 -0
  127. package/src/core/surface.ts +181 -0
  128. package/src/domain/architecture-config.ts +370 -0
  129. package/src/domain/architecture-error.ts +59 -0
  130. package/src/domain/facts.ts +47 -0
  131. package/src/domain/violation.ts +24 -0
  132. package/src/index.ts +136 -0
  133. package/src/infrastructure/fact-extractor-fake.ts +28 -0
  134. package/src/infrastructure/file-system-fake.ts +14 -0
  135. package/src/infrastructure/file-system-live.ts +27 -0
  136. package/src/infrastructure/manifest-file.ts +17 -0
  137. package/src/infrastructure/module-resolver-fake.ts +39 -0
  138. package/src/infrastructure/walk.ts +54 -0
  139. package/src/load/policy.ts +344 -0
  140. package/src/manifest/compile.ts +1011 -0
  141. package/src/manifest/glob.ts +108 -0
  142. package/src/manifest/manifest.ts +408 -0
  143. package/src/ports/fact-extractor.ts +12 -0
  144. package/src/ports/file-system.ts +8 -0
  145. package/src/ports/language.ts +39 -0
  146. package/src/ports/module-resolver.ts +28 -0
  147. package/src/testing.ts +6 -0
@@ -0,0 +1,116 @@
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
+
6
+ const BACKREFERENCE = /\$([1-9])/g;
7
+
8
+ // Validation-only stand-in so `new RegExp` sees a syntactically complete pattern
9
+ // where a backreference will later be spliced.
10
+ const withPlaceholderCaptures = (pattern: string): string => pattern.replaceAll(BACKREFERENCE, "x");
11
+
12
+ const compilePattern = (
13
+ ruleName: string,
14
+ field: string,
15
+ pattern: string,
16
+ ): Result.Result<RegExp, PatternInvalid> => {
17
+ try {
18
+ return Result.succeed(new RegExp(pattern));
19
+ } catch (cause) {
20
+ return Result.fail(new PatternInvalid({ ruleName, field, pattern, detail: String(cause) }));
21
+ }
22
+ };
23
+
24
+ export const compilePatterns = (
25
+ ruleName: string,
26
+ field: string,
27
+ patterns: string | ReadonlyArray<string> | undefined,
28
+ ): Result.Result<ReadonlyArray<RegExp>, PatternInvalid> => {
29
+ const sources = patterns === undefined ? [] : patternsOf(patterns);
30
+ const compiled: Array<RegExp> = [];
31
+ for (const source of sources) {
32
+ const one = compilePattern(ruleName, field, source);
33
+ if (Result.isFailure(one)) return Result.fail(one.failure);
34
+ compiled.push(one.success);
35
+ }
36
+ return Result.succeed(compiled);
37
+ };
38
+
39
+ // Target patterns are compiled per edge (a `$1` is only known once the `from`
40
+ // side has matched), so they are validated separately at load. A typo in a
41
+ // `toNot` must fail the config, not lie dormant until some file happens to
42
+ // select the rule.
43
+ export const validateTargetPatterns = (
44
+ ruleName: string,
45
+ fields: ReadonlyArray<readonly [string, string | ReadonlyArray<string> | undefined]>,
46
+ ): Result.Result<void, PatternInvalid> => {
47
+ for (const [field, patterns] of fields) {
48
+ const validated = compilePatterns(
49
+ ruleName,
50
+ field,
51
+ patterns === undefined ? undefined : patternsOf(patterns).map(withPlaceholderCaptures),
52
+ );
53
+ if (Result.isFailure(validated)) return Result.fail(validated.failure);
54
+ }
55
+ return Result.succeed(undefined);
56
+ };
57
+
58
+ export const sourcesOf = (
59
+ patterns: string | ReadonlyArray<string> | undefined,
60
+ ): ReadonlyArray<string> => (patterns === undefined ? [] : [...patternsOf(patterns)]);
61
+
62
+ // A capture is one path segment from the importer, spliced into a target
63
+ // pattern as `$1`. It is data, not pattern syntax: a folder named `my.module`
64
+ // must match itself, not "my" plus any character plus "module".
65
+ const REGEX_METACHARACTER = /[.*+?^${}()|[\]\\]/g;
66
+
67
+ const substitute = (pattern: string, captures: RegExpExecArray): string =>
68
+ pattern.replaceAll(BACKREFERENCE, (whole, index: string) => {
69
+ const capture = captures[Number(index)];
70
+ return capture === undefined ? whole : capture.replace(REGEX_METACHARACTER, "\\$&");
71
+ });
72
+
73
+ const targetCache = new Map<string, RegExp>();
74
+
75
+ const compiledTarget = (pattern: string): RegExp => {
76
+ const cached = targetCache.get(pattern);
77
+ if (cached !== undefined) return cached;
78
+ const compiled = new RegExp(pattern);
79
+ targetCache.set(pattern, compiled);
80
+ return compiled;
81
+ };
82
+
83
+ export const matchesAny = (
84
+ patterns: ReadonlyArray<string>,
85
+ captures: RegExpExecArray,
86
+ value: string,
87
+ ): boolean => patterns.some((pattern) => compiledTarget(substitute(pattern, captures)).test(value));
88
+
89
+ export type Selectable = {
90
+ readonly from: ReadonlyArray<RegExp>;
91
+ readonly fromNot: ReadonlyArray<RegExp>;
92
+ };
93
+
94
+ export const firstFromMatch = (rule: Selectable, importer: string): RegExpExecArray | null => {
95
+ if (rule.fromNot.some((pattern) => pattern.test(importer))) return null;
96
+ for (const pattern of rule.from) {
97
+ const captures = pattern.exec(importer);
98
+ if (captures !== null) return captures;
99
+ }
100
+ return null;
101
+ };
102
+
103
+ export type Targeted = Selectable & {
104
+ readonly to: ReadonlyArray<string>;
105
+ readonly toNot: ReadonlyArray<string>;
106
+ };
107
+
108
+ export const targetAllowed = (
109
+ rule: Targeted,
110
+ captures: RegExpExecArray,
111
+ targetPath: string,
112
+ ): boolean => {
113
+ if (rule.to.length > 0 && !matchesAny(rule.to, captures, targetPath)) return false;
114
+ if (rule.toNot.length > 0 && matchesAny(rule.toNot, captures, targetPath)) return false;
115
+ return true;
116
+ };
@@ -0,0 +1,349 @@
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 — `create-todo.handler` for
175
+ // `create-todo.handler.ts`, `handler` for `handler.go` — so `{base}.test.ts` or
176
+ // `{base}_test.go` names the sibling test. The dot-delimited stereotype stays
177
+ // part of the base on purpose.
178
+ const baseOf = (basename: string): string => {
179
+ const cut = basename.lastIndexOf(".");
180
+ return cut <= 0 ? basename : basename.slice(0, cut);
181
+ };
182
+
183
+ // A `../` in a required path is resolved against the file's own folder, which is
184
+ // how a port three folders from its adapters names them.
185
+ const resolveSibling = (folder: string, relative: string): string => {
186
+ const segments = folder === "" ? [] : folder.split("/");
187
+ for (const segment of relative.split("/")) {
188
+ if (segment === "..") segments.pop();
189
+ else if (segment !== "." && segment !== "") segments.push(segment);
190
+ }
191
+ return segments.join("/");
192
+ };
193
+
194
+ export const requiredSiblingsOf = (
195
+ rule: CompiledStructureParity,
196
+ file: string,
197
+ ): ReadonlyArray<string> => {
198
+ const base = baseOf(basenameOf(file));
199
+ const folder = dirnameOf(file);
200
+ return rule.requires.map((template) =>
201
+ resolveSibling(folder, template.replaceAll("{base}", base)),
202
+ );
203
+ };
204
+
205
+ type NamingMatch = {
206
+ readonly subject: string;
207
+ readonly expected: string | null;
208
+ // Whether the rule asked for a comparison at all. Without this a `sameAs`
209
+ // naming a group the pattern never fills would fall back to "no convention"
210
+ // and admit every name — vacuous, in the one family added to stop that.
211
+ readonly comparing: boolean;
212
+ };
213
+
214
+ // The rule's pattern carries capture groups; `subject` says which one holds the
215
+ // name being judged, and `sameAs` which one it has to equal.
216
+ const firstNamingMatch = (rule: CompiledStructureNaming, file: string): NamingMatch | null => {
217
+ for (const pattern of rule.file) {
218
+ const found = pattern.exec(file);
219
+ if (found === null) continue;
220
+ const subject = found[rule.subject];
221
+ if (subject === undefined) continue;
222
+ return {
223
+ subject,
224
+ expected: rule.sameAs === null ? null : (found[rule.sameAs] ?? null),
225
+ comparing: rule.sameAs !== null,
226
+ };
227
+ }
228
+ return null;
229
+ };
230
+
231
+ const namingSatisfied = (rule: CompiledStructureNaming, named: NamingMatch): boolean =>
232
+ named.comparing
233
+ ? named.expected !== null && named.subject === named.expected
234
+ : rule.convention === null || rule.convention.test(named.subject);
235
+
236
+ export const evaluateStructure = (
237
+ structure: CompiledStructure,
238
+ fileSystem: FileSystem,
239
+ file: string,
240
+ ): ReadonlyArray<Violation> => {
241
+ const violations: Array<Violation> = [];
242
+ const folder = dirnameOf(file);
243
+ const basename = basenameOf(file);
244
+
245
+ for (const rule of structure.parity) {
246
+ if (!anyMatches(rule.file, file) || anyMatches(rule.fileNot, file)) continue;
247
+ for (const sibling of requiredSiblingsOf(rule, file)) {
248
+ if (fileSystem.exists(sibling)) continue;
249
+ violations.push({
250
+ kind: "structure",
251
+ ruleName: rule.name,
252
+ message: rule.message,
253
+ file,
254
+ subject: sibling,
255
+ });
256
+ }
257
+ }
258
+
259
+ for (const rule of structure.naming) {
260
+ if (anyMatches(rule.fileNot, file)) continue;
261
+ const named = firstNamingMatch(rule, file);
262
+ if (named === null || namingSatisfied(rule, named)) continue;
263
+ violations.push({
264
+ kind: "structure",
265
+ ruleName: rule.name,
266
+ message: rule.message,
267
+ file,
268
+ subject: named.subject,
269
+ });
270
+ }
271
+
272
+ const governing = structure.folders.filter((rule) => anyMatches(rule.folder, folder));
273
+
274
+ if (governing.length > 0) {
275
+ if (!governing.some((rule) => anyMatches(rule.files, basename))) {
276
+ const rule = governing[0];
277
+ if (rule !== undefined) {
278
+ violations.push({
279
+ kind: "structure",
280
+ ruleName: rule.name,
281
+ message: rule.message,
282
+ file,
283
+ subject: basename,
284
+ });
285
+ }
286
+ }
287
+ return violations;
288
+ }
289
+
290
+ // No folder rule governs this folder at all. Inside a taxonomy root that means
291
+ // the folder itself is not part of the taxonomy — the stray-folder case a
292
+ // per-file check would otherwise miss entirely.
293
+ const root = structure.roots.find((candidate) => anyMatches(candidate.path, file));
294
+ if (root !== undefined) {
295
+ violations.push({
296
+ kind: "structure",
297
+ ruleName: root.name,
298
+ message: root.message,
299
+ file,
300
+ subject: folder,
301
+ });
302
+ }
303
+
304
+ return violations;
305
+ };
306
+
307
+ // A structure rule proves itself the same way the other families do: on a path
308
+ // it must reject. Parity is checked structurally — that the rule still selects
309
+ // its probe and still renders a sibling to demand — because asking the real
310
+ // filesystem at config-load time would make the guard depend on a fixture.
311
+ export const structureRulesFailingTheirProbe = (
312
+ structure: CompiledStructure,
313
+ ): ReadonlyArray<string> => {
314
+ const failing: Array<string> = [];
315
+
316
+ for (const rule of structure.naming) {
317
+ const named = firstNamingMatch(rule, rule.probe);
318
+ if (named === null || namingSatisfied(rule, named) || anyMatches(rule.fileNot, rule.probe)) {
319
+ failing.push(rule.name);
320
+ }
321
+ }
322
+
323
+ for (const rule of structure.parity) {
324
+ const selects = anyMatches(rule.file, rule.probe) && !anyMatches(rule.fileNot, rule.probe);
325
+ const demands = requiredSiblingsOf(rule, rule.probe).some((sibling) => sibling.length > 0);
326
+ if (!selects || !demands) failing.push(rule.name);
327
+ }
328
+
329
+ const layoutOnly = { ...structure, parity: [] };
330
+ const reported = (file: string): ReadonlyArray<string> =>
331
+ evaluateStructure(layoutOnly, { exists: () => true, readText: () => null }, file).map(
332
+ (violation) => violation.ruleName,
333
+ );
334
+
335
+ for (const rule of structure.folders) {
336
+ // A folder that admits any name claims its folder but states no policy, so
337
+ // there is nothing for a probe to demonstrate.
338
+ if (rule.files.some((pattern) => pattern.source === "^.*$")) continue;
339
+ // The rule must govern its probe's folder AND the folder's admitted set —
340
+ // the union of every governing rule — must still reject the basename.
341
+ const governs = anyMatches(rule.folder, dirnameOf(rule.probe));
342
+ if (!governs || reported(rule.probe).length === 0) failing.push(rule.name);
343
+ }
344
+ for (const rule of structure.roots) {
345
+ if (!reported(rule.probe).includes(rule.name)) failing.push(rule.name);
346
+ }
347
+
348
+ return failing;
349
+ };
@@ -0,0 +1,181 @@
1
+ import * as Result from "effect/Result";
2
+
3
+ import type {
4
+ BindingKind,
5
+ DeclarationKind,
6
+ SurfaceProbe,
7
+ SurfaceRule,
8
+ } from "../domain/architecture-config.js";
9
+ import type { PatternInvalid } from "../domain/architecture-error.js";
10
+ import type { ExportSite } 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 { ExportSite } from "../domain/facts.js";
16
+
17
+ // What a rule asks of the sites it speaks to. Exactly one per rule: the
18
+ // manifest lowers a spec that names several demands into several rules.
19
+ export type Demand =
20
+ | { readonly kind: "forbid" }
21
+ | { readonly kind: "allow"; readonly allow: ReadonlyArray<RegExp> }
22
+ | { readonly kind: "convention"; readonly convention: RegExp }
23
+ | { readonly kind: "count"; readonly min: number; readonly max: number };
24
+
25
+ export type CompiledSurfaceRule = {
26
+ readonly name: string;
27
+ readonly message: string;
28
+ readonly from: ReadonlyArray<RegExp>;
29
+ readonly fromNot: ReadonlyArray<RegExp>;
30
+ readonly kinds: ReadonlyArray<BindingKind> | null;
31
+ readonly declares: ReadonlyArray<DeclarationKind> | null;
32
+ readonly reexport: boolean | null;
33
+ readonly match: ReadonlyArray<RegExp>;
34
+ readonly matchNot: ReadonlyArray<RegExp>;
35
+ readonly demand: Demand;
36
+ readonly probe: SurfaceProbe;
37
+ };
38
+
39
+ const demandOf = (rule: SurfaceRule): Result.Result<Demand, PatternInvalid> => {
40
+ if (rule.allow !== undefined) {
41
+ const allow = compilePatterns(rule.name, "allow", rule.allow);
42
+ if (Result.isFailure(allow)) return Result.fail(allow.failure);
43
+ return Result.succeed({ kind: "allow", allow: allow.success });
44
+ }
45
+ if (rule.convention !== undefined) {
46
+ const convention = compilePatterns(rule.name, "convention", rule.convention);
47
+ if (Result.isFailure(convention)) return Result.fail(convention.failure);
48
+ const [compiled] = convention.success;
49
+ return compiled === undefined
50
+ ? Result.succeed({ kind: "forbid" })
51
+ : Result.succeed({ kind: "convention", convention: compiled });
52
+ }
53
+ if (rule.count !== undefined) {
54
+ return Result.succeed({
55
+ kind: "count",
56
+ min: rule.count.min ?? 0,
57
+ max: rule.count.max ?? Number.POSITIVE_INFINITY,
58
+ });
59
+ }
60
+ return Result.succeed({ kind: "forbid" });
61
+ };
62
+
63
+ export const compileSurfaceRule = (
64
+ rule: SurfaceRule,
65
+ ): Result.Result<CompiledSurfaceRule, PatternInvalid> => {
66
+ const from = compilePatterns(rule.name, "from", rule.from);
67
+ if (Result.isFailure(from)) return Result.fail(from.failure);
68
+ const fromNot = compilePatterns(rule.name, "fromNot", rule.fromNot);
69
+ if (Result.isFailure(fromNot)) return Result.fail(fromNot.failure);
70
+ const match = compilePatterns(rule.name, "match", rule.match);
71
+ if (Result.isFailure(match)) return Result.fail(match.failure);
72
+ const matchNot = compilePatterns(rule.name, "matchNot", rule.matchNot);
73
+ if (Result.isFailure(matchNot)) return Result.fail(matchNot.failure);
74
+ const demand = demandOf(rule);
75
+ if (Result.isFailure(demand)) return Result.fail(demand.failure);
76
+
77
+ return Result.succeed({
78
+ name: rule.name,
79
+ message: rule.message,
80
+ probe: rule.probe,
81
+ from: from.success,
82
+ fromNot: fromNot.success,
83
+ kinds: rule.kinds === undefined ? null : [...rule.kinds],
84
+ declares: rule.declares === undefined ? null : [...rule.declares],
85
+ reexport: rule.reexport ?? null,
86
+ match: match.success,
87
+ matchNot: matchNot.success,
88
+ demand: demand.success,
89
+ });
90
+ };
91
+
92
+ export const compileSurfaceRules = (
93
+ rules: ReadonlyArray<SurfaceRule>,
94
+ ): Result.Result<ReadonlyArray<CompiledSurfaceRule>, PatternInvalid> => {
95
+ const compiled: Array<CompiledSurfaceRule> = [];
96
+ for (const rule of rules) {
97
+ const one = compileSurfaceRule(rule);
98
+ if (Result.isFailure(one)) return Result.fail(one.failure);
99
+ compiled.push(one.success);
100
+ }
101
+ return Result.succeed(compiled);
102
+ };
103
+
104
+ const anyMatches = (patterns: ReadonlyArray<RegExp>, value: string): boolean =>
105
+ patterns.some((pattern) => pattern.test(value));
106
+
107
+ export const surfaceRulesSelecting = (
108
+ rules: ReadonlyArray<CompiledSurfaceRule>,
109
+ file: string,
110
+ ): ReadonlyArray<CompiledSurfaceRule> =>
111
+ rules.filter((rule) => firstFromMatch(rule, file) !== null);
112
+
113
+ // Whether a site is one the rule speaks to at all.
114
+ const governs = (rule: CompiledSurfaceRule, site: ExportSite): boolean => {
115
+ if (rule.kinds !== null && !rule.kinds.includes(site.kind)) return false;
116
+ if (rule.declares !== null && !rule.declares.includes(site.declares)) return false;
117
+ if (rule.reexport !== null && rule.reexport !== site.reexport) return false;
118
+ if (rule.match.length > 0 && !anyMatches(rule.match, site.name)) return false;
119
+ if (rule.matchNot.length > 0 && anyMatches(rule.matchNot, site.name)) return false;
120
+ return true;
121
+ };
122
+
123
+ // A file's whole surface at once, because `count` is a statement about the
124
+ // file rather than about any one site. The other demands report per site, on
125
+ // the name that is actually restricted.
126
+ export const evaluateSurface = (
127
+ selected: ReadonlyArray<CompiledSurfaceRule>,
128
+ file: string,
129
+ sites: ReadonlyArray<ExportSite>,
130
+ ): ReadonlyArray<Violation> => {
131
+ const violations: Array<Violation> = [];
132
+ const report = (rule: CompiledSurfaceRule, subject: string | null): void => {
133
+ violations.push({ kind: "surface", ruleName: rule.name, message: rule.message, file, subject });
134
+ };
135
+
136
+ for (const rule of selected) {
137
+ const spoken = sites.filter((site) => governs(rule, site));
138
+ const demand = rule.demand;
139
+ switch (demand.kind) {
140
+ case "forbid":
141
+ for (const site of spoken) report(rule, site.name);
142
+ break;
143
+ case "allow":
144
+ for (const site of spoken) {
145
+ if (!anyMatches(demand.allow, site.name)) report(rule, site.name);
146
+ }
147
+ break;
148
+ case "convention":
149
+ for (const site of spoken) {
150
+ if (!demand.convention.test(site.name)) report(rule, site.name);
151
+ }
152
+ break;
153
+ case "count":
154
+ if (spoken.length < demand.min || spoken.length > demand.max) report(rule, null);
155
+ break;
156
+ }
157
+ }
158
+ return violations;
159
+ };
160
+
161
+ // The probe is a whole surface — a list of sites, or a source the parser reads
162
+ // them out of — because that is what the rule is evaluated against. A rule that
163
+ // reports nothing for its own probe enforces nothing.
164
+ export const surfaceRulesFailingTheirProbe = (
165
+ rules: ReadonlyArray<CompiledSurfaceRule>,
166
+ extractor: FactExtractor,
167
+ ): ReadonlyArray<CompiledSurfaceRule> =>
168
+ rules.filter((rule) => {
169
+ if (firstFromMatch(rule, rule.probe.from) === null) return true;
170
+ const sites: ReadonlyArray<ExportSite> =
171
+ rule.probe.source === undefined
172
+ ? (rule.probe.sites ?? []).map((site) => ({
173
+ file: rule.probe.from,
174
+ name: site.name,
175
+ kind: site.kind,
176
+ declares: site.declares ?? "other",
177
+ reexport: site.reexport ?? false,
178
+ }))
179
+ : extractor.factsOf(rule.probe.from, rule.probe.source).exportSites;
180
+ return evaluateSurface([rule], rule.probe.from, sites).length === 0;
181
+ });