@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,706 @@
1
+ import { OPEN_LAYOUT, } from "../domain/architecture-config.js";
2
+ import { anchored, globToRegexSource, prefixed } from "./glob.js";
3
+ import { globsOf, } from "./manifest.js";
4
+ const FOLDER_KEY = /\/$/;
5
+ // The marker an open folder's allowlist carries: it admits any name, so it has
6
+ // no layout policy to prove.
7
+ export const ANY_FILE = OPEN_LAYOUT;
8
+ // The `from` side of a rule that applies to every file, wherever the repository
9
+ // happens to keep its packages.
10
+ const EVERY_FILE = "";
11
+ // A key may name several patterns that share one node: the four `*-ops`
12
+ // stereotypes carry identical policy, and saying it once is the point of writing
13
+ // the architecture as a tree.
14
+ const ALTERNATIVE = /\s*\|\s*/;
15
+ const isFolderKey = (key) => FOLDER_KEY.test(key);
16
+ const stripSlash = (key) => key.replace(FOLDER_KEY, "");
17
+ // Split first, then strip: `"http/ | cli/"` carries a trailing slash on every
18
+ // alternative, not only the last one.
19
+ const alternativesOf = (key) => key
20
+ .split(ALTERNATIVE)
21
+ .map(stripSlash)
22
+ .filter((one) => one !== "");
23
+ const expandAliases = (glob, aliases) => {
24
+ for (const [alias, target] of Object.entries(aliases)) {
25
+ if (glob === alias)
26
+ return target;
27
+ if (glob.startsWith(`${alias}/`))
28
+ return target + glob.slice(alias.length);
29
+ }
30
+ return glob;
31
+ };
32
+ // A probe is the node's own path with its wildcards filled in, so a rule is
33
+ // proven against the shape it was written for and nobody hand-writes one.
34
+ const PROBE_WORDS = ["alpha", "beta", "gamma", "delta"];
35
+ const probePathOf = (pathGlob, leaf) => {
36
+ let word = 0;
37
+ const filled = pathGlob
38
+ .replace(/\{[a-zA-Z][a-zA-Z0-9]*\}/g, () => {
39
+ const value = PROBE_WORDS[word % PROBE_WORDS.length] ?? "alpha";
40
+ word += 1;
41
+ return value;
42
+ })
43
+ .replace(/\*\*/g, "deep")
44
+ .replace(/\*/g, "zz");
45
+ if (leaf === "")
46
+ return filled;
47
+ return filled === "" ? leaf : `${filled}/${leaf}`;
48
+ };
49
+ // The probe name must satisfy the rule's own `match`, or the rule cannot report
50
+ // it and the vacuity check fires on a healthy rule.
51
+ const probeMemberName = (match) => {
52
+ if (match === undefined)
53
+ return "zzProbeMember";
54
+ const first = globsOf(match)[0] ?? "";
55
+ return `${first
56
+ // A character class stands for one character, so the probe uses the first
57
+ // one it admits: `use[A-Z]*` has to be proven with `useA…`, not `use[A-Z]…`.
58
+ .replace(/\[\^?([^\]])[^\]]*\]/g, "$1")
59
+ .replace(/\*/g, "")}ZzProbe`;
60
+ };
61
+ // Each convention pairs the shape a name must have with a name that does not
62
+ // have it, so the rule's probe is generated rather than written.
63
+ const CONVENTIONS = {
64
+ "kebab-case": {
65
+ source: "^[a-z0-9]+(?:-[a-z0-9]+)*$",
66
+ violating: "zzProbeStray",
67
+ shape: "lowercase words joined by hyphens",
68
+ },
69
+ camelCase: {
70
+ source: "^[a-z][a-zA-Z0-9]*$",
71
+ violating: "zz-probe-stray",
72
+ shape: "a lowercase first word, then capitalised ones, with no separators",
73
+ },
74
+ PascalCase: {
75
+ source: "^[A-Z][a-zA-Z0-9]*$",
76
+ violating: "zz-probe-stray",
77
+ shape: "capitalised words with no separators",
78
+ },
79
+ snake_case: {
80
+ source: "^[a-z0-9]+(?:_[a-z0-9]+)*$",
81
+ violating: "zzProbeStray",
82
+ shape: "lowercase words joined by underscores",
83
+ },
84
+ };
85
+ // The folder's file list already enforces the stereotype suffix; a naming rule
86
+ // is about the concept name in front of it, so the message says which part it
87
+ // is talking about.
88
+ const namingMessageOf = (spec, subject) => {
89
+ const what = subject === "folder" ? "This folder's name" : "The concept name in front of the stereotype";
90
+ if (typeof spec === "string") {
91
+ return `${what} is ${spec} here — ${CONVENTIONS[spec]?.shape ?? ""}.`;
92
+ }
93
+ if (spec.message !== undefined)
94
+ return spec.message;
95
+ if ("like" in spec) {
96
+ return "A file here is named after its folder, so its concept name is the folder's own.";
97
+ }
98
+ return `${what} matches /${spec.regex}/ here.`;
99
+ };
100
+ // A custom convention still owes a counter-example. If none of these fails it,
101
+ // the pattern admits every name and the rule could never report anything —
102
+ // which is the vacuity this package refuses to load.
103
+ const VIOLATING_CANDIDATES = ["zzProbeStray", "zz-probe-stray", "ZZ_PROBE_STRAY", "zz probe.stray"];
104
+ const violatingSampleFor = (spec, ruleName) => {
105
+ if (typeof spec === "string") {
106
+ const convention = CONVENTIONS[spec];
107
+ if (convention === undefined)
108
+ throw new Error(`unknown naming convention "${spec}"`);
109
+ return convention.violating;
110
+ }
111
+ if ("like" in spec)
112
+ return "zzprobestray";
113
+ const matcher = new RegExp(spec.regex);
114
+ const found = VIOLATING_CANDIDATES.find((candidate) => !matcher.test(candidate));
115
+ if (found === undefined) {
116
+ throw new Error(`naming rule "${ruleName}" states /${spec.regex}/, which admits every name this ` +
117
+ `compiler can think of. A convention nothing can violate is a rule that never reports.`);
118
+ }
119
+ return found;
120
+ };
121
+ // The probe is the node's own probe path with the subject replaced by a name the
122
+ // convention rejects — located by matching, so the compiler never has to reason
123
+ // about which segment of a glob the subject came from.
124
+ const namingProbeOf = (patternSource, subject, probe, violating) => {
125
+ const found = new RegExp(patternSource, "d").exec(probe);
126
+ const span = found?.indices?.[subject];
127
+ if (span === undefined)
128
+ return probe;
129
+ return probe.slice(0, span[0]) + violating + probe.slice(span[1]);
130
+ };
131
+ const mergeImports = (frame, spec, aliases, captures, nextGroup) => {
132
+ if (spec === undefined) {
133
+ return {
134
+ allow: frame.allow,
135
+ externals: frame.externals,
136
+ deny: [],
137
+ importsMessage: frame.importsMessage,
138
+ };
139
+ }
140
+ const compileAllow = (glob) => prefixed(globToRegexSource(expandAliases(glob, aliases), captures, { declaring: false, nextGroup })
141
+ .source);
142
+ const own = globsOf(spec.allow ?? []).map(compileAllow);
143
+ const external = spec.external ?? [];
144
+ const deny = (spec.deny ?? []).flatMap((entry) => globsOf(entry.match).map((glob) => ({
145
+ match: compileAllow(glob),
146
+ matchNot: globsOf(entry.matchNot ?? []).map(compileAllow),
147
+ except: globsOf(entry.except ?? []).map(compileAllow),
148
+ message: entry.message,
149
+ probe: probePathOf(expandAliases(glob, aliases), ""),
150
+ })));
151
+ // `reset` drops inherited ALLOWANCES only. A prohibition always accumulates, so
152
+ // resetting can never make a subtree quieter than its ancestors — the direction
153
+ // a mistake here would be dangerous in.
154
+ const dropping = spec.reset === true || spec.unrestricted === true;
155
+ return {
156
+ allow: dropping ? own : [...frame.allow, ...own],
157
+ externals: dropping ? external : [...frame.externals, ...external],
158
+ // Only what this node declares. A prohibition is emitted once, over its whole
159
+ // subtree, so descendants neither re-emit it nor can escape it — which is
160
+ // what makes `reset` structurally unable to make a subtree quieter.
161
+ deny,
162
+ importsMessage: spec.message ?? frame.importsMessage,
163
+ };
164
+ };
165
+ export const lowerManifest = (manifest, languages = []) => {
166
+ const aliases = manifest.aliases ?? {};
167
+ // The extension a synthetic probe file carries: the first extension of the
168
+ // language whose scope covers the probe's folder. A probe is matched by its
169
+ // folder, so the extension only makes it a file that language would have —
170
+ // which matters exactly when an allowlist admits `**/*.<ext>`, and would
171
+ // otherwise pass its probe while admitting every file.
172
+ const extensionFor = (folder) => {
173
+ const scope = manifest.resolve.scopes.find((one) => new RegExp(one.files).test(`${folder}/zzprobe`));
174
+ const language = languages.find((one) => one.id === scope?.language);
175
+ return language?.extensions[0] ?? "";
176
+ };
177
+ // A synthetic file inside a folder glob: the folder with its wildcards filled,
178
+ // then the stem, then whatever extension the folder's language uses.
179
+ const probeIn = (folderGlob, stem) => probePathOf(folderGlob, `${stem}${extensionFor(probePathOf(folderGlob, ""))}`);
180
+ // A synthetic file somewhere no node governs, written like a file of the
181
+ // language at `likeFolder` so that it is refused by a rule about that
182
+ // language's files and not merely by one about its own folder.
183
+ const probeOutside = (stem, likeFolder = "packages/zzprobe") => `packages/zzprobe/${stem}${extensionFor(likeFolder)}`;
184
+ // The folder layout rule must be proven with a basename the folder rejects.
185
+ // What that is depends on what the folder admits: a stray with the language's
186
+ // own extension is a file a folder admitting every such file is happy with.
187
+ const strayBasenameFor = (admitted, extension, ruleName) => {
188
+ const candidates = [`zzprobe-stray${extension}`, "zzprobe-stray", "zzprobe.stray.zz"];
189
+ const rejected = candidates.find((candidate) => !admitted.some((pattern) => new RegExp(pattern).test(candidate)));
190
+ if (rejected === undefined) {
191
+ throw new Error(`"${ruleName}" admits every file name this compiler can think of, so it enumerates ` +
192
+ `nothing. A folder that does not police its file names is \`layout: "open"\`; say so.`);
193
+ }
194
+ return rejected;
195
+ };
196
+ const imports = [];
197
+ const exports = [];
198
+ const members = [];
199
+ const surface = [];
200
+ const unrestrictedNodes = [];
201
+ const partialNodes = [];
202
+ const roots = [];
203
+ const folders = [];
204
+ const parity = [];
205
+ const namingRules = [];
206
+ const walk = (key, node, parent, name, siblings) => {
207
+ const literalSiblings = siblings
208
+ .filter((sibling) => sibling !== key)
209
+ .flatMap(alternativesOf)
210
+ .filter((sibling) => !/[*{]/.test(sibling));
211
+ const alternatives = alternativesOf(key).map((one) => expandAliases(one, aliases));
212
+ const [first = ""] = alternatives;
213
+ if (node.partial === true)
214
+ partialNodes.push(name);
215
+ if (node.imports?.unrestricted === true)
216
+ unrestrictedNodes.push(name);
217
+ if (alternatives.length > 1 && alternatives.some((one) => one.includes("{"))) {
218
+ throw new Error(`key "${key}" both names several patterns and declares a capture. A capture has to come ` +
219
+ `from one place, so give the capturing pattern its own key.`);
220
+ }
221
+ // The probe, the path and every descendant hang off the first alternative;
222
+ // the rest only widen what the pattern matches.
223
+ const joinedGlob = parent.pathGlob === "" ? first : `${parent.pathGlob}/${first}`;
224
+ const compiledFirst = globToRegexSource(first, parent.captures, {
225
+ declaring: true,
226
+ nextGroup: parent.nextGroup,
227
+ });
228
+ const compiled = alternatives.length === 1
229
+ ? compiledFirst
230
+ : {
231
+ captures: compiledFirst.captures,
232
+ source: `(?:${alternatives
233
+ .map((one) => globToRegexSource(one, parent.captures, {
234
+ declaring: false,
235
+ nextGroup: parent.nextGroup,
236
+ }).source)
237
+ .join("|")})`,
238
+ };
239
+ // `(?!domain-services$|ports$)` — written by the compiler, from the fact that
240
+ // those keys are siblings of this one.
241
+ const guarded = literalSiblings.length > 0 && /[*{]/.test(first)
242
+ ? `(?!(?:${literalSiblings.map((one) => one.replace(/[.+^$()|[\]\\*?{}]/g, "\\$&")).join("|")})(?:/|$))${compiled.source}`
243
+ : compiled.source;
244
+ const pathSource = parent.pathSource === "" ? guarded : `${parent.pathSource}/${guarded}`;
245
+ const nextGroup = parent.nextGroup +
246
+ (Object.keys(compiled.captures).length - Object.keys(parent.captures).length);
247
+ const merged = mergeImports(parent, node.imports, aliases, compiled.captures, nextGroup);
248
+ const ownDenials = merged.deny;
249
+ const frame = {
250
+ pathSource,
251
+ pathGlob: joinedGlob,
252
+ captures: compiled.captures,
253
+ nextGroup,
254
+ allow: merged.allow,
255
+ externals: merged.externals,
256
+ importsMessage: merged.importsMessage,
257
+ naming: node.name ?? parent.naming,
258
+ };
259
+ const isFolder = isFolderKey(key) || node.children !== undefined;
260
+ const selfPattern = anchored(pathSource);
261
+ // Naming, in two shapes. A folder judges its own segment (when its key
262
+ // declares a capture) and the concept name of every file directly inside
263
+ // it; a file node judges what its own `*` matched, which is where "named
264
+ // after its folder" lives.
265
+ //
266
+ // A file's concept name is its basename up to the FIRST dot, not what a `*`
267
+ // matched: the key `*-live.<ext>` matches `todos.repository-live.<ext>`, whose
268
+ // wildcard spans a stereotype segment as well as the concept.
269
+ const naming = frame.naming;
270
+ if (naming !== undefined) {
271
+ const declaredHere = Object.keys(compiled.captures).filter((one) => parent.captures[one] === undefined);
272
+ const lastDeclared = declaredHere[declaredHere.length - 1];
273
+ const isLike = typeof naming === "object" && "like" in naming;
274
+ const emit = (ruleName, patterns, subject, probe, sameAs, judging = "file") => {
275
+ namingRules.push({
276
+ name: ruleName,
277
+ message: namingMessageOf(naming, judging),
278
+ probe: {
279
+ path: sameAs === undefined
280
+ ? namingProbeOf(patterns[0] ?? "", subject, probe, violatingSampleFor(naming, ruleName))
281
+ : probe,
282
+ },
283
+ file: patterns,
284
+ subject,
285
+ ...(sameAs === undefined
286
+ ? {
287
+ convention: typeof naming === "string"
288
+ ? (CONVENTIONS[naming]?.source ?? "")
289
+ : "regex" in naming
290
+ ? naming.regex
291
+ : "",
292
+ }
293
+ : { sameAs }),
294
+ });
295
+ };
296
+ if (isFolder && !isLike) {
297
+ if (lastDeclared !== undefined) {
298
+ const subject = compiled.captures[lastDeclared];
299
+ if (subject !== undefined) {
300
+ emit(`${name}/naming-folder`, [prefixed(`${pathSource}/`)], subject, probeIn(joinedGlob, "zzprobe"), undefined, "folder");
301
+ }
302
+ }
303
+ emit(`${name}/naming`, [anchored(`${pathSource}/([^/.]+)[^/]*`)], nextGroup, probeIn(joinedGlob, "zzprobe"));
304
+ }
305
+ if (!isFolder && isLike) {
306
+ const namingCompiled = alternatives.map((one) => globToRegexSource(one, parent.captures, {
307
+ declaring: true,
308
+ nextGroup: parent.nextGroup,
309
+ capturing: true,
310
+ }));
311
+ const [firstNaming] = namingCompiled;
312
+ const subject = firstNaming?.wildcards[firstNaming.wildcards.length - 1];
313
+ const sameAs = typeof naming === "object" && "like" in naming
314
+ ? parent.captures[naming.like.replace(/[{}]/g, "")]
315
+ : undefined;
316
+ if (typeof naming === "object" && "like" in naming && sameAs === undefined) {
317
+ throw new Error(`naming at "${key}" is like ${naming.like}, which no ancestor path declares.`);
318
+ }
319
+ if (subject !== undefined && sameAs !== undefined) {
320
+ emit(`${name}/naming`, namingCompiled.map((one) => anchored(parent.pathSource === "" ? one.source : `${parent.pathSource}/${one.source}`)), subject, probePathOf(joinedGlob, ""), sameAs);
321
+ }
322
+ }
323
+ }
324
+ const childEntries = Object.entries(node.children ?? {});
325
+ // The nearest descendants — at any depth — that state their own import
326
+ // policy. A folder's allowlist covers its whole subtree except these, and
327
+ // each of them emits a rule over its own subtree in turn. Descent stops at a
328
+ // node that overrides, because everything below it is that node's business.
329
+ const overridingDescendants = (from, atPath, atCaptures, atGroup) => Object.entries(from.children ?? {}).flatMap(([childKey, child]) => alternativesOf(childKey).flatMap((one) => {
330
+ // `declaring` because a folder key may itself name a capture
331
+ // (`{subdomain}/`). The extra group is harmless in an exclusion, which
332
+ // is matched on its own rather than substituted into.
333
+ const childCompiled = globToRegexSource(one, atCaptures, {
334
+ declaring: true,
335
+ nextGroup: atGroup,
336
+ });
337
+ const source = `${atPath}/${childCompiled.source}`;
338
+ const childGroup = atGroup + (Object.keys(childCompiled.captures).length - Object.keys(atCaptures).length);
339
+ if (child.imports !== undefined) {
340
+ return [isFolderKey(childKey) ? prefixed(`${source}/`) : anchored(source)];
341
+ }
342
+ return isFolderKey(childKey)
343
+ ? overridingDescendants(child, source, childCompiled.captures, childGroup)
344
+ : [];
345
+ }));
346
+ const overridingChildren = overridingDescendants(node, pathSource, compiled.captures, nextGroup);
347
+ if (isFolder) {
348
+ const childKeys = childEntries;
349
+ const fileKeys = childKeys.filter(([childKey]) => !isFolderKey(childKey));
350
+ if (node.partial !== true) {
351
+ const ruleName = `${name}/layout`;
352
+ // An open folder still CLAIMS its folder — otherwise no rule governs
353
+ // it and the taxonomy root fires — it just admits any file name.
354
+ const admitted = node.layout === "open"
355
+ ? [ANY_FILE]
356
+ : fileKeys.flatMap(([childKey]) => alternativesOf(childKey).map((one) => anchored(globToRegexSource(one, compiled.captures, { declaring: false, nextGroup })
357
+ .source)));
358
+ const folderProbe = probePathOf(joinedGlob, "");
359
+ const stray = node.layout === "open"
360
+ ? `zzprobe-stray${extensionFor(folderProbe)}`
361
+ : strayBasenameFor(admitted, extensionFor(folderProbe), ruleName);
362
+ folders.push({
363
+ name: ruleName,
364
+ message: node.message ?? "This folder does not admit that file.",
365
+ probe: { path: probePathOf(joinedGlob, stray) },
366
+ folder: selfPattern,
367
+ files: admitted,
368
+ });
369
+ }
370
+ const siblingKeys = childKeys.map(([childKey]) => childKey);
371
+ for (const [childKey, child] of childKeys) {
372
+ walk(childKey, child, frame, `${name}/${alternativesOf(childKey)[0] ?? ""}`, siblingKeys);
373
+ }
374
+ }
375
+ // Outbound. One allowlist stands in for every "may not reach X" rule that
376
+ // would otherwise be written separately and far from here.
377
+ //
378
+ // Import policy belongs to a folder, so it lowers once per folder — matching
379
+ // that folder's direct children — rather than once per file kind inside it.
380
+ // A file node emits its own only when it says something its folder did not.
381
+ const emitsOwnImports = isFolder ? node.imports !== undefined : node.imports !== undefined;
382
+ const scope = isFolder ? prefixed(`${pathSource}/`) : selfPattern;
383
+ const scopeProbe = isFolder ? probeIn(joinedGlob, "zzprobe") : probePathOf(joinedGlob, "");
384
+ // The folder a probe of this node sits in, for "a file of the same language".
385
+ const ownFolder = isFolder
386
+ ? probePathOf(joinedGlob, "")
387
+ : probePathOf(joinedGlob, "").replace(/\/[^/]*$/, "");
388
+ const admitsEverything = frame.allow.some((pattern) => pattern === "^.*" || pattern === "^");
389
+ const hasAllowlist = (frame.allow.length > 0 || frame.externals.length > 0) && !admitsEverything;
390
+ if (emitsOwnImports && node.imports?.unrestricted !== true && !hasAllowlist) {
391
+ throw new Error(`"${name}" states an \`imports\` policy with no allowlist. If that is deliberate — the ` +
392
+ `tier is not tightened yet and only its prohibitions apply — say \`unrestricted: true\`, ` +
393
+ `so the gap is a sentence someone wrote rather than an omission nobody noticed.`);
394
+ }
395
+ if (emitsOwnImports) {
396
+ const exemptions = overridingChildren.length > 0 ? { fromNot: overridingChildren } : {};
397
+ if (hasAllowlist) {
398
+ imports.push({
399
+ name: `${name}/imports`,
400
+ message: frame.importsMessage,
401
+ probe: { from: scopeProbe, to: probeOutside("nowhere", ownFolder) },
402
+ from: scope,
403
+ ...exemptions,
404
+ toNot: [...frame.allow],
405
+ ...(frame.externals.length > 0 ? { externals: [...frame.externals] } : {}),
406
+ });
407
+ }
408
+ }
409
+ // Prohibitions are emitted once, over this node's whole subtree, and carry no
410
+ // exemptions: a node cannot opt out of an ancestor's prohibition.
411
+ for (const [index, denial] of ownDenials.entries()) {
412
+ imports.push({
413
+ name: `${name}/deny-${String(index)}`,
414
+ message: denial.message,
415
+ probe: { from: scopeProbe, to: denial.probe },
416
+ from: isFolder ? prefixed(`${pathSource}/`) : selfPattern,
417
+ ...(denial.except.length > 0 ? { fromNot: [...denial.except] } : {}),
418
+ to: denial.match,
419
+ ...(denial.matchNot.length > 0 ? { toNot: [...denial.matchNot] } : {}),
420
+ });
421
+ }
422
+ // Inbound. "This file is private to X" belongs beside the file, not in a
423
+ // distant rule whose `from` side grows an exclusion for every new caller.
424
+ if (node.importedBy !== undefined) {
425
+ // An `importedBy` allowlist is matched against the IMPORTER, while the
426
+ // captures on this node were declared by the TARGET's path — so a
427
+ // `{capture}` here has nothing to resolve against and would compile to a
428
+ // pattern that never matches, silently over-reporting. Refuse it rather
429
+ // than emit a rule whose exemptions do not work.
430
+ for (const allowed of globsOf(node.importedBy.allow)) {
431
+ const referenced = allowed.match(/\{[a-zA-Z][a-zA-Z0-9]*\}/g) ?? [];
432
+ if (referenced.length > 0) {
433
+ throw new Error(`"${name}" allows ${referenced.join(", ")} in importedBy, but a capture from this ` +
434
+ `node's own path cannot be used there: importedBy patterns are matched against the ` +
435
+ `importing file, and ${referenced[0] ?? ""} was declared by this file's path. Use a ` +
436
+ `wildcard (the barrel rules are what stop another module reaching in), or move the ` +
437
+ `restriction to that importer's own node as an \`imports\` allowlist.`);
438
+ }
439
+ }
440
+ const asTarget = (glob) => prefixed(globToRegexSource(expandAliases(glob, aliases), compiled.captures, {
441
+ declaring: false,
442
+ nextGroup,
443
+ }).source);
444
+ // On a folder the restriction covers the whole subtree — "a module is
445
+ // private" is a statement about everything under it, not about the folder
446
+ // node itself.
447
+ const exempt = globsOf(node.importedBy.matchNot ?? []).map((glob) => anchored(`${pathSource}/${globToRegexSource(glob, compiled.captures, { declaring: false, nextGroup }).source}`));
448
+ imports.push({
449
+ name: `${name}/imported-by`,
450
+ message: node.importedBy.message,
451
+ probe: { from: probeOutside("outsider", ownFolder), to: scopeProbe },
452
+ from: EVERY_FILE,
453
+ fromNot: globsOf(node.importedBy.allow).map(asTarget),
454
+ to: isFolder ? prefixed(`${pathSource}/`) : selfPattern,
455
+ ...(exempt.length > 0 ? { toNot: exempt } : {}),
456
+ });
457
+ }
458
+ for (const [index, spec] of (node.members ?? []).entries()) {
459
+ const asRegex = (globs) => globsOf(globs).map((one) => anchored(globToRegexSource(one, compiled.captures, { declaring: false, nextGroup }).source));
460
+ members.push({
461
+ name: `${name}/members-${String(index)}`,
462
+ message: spec.message,
463
+ // An authored probe replaces the synthetic site: the name is the one
464
+ // the author says the snippet declares, and the declaration is the
465
+ // parser's to find.
466
+ probe: spec.probe === undefined
467
+ ? {
468
+ from: scopeProbe,
469
+ name: probeMemberName(spec.match),
470
+ ...(spec.in === undefined ? {} : { in: "ZzProbeRepositoryShape" }),
471
+ ...(spec.declares?.[0] === undefined ? {} : { declares: spec.declares[0] }),
472
+ }
473
+ : { from: scopeProbe, name: spec.probe.name, source: spec.probe.source },
474
+ // On a folder the rule covers the subtree, as `imports` does — a
475
+ // vocabulary is a statement about every file in a tier. Selecting the
476
+ // folder's own path instead governed no file, and the probe, a folder
477
+ // path, passed regardless.
478
+ from: scope,
479
+ subject: spec.subject,
480
+ ...(spec.in === undefined ? {} : { in: asRegex(spec.in) }),
481
+ ...(spec.declares === undefined ? {} : { declares: [...spec.declares] }),
482
+ ...(spec.match === undefined ? {} : { match: asRegex(spec.match) }),
483
+ ...(spec.matchNot === undefined ? {} : { matchNot: asRegex(spec.matchNot) }),
484
+ ...(spec.allow === undefined ? {} : { allow: asRegex(spec.allow) }),
485
+ });
486
+ }
487
+ for (const [index, spec] of (node.surface ?? []).entries()) {
488
+ const ruleName = `${name}/surface-${String(index)}`;
489
+ const asName = (globs) => globsOf(globs).map((one) => anchored(globToRegexSource(one, compiled.captures, { declaring: false, nextGroup }).source));
490
+ const asPath = (glob) => prefixed(globToRegexSource(expandAliases(glob, aliases), compiled.captures, {
491
+ declaring: false,
492
+ nextGroup,
493
+ }).source);
494
+ const demands = [spec.allow, spec.convention, spec.count].filter((one) => one !== undefined).length;
495
+ if (demands > 1) {
496
+ throw new Error(`surface rule "${ruleName}" states more than one of allow, convention and count. ` +
497
+ `A rule makes one demand; write one entry per demand.`);
498
+ }
499
+ const kind = spec.kinds?.[0] ?? "named";
500
+ const conventionSource = spec.convention === undefined
501
+ ? undefined
502
+ : typeof spec.convention === "string"
503
+ ? CONVENTIONS[spec.convention]?.source
504
+ : spec.convention.regex;
505
+ // The synthetic probe: one site the rule must reject, or for `count`, a
506
+ // surface of the wrong size. A count nothing can violate — no minimum, no
507
+ // maximum — is a rule that never reports, and is refused here.
508
+ const siteName = kind === "default"
509
+ ? "default"
510
+ : kind === "namespace"
511
+ ? "*"
512
+ : spec.convention !== undefined
513
+ ? violatingSampleFor(spec.convention, ruleName)
514
+ : probeMemberName(spec.match);
515
+ const oneSite = {
516
+ name: siteName,
517
+ kind,
518
+ ...(spec.declares?.[0] === undefined ? {} : { declares: spec.declares[0] }),
519
+ ...(spec.reexport === undefined ? {} : { reexport: spec.reexport }),
520
+ };
521
+ const probeSites = () => {
522
+ if (spec.count === undefined)
523
+ return [oneSite];
524
+ const min = spec.count.min ?? 0;
525
+ if (min > 0)
526
+ return [];
527
+ if (spec.count.max === undefined) {
528
+ throw new Error(`surface rule "${ruleName}" states a count with no minimum and no maximum, ` +
529
+ `which nothing can violate.`);
530
+ }
531
+ return Array.from({ length: spec.count.max + 1 }, (_, i) => ({
532
+ ...oneSite,
533
+ name: `${siteName}${String(i)}`,
534
+ }));
535
+ };
536
+ surface.push({
537
+ name: ruleName,
538
+ message: spec.message,
539
+ probe: spec.probe === undefined
540
+ ? { from: scopeProbe, sites: probeSites() }
541
+ : { from: scopeProbe, source: spec.probe.source },
542
+ from: scope,
543
+ ...(spec.except === undefined ? {} : { fromNot: globsOf(spec.except).map(asPath) }),
544
+ ...(spec.kinds === undefined ? {} : { kinds: [...spec.kinds] }),
545
+ ...(spec.declares === undefined ? {} : { declares: [...spec.declares] }),
546
+ ...(spec.reexport === undefined ? {} : { reexport: spec.reexport }),
547
+ ...(spec.match === undefined ? {} : { match: asName(spec.match) }),
548
+ ...(spec.matchNot === undefined ? {} : { matchNot: asName(spec.matchNot) }),
549
+ ...(spec.forbid === undefined ? {} : { forbid: spec.forbid }),
550
+ ...(spec.allow === undefined ? {} : { allow: asName(spec.allow) }),
551
+ ...(conventionSource === undefined ? {} : { convention: conventionSource }),
552
+ ...(spec.count === undefined ? {} : { count: spec.count }),
553
+ });
554
+ }
555
+ if (node.requires !== undefined && node.requires.length > 0) {
556
+ const exempt = (node.requiresNot ?? []).map((basename) => `/${globToRegexSource(basename, compiled.captures, { declaring: false, nextGroup }).source}$`);
557
+ parity.push({
558
+ name: `${name}/requires`,
559
+ message: node.message ?? "This file needs its sibling.",
560
+ probe: { path: probePathOf(joinedGlob, "") },
561
+ file: selfPattern,
562
+ ...(exempt.length > 0 ? { fileNot: exempt } : {}),
563
+ requires: [...node.requires],
564
+ });
565
+ }
566
+ };
567
+ const emptyFrame = {
568
+ pathSource: "",
569
+ pathGlob: "",
570
+ captures: {},
571
+ nextGroup: 1,
572
+ allow: [],
573
+ externals: [],
574
+ importsMessage: "This import is not on this folder's allowlist.",
575
+ naming: undefined,
576
+ };
577
+ // Repo-wide prohibitions: `from` is every file, so no tier can be written
578
+ // that escapes them.
579
+ const globalFrame = { ...emptyFrame };
580
+ for (const [index, denial] of mergeImports(globalFrame, { unrestricted: true, deny: manifest.deny ?? [] }, aliases, {}, 1).deny.entries()) {
581
+ imports.push({
582
+ name: `repo/deny-${String(index)}`,
583
+ message: denial.message,
584
+ probe: { from: probeOutside("anywhere"), to: denial.probe },
585
+ from: EVERY_FILE,
586
+ ...(denial.except.length > 0 ? { fromNot: [...denial.except] } : {}),
587
+ to: denial.match,
588
+ ...(denial.matchNot.length > 0 ? { toNot: [...denial.matchNot] } : {}),
589
+ });
590
+ }
591
+ for (const [key, node] of Object.entries(manifest.tree)) {
592
+ // A taxonomy root is a region whose folders are enumerated. A key naming a
593
+ // single file has no folders to deny, and an open tree governs every folder
594
+ // it contains — neither has anything for a root to catch.
595
+ if ((isFolderKey(key) || node.children !== undefined) && node.layout !== "open")
596
+ roots.push({
597
+ name: `${stripSlash(key)}/taxonomy`,
598
+ message: node.message ??
599
+ "This folder is not part of the taxonomy. Declare it in the manifest deliberately, or move the file into the folder that owns it.",
600
+ probe: { path: probeIn(`${expandAliases(stripSlash(key), aliases)}/zzprobe`, "stray") },
601
+ path: prefixed(globToRegexSource(expandAliases(stripSlash(key), aliases), {}, {
602
+ declaring: true,
603
+ nextGroup: 1,
604
+ }).source),
605
+ });
606
+ walk(key, node, emptyFrame, stripSlash(key)
607
+ .replace(/[^a-zA-Z0-9]+/g, "-")
608
+ .replace(/^-|-$/g, ""), Object.keys(manifest.tree));
609
+ }
610
+ for (const rule of manifest.exports ?? []) {
611
+ const asPattern = (glob) => prefixed(globToRegexSource(expandAliases(glob, aliases), {}, { declaring: false, nextGroup: 1 })
612
+ .source);
613
+ // The synthetic probe is a binding of the rule's first kind. A default
614
+ // binding is only ever named `default` and a namespace one `*`, so a rule
615
+ // that lists `symbols` alongside those kinds cannot cover its probe — and
616
+ // is refused at load, which is right: it could never fire on such a form.
617
+ const kind = rule.kinds?.[0] ?? "named";
618
+ const symbol = rule.probe?.symbol ??
619
+ (kind === "namespace" ? "*" : kind === "default" ? "default" : rule.symbols?.[0]) ??
620
+ "zzProbeSymbol";
621
+ exports.push({
622
+ name: rule.name,
623
+ message: rule.message,
624
+ probe: {
625
+ from: probeOutside("anywhere"),
626
+ to: probePathOf(expandAliases(globsOf(rule.module)[0] ?? "", aliases), ""),
627
+ symbol,
628
+ kind,
629
+ ...(rule.probe === undefined ? {} : { source: rule.probe.source }),
630
+ },
631
+ from: EVERY_FILE,
632
+ ...(rule.except === undefined ? {} : { fromNot: globsOf(rule.except).map(asPattern) }),
633
+ to: globsOf(rule.module).map(asPattern),
634
+ ...(rule.symbols === undefined ? {} : { symbols: [...rule.symbols] }),
635
+ ...(rule.kinds === undefined ? {} : { kinds: [...rule.kinds] }),
636
+ ...(rule.fix === undefined ? {} : { fix: rule.fix }),
637
+ });
638
+ }
639
+ // Graph rules pass through with their globs resolved, and a probe built from
640
+ // the shape each is about: two files importing each other, a file nothing
641
+ // imports, a direct edge from `from` to `to` that touches no `via`.
642
+ const asGraphPattern = (glob) => prefixed(globToRegexSource(expandAliases(glob, aliases), {}, { declaring: false, nextGroup: 1 })
643
+ .source);
644
+ const asGraphPatterns = (globs) => globs === undefined ? undefined : globsOf(globs).map(asGraphPattern);
645
+ // A synthetic file inside a graph scope. A glob whose last segment names a
646
+ // file shape (`src/**/*.<ext>`) is filled in place and keeps its extension; a
647
+ // folder glob (`src/**`) gets a file of its language's added beneath it.
648
+ const probeFileIn = (globs, stem) => {
649
+ const glob = expandAliases(globsOf(globs)[0] ?? "", aliases);
650
+ const at = glob.lastIndexOf("/");
651
+ const last = glob.slice(at + 1);
652
+ if (!last.includes("."))
653
+ return probeIn(glob, stem);
654
+ return probePathOf(at === -1 ? "" : glob.slice(0, at), last.replace(/\{[a-zA-Z][a-zA-Z0-9]*\}|\*+/g, stem));
655
+ };
656
+ const graph = {
657
+ cycles: (manifest.graph?.cycles ?? []).map((rule) => {
658
+ const a = probeFileIn(rule.within, "zz-alpha");
659
+ const b = probeFileIn(rule.within, "zz-beta");
660
+ return {
661
+ name: rule.name,
662
+ message: rule.message,
663
+ probe: {
664
+ edges: [
665
+ [a, b],
666
+ [b, a],
667
+ ],
668
+ },
669
+ within: globsOf(rule.within).map(asGraphPattern),
670
+ ...(rule.withinNot === undefined
671
+ ? {}
672
+ : { withinNot: asGraphPatterns(rule.withinNot) ?? [] }),
673
+ };
674
+ }),
675
+ orphans: (manifest.graph?.orphans ?? []).map((rule) => ({
676
+ name: rule.name,
677
+ message: rule.message,
678
+ probe: { edges: [], files: [probeFileIn(rule.within, "zz-orphan")] },
679
+ within: globsOf(rule.within).map(asGraphPattern),
680
+ ...(rule.withinNot === undefined ? {} : { withinNot: asGraphPatterns(rule.withinNot) ?? [] }),
681
+ entry: globsOf(rule.entry).map(asGraphPattern),
682
+ })),
683
+ reach: (manifest.graph?.reach ?? []).map((rule) => ({
684
+ name: rule.name,
685
+ message: rule.message,
686
+ probe: {
687
+ edges: [[probeFileIn(rule.from, "zz-origin"), probeFileIn(rule.to, "zz-target")]],
688
+ },
689
+ from: globsOf(rule.from).map(asGraphPattern),
690
+ ...(rule.fromNot === undefined ? {} : { fromNot: asGraphPatterns(rule.fromNot) ?? [] }),
691
+ to: globsOf(rule.to).map(asGraphPattern),
692
+ ...(rule.toNot === undefined ? {} : { toNot: asGraphPatterns(rule.toNot) ?? [] }),
693
+ ...(rule.via === undefined ? {} : { via: asGraphPatterns(rule.via) ?? [] }),
694
+ })),
695
+ };
696
+ return {
697
+ imports,
698
+ exports,
699
+ members,
700
+ surface,
701
+ graph,
702
+ adoption: { unrestricted: unrestrictedNodes, partial: partialNodes },
703
+ structure: { roots, folders, parity, naming: namingRules },
704
+ };
705
+ };
706
+ //# sourceMappingURL=compile.js.map