@goodbones/core 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/build/dts/core/baseline.d.ts +16 -0
- package/build/dts/core/baseline.d.ts.map +1 -0
- package/build/dts/core/coverage.d.ts +47 -0
- package/build/dts/core/coverage.d.ts.map +1 -0
- package/build/dts/core/exports.d.ts +38 -0
- package/build/dts/core/exports.d.ts.map +1 -0
- package/build/dts/core/graph.d.ts +40 -0
- package/build/dts/core/graph.d.ts.map +1 -0
- package/build/dts/core/imports.d.ts +29 -0
- package/build/dts/core/imports.d.ts.map +1 -0
- package/build/dts/core/members.d.ts +26 -0
- package/build/dts/core/members.d.ts.map +1 -0
- package/build/dts/core/patterns.d.ts +17 -0
- package/build/dts/core/patterns.d.ts.map +1 -0
- package/build/dts/core/structure.d.ts +48 -0
- package/build/dts/core/structure.d.ts.map +1 -0
- package/build/dts/core/surface.d.ts +39 -0
- package/build/dts/core/surface.d.ts.map +1 -0
- package/build/dts/domain/architecture-config.d.ts +322 -0
- package/build/dts/domain/architecture-config.d.ts.map +1 -0
- package/build/dts/domain/architecture-error.d.ts +35 -0
- package/build/dts/domain/architecture-error.d.ts.map +1 -0
- package/build/dts/domain/facts.d.ts +26 -0
- package/build/dts/domain/facts.d.ts.map +1 -0
- package/build/dts/domain/violation.d.ts +11 -0
- package/build/dts/domain/violation.d.ts.map +1 -0
- package/build/dts/index.d.ts +23 -0
- package/build/dts/index.d.ts.map +1 -0
- package/build/dts/infrastructure/fact-extractor-fake.d.ts +4 -0
- package/build/dts/infrastructure/fact-extractor-fake.d.ts.map +1 -0
- package/build/dts/infrastructure/file-system-fake.d.ts +3 -0
- package/build/dts/infrastructure/file-system-fake.d.ts.map +1 -0
- package/build/dts/infrastructure/file-system-live.d.ts +3 -0
- package/build/dts/infrastructure/file-system-live.d.ts.map +1 -0
- package/build/dts/infrastructure/manifest-file.d.ts +3 -0
- package/build/dts/infrastructure/manifest-file.d.ts.map +1 -0
- package/build/dts/infrastructure/module-resolver-fake.d.ts +3 -0
- package/build/dts/infrastructure/module-resolver-fake.d.ts.map +1 -0
- package/build/dts/infrastructure/walk.d.ts +4 -0
- package/build/dts/infrastructure/walk.d.ts.map +1 -0
- package/build/dts/load/policy.d.ts +42 -0
- package/build/dts/load/policy.d.ts.map +1 -0
- package/build/dts/manifest/compile.d.ts +26 -0
- package/build/dts/manifest/compile.d.ts.map +1 -0
- package/build/dts/manifest/glob.d.ts +14 -0
- package/build/dts/manifest/glob.d.ts.map +1 -0
- package/build/dts/manifest/manifest.d.ts +208 -0
- package/build/dts/manifest/manifest.d.ts.map +1 -0
- package/build/dts/ports/fact-extractor.d.ts +5 -0
- package/build/dts/ports/fact-extractor.d.ts.map +1 -0
- package/build/dts/ports/file-system.d.ts +5 -0
- package/build/dts/ports/file-system.d.ts.map +1 -0
- package/build/dts/ports/language.d.ts +14 -0
- package/build/dts/ports/language.d.ts.map +1 -0
- package/build/dts/ports/module-resolver.d.ts +12 -0
- package/build/dts/ports/module-resolver.d.ts.map +1 -0
- package/build/dts/testing.d.ts +4 -0
- package/build/dts/testing.d.ts.map +1 -0
- package/build/esm/core/baseline.js +30 -0
- package/build/esm/core/baseline.js.map +1 -0
- package/build/esm/core/coverage.js +75 -0
- package/build/esm/core/coverage.js.map +1 -0
- package/build/esm/core/exports.js +102 -0
- package/build/esm/core/exports.js.map +1 -0
- package/build/esm/core/graph.js +259 -0
- package/build/esm/core/graph.js.map +1 -0
- package/build/esm/core/imports.js +103 -0
- package/build/esm/core/imports.js.map +1 -0
- package/build/esm/core/members.js +106 -0
- package/build/esm/core/members.js.map +1 -0
- package/build/esm/core/patterns.js +75 -0
- package/build/esm/core/patterns.js.map +1 -0
- package/build/esm/core/structure.js +257 -0
- package/build/esm/core/structure.js.map +1 -0
- package/build/esm/core/surface.js +137 -0
- package/build/esm/core/surface.js.map +1 -0
- package/build/esm/domain/architecture-config.js +312 -0
- package/build/esm/domain/architecture-config.js.map +1 -0
- package/build/esm/domain/architecture-error.js +41 -0
- package/build/esm/domain/architecture-error.js.map +1 -0
- package/build/esm/domain/facts.js +2 -0
- package/build/esm/domain/facts.js.map +1 -0
- package/build/esm/domain/violation.js +8 -0
- package/build/esm/domain/violation.js.map +1 -0
- package/build/esm/index.js +22 -0
- package/build/esm/index.js.map +1 -0
- package/build/esm/infrastructure/fact-extractor-fake.js +24 -0
- package/build/esm/infrastructure/fact-extractor-fake.js.map +1 -0
- package/build/esm/infrastructure/file-system-fake.js +10 -0
- package/build/esm/infrastructure/file-system-fake.js.map +1 -0
- package/build/esm/infrastructure/file-system-live.js +27 -0
- package/build/esm/infrastructure/file-system-live.js.map +1 -0
- package/build/esm/infrastructure/manifest-file.js +15 -0
- package/build/esm/infrastructure/manifest-file.js.map +1 -0
- package/build/esm/infrastructure/module-resolver-fake.js +33 -0
- package/build/esm/infrastructure/module-resolver-fake.js.map +1 -0
- package/build/esm/infrastructure/walk.js +45 -0
- package/build/esm/infrastructure/walk.js.map +1 -0
- package/build/esm/load/policy.js +207 -0
- package/build/esm/load/policy.js.map +1 -0
- package/build/esm/manifest/compile.js +706 -0
- package/build/esm/manifest/compile.js.map +1 -0
- package/build/esm/manifest/glob.js +77 -0
- package/build/esm/manifest/glob.js.map +1 -0
- package/build/esm/manifest/manifest.js +303 -0
- package/build/esm/manifest/manifest.js.map +1 -0
- package/build/esm/ports/fact-extractor.js +2 -0
- package/build/esm/ports/fact-extractor.js.map +1 -0
- package/build/esm/ports/file-system.js +2 -0
- package/build/esm/ports/file-system.js.map +1 -0
- package/build/esm/ports/language.js +2 -0
- package/build/esm/ports/language.js.map +1 -0
- package/build/esm/ports/module-resolver.js +2 -0
- package/build/esm/ports/module-resolver.js.map +1 -0
- package/build/esm/testing.js +7 -0
- package/build/esm/testing.js.map +1 -0
- package/package.json +61 -0
- package/src/core/baseline.ts +62 -0
- package/src/core/coverage.ts +154 -0
- package/src/core/exports.ts +171 -0
- package/src/core/graph.ts +331 -0
- package/src/core/imports.ts +160 -0
- package/src/core/members.ts +161 -0
- package/src/core/patterns.ts +116 -0
- package/src/core/structure.ts +349 -0
- package/src/core/surface.ts +181 -0
- package/src/domain/architecture-config.ts +370 -0
- package/src/domain/architecture-error.ts +59 -0
- package/src/domain/facts.ts +47 -0
- package/src/domain/violation.ts +24 -0
- package/src/index.ts +136 -0
- package/src/infrastructure/fact-extractor-fake.ts +28 -0
- package/src/infrastructure/file-system-fake.ts +14 -0
- package/src/infrastructure/file-system-live.ts +27 -0
- package/src/infrastructure/manifest-file.ts +17 -0
- package/src/infrastructure/module-resolver-fake.ts +39 -0
- package/src/infrastructure/walk.ts +54 -0
- package/src/load/policy.ts +344 -0
- package/src/manifest/compile.ts +1011 -0
- package/src/manifest/glob.ts +108 -0
- package/src/manifest/manifest.ts +408 -0
- package/src/ports/fact-extractor.ts +12 -0
- package/src/ports/file-system.ts +8 -0
- package/src/ports/language.ts +39 -0
- package/src/ports/module-resolver.ts +28 -0
- package/src/testing.ts +6 -0
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExportRule,
|
|
3
|
+
type GraphConfig,
|
|
4
|
+
type ImportRule,
|
|
5
|
+
type MemberRule,
|
|
6
|
+
OPEN_LAYOUT,
|
|
7
|
+
type StructureFolder,
|
|
8
|
+
type StructureNaming,
|
|
9
|
+
type StructureParity,
|
|
10
|
+
type StructureRoot,
|
|
11
|
+
type SurfaceRule,
|
|
12
|
+
} from "../domain/architecture-config.js";
|
|
13
|
+
import { anchored, type CaptureIndex, globToRegexSource, prefixed } from "./glob.js";
|
|
14
|
+
import {
|
|
15
|
+
globsOf,
|
|
16
|
+
type ImportsSpec,
|
|
17
|
+
type Manifest,
|
|
18
|
+
type ManifestNode,
|
|
19
|
+
type NamingSpec,
|
|
20
|
+
} from "./manifest.js";
|
|
21
|
+
|
|
22
|
+
// The manifest is the authoring surface; these flat rules are the machine's.
|
|
23
|
+
// Lowering rather than interpreting keeps one evaluator, one probe mechanism and
|
|
24
|
+
// one set of semantics — the tree only decides what gets written.
|
|
25
|
+
export type LoweredRules = {
|
|
26
|
+
readonly imports: ReadonlyArray<ImportRule>;
|
|
27
|
+
readonly exports: ReadonlyArray<ExportRule>;
|
|
28
|
+
readonly members: ReadonlyArray<MemberRule>;
|
|
29
|
+
readonly surface: ReadonlyArray<SurfaceRule>;
|
|
30
|
+
readonly graph: GraphConfig;
|
|
31
|
+
// The nodes that said "not tightened yet", by name — the adoption backlog,
|
|
32
|
+
// and what `limits` puts a ceiling on.
|
|
33
|
+
readonly adoption: {
|
|
34
|
+
readonly unrestricted: ReadonlyArray<string>;
|
|
35
|
+
readonly partial: ReadonlyArray<string>;
|
|
36
|
+
};
|
|
37
|
+
readonly structure: {
|
|
38
|
+
readonly roots: ReadonlyArray<StructureRoot>;
|
|
39
|
+
readonly folders: ReadonlyArray<StructureFolder>;
|
|
40
|
+
readonly parity: ReadonlyArray<StructureParity>;
|
|
41
|
+
readonly naming: ReadonlyArray<StructureNaming>;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const FOLDER_KEY = /\/$/;
|
|
46
|
+
|
|
47
|
+
// The marker an open folder's allowlist carries: it admits any name, so it has
|
|
48
|
+
// no layout policy to prove.
|
|
49
|
+
export const ANY_FILE = OPEN_LAYOUT;
|
|
50
|
+
|
|
51
|
+
// The `from` side of a rule that applies to every file, wherever the repository
|
|
52
|
+
// happens to keep its packages.
|
|
53
|
+
const EVERY_FILE = "";
|
|
54
|
+
|
|
55
|
+
// A key may name several patterns that share one node: the four `*-ops`
|
|
56
|
+
// stereotypes carry identical policy, and saying it once is the point of writing
|
|
57
|
+
// the architecture as a tree.
|
|
58
|
+
const ALTERNATIVE = /\s*\|\s*/;
|
|
59
|
+
|
|
60
|
+
const isFolderKey = (key: string): boolean => FOLDER_KEY.test(key);
|
|
61
|
+
|
|
62
|
+
const stripSlash = (key: string): string => key.replace(FOLDER_KEY, "");
|
|
63
|
+
|
|
64
|
+
// Split first, then strip: `"http/ | cli/"` carries a trailing slash on every
|
|
65
|
+
// alternative, not only the last one.
|
|
66
|
+
const alternativesOf = (key: string): ReadonlyArray<string> =>
|
|
67
|
+
key
|
|
68
|
+
.split(ALTERNATIVE)
|
|
69
|
+
.map(stripSlash)
|
|
70
|
+
.filter((one) => one !== "");
|
|
71
|
+
|
|
72
|
+
const expandAliases = (glob: string, aliases: Readonly<Record<string, string>>): string => {
|
|
73
|
+
for (const [alias, target] of Object.entries(aliases)) {
|
|
74
|
+
if (glob === alias) return target;
|
|
75
|
+
if (glob.startsWith(`${alias}/`)) return target + glob.slice(alias.length);
|
|
76
|
+
}
|
|
77
|
+
return glob;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
type Frame = {
|
|
81
|
+
// Regex source for this node's own path, unanchored.
|
|
82
|
+
readonly pathSource: string;
|
|
83
|
+
// Glob for this node's path, used to synthesise probes.
|
|
84
|
+
readonly pathGlob: string;
|
|
85
|
+
readonly captures: CaptureIndex;
|
|
86
|
+
readonly nextGroup: number;
|
|
87
|
+
// Accumulated down the tree. `reset` is the only thing that clears it.
|
|
88
|
+
readonly allow: ReadonlyArray<string>;
|
|
89
|
+
// Third-party packages, by name, accumulated and reset the same way. Kept
|
|
90
|
+
// apart from `allow` because a package is judged by its name and never by
|
|
91
|
+
// where the language's resolver found it.
|
|
92
|
+
readonly externals: ReadonlyArray<string>;
|
|
93
|
+
readonly importsMessage: string;
|
|
94
|
+
// Inherited like the allowlist: a tier states its naming convention once.
|
|
95
|
+
readonly naming: NamingSpec | undefined;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// What lowering needs to know about a language: which scope ids name it, and
|
|
99
|
+
// what a source file of it is called. Structurally a `Language`, so the host
|
|
100
|
+
// passes its packs straight through; declared here so this tier never names
|
|
101
|
+
// the port.
|
|
102
|
+
export type ProbeLanguage = {
|
|
103
|
+
readonly id: string;
|
|
104
|
+
readonly extensions: ReadonlyArray<string>;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// A probe is the node's own path with its wildcards filled in, so a rule is
|
|
108
|
+
// proven against the shape it was written for and nobody hand-writes one.
|
|
109
|
+
const PROBE_WORDS = ["alpha", "beta", "gamma", "delta"];
|
|
110
|
+
|
|
111
|
+
const probePathOf = (pathGlob: string, leaf: string): string => {
|
|
112
|
+
let word = 0;
|
|
113
|
+
const filled = pathGlob
|
|
114
|
+
.replace(/\{[a-zA-Z][a-zA-Z0-9]*\}/g, () => {
|
|
115
|
+
const value = PROBE_WORDS[word % PROBE_WORDS.length] ?? "alpha";
|
|
116
|
+
word += 1;
|
|
117
|
+
return value;
|
|
118
|
+
})
|
|
119
|
+
.replace(/\*\*/g, "deep")
|
|
120
|
+
.replace(/\*/g, "zz");
|
|
121
|
+
if (leaf === "") return filled;
|
|
122
|
+
return filled === "" ? leaf : `${filled}/${leaf}`;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// The probe name must satisfy the rule's own `match`, or the rule cannot report
|
|
126
|
+
// it and the vacuity check fires on a healthy rule.
|
|
127
|
+
const probeMemberName = (match: string | ReadonlyArray<string> | undefined): string => {
|
|
128
|
+
if (match === undefined) return "zzProbeMember";
|
|
129
|
+
const first = globsOf(match)[0] ?? "";
|
|
130
|
+
return `${first
|
|
131
|
+
// A character class stands for one character, so the probe uses the first
|
|
132
|
+
// one it admits: `use[A-Z]*` has to be proven with `useA…`, not `use[A-Z]…`.
|
|
133
|
+
.replace(/\[\^?([^\]])[^\]]*\]/g, "$1")
|
|
134
|
+
.replace(/\*/g, "")}ZzProbe`;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Each convention pairs the shape a name must have with a name that does not
|
|
138
|
+
// have it, so the rule's probe is generated rather than written.
|
|
139
|
+
const CONVENTIONS: Readonly<
|
|
140
|
+
Record<string, { readonly source: string; readonly violating: string; readonly shape: string }>
|
|
141
|
+
> = {
|
|
142
|
+
"kebab-case": {
|
|
143
|
+
source: "^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
|
144
|
+
violating: "zzProbeStray",
|
|
145
|
+
shape: "lowercase words joined by hyphens",
|
|
146
|
+
},
|
|
147
|
+
camelCase: {
|
|
148
|
+
source: "^[a-z][a-zA-Z0-9]*$",
|
|
149
|
+
violating: "zz-probe-stray",
|
|
150
|
+
shape: "a lowercase first word, then capitalised ones, with no separators",
|
|
151
|
+
},
|
|
152
|
+
PascalCase: {
|
|
153
|
+
source: "^[A-Z][a-zA-Z0-9]*$",
|
|
154
|
+
violating: "zz-probe-stray",
|
|
155
|
+
shape: "capitalised words with no separators",
|
|
156
|
+
},
|
|
157
|
+
snake_case: {
|
|
158
|
+
source: "^[a-z0-9]+(?:_[a-z0-9]+)*$",
|
|
159
|
+
violating: "zzProbeStray",
|
|
160
|
+
shape: "lowercase words joined by underscores",
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// The folder's file list already enforces the stereotype suffix; a naming rule
|
|
165
|
+
// is about the concept name in front of it, so the message says which part it
|
|
166
|
+
// is talking about.
|
|
167
|
+
const namingMessageOf = (spec: NamingSpec, subject: "file" | "folder"): string => {
|
|
168
|
+
const what =
|
|
169
|
+
subject === "folder" ? "This folder's name" : "The concept name in front of the stereotype";
|
|
170
|
+
if (typeof spec === "string") {
|
|
171
|
+
return `${what} is ${spec} here — ${CONVENTIONS[spec]?.shape ?? ""}.`;
|
|
172
|
+
}
|
|
173
|
+
if (spec.message !== undefined) return spec.message;
|
|
174
|
+
if ("like" in spec) {
|
|
175
|
+
return "A file here is named after its folder, so its concept name is the folder's own.";
|
|
176
|
+
}
|
|
177
|
+
return `${what} matches /${spec.regex}/ here.`;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// A custom convention still owes a counter-example. If none of these fails it,
|
|
181
|
+
// the pattern admits every name and the rule could never report anything —
|
|
182
|
+
// which is the vacuity this package refuses to load.
|
|
183
|
+
const VIOLATING_CANDIDATES = ["zzProbeStray", "zz-probe-stray", "ZZ_PROBE_STRAY", "zz probe.stray"];
|
|
184
|
+
|
|
185
|
+
const violatingSampleFor = (spec: NamingSpec, ruleName: string): string => {
|
|
186
|
+
if (typeof spec === "string") {
|
|
187
|
+
const convention = CONVENTIONS[spec];
|
|
188
|
+
if (convention === undefined) throw new Error(`unknown naming convention "${spec}"`);
|
|
189
|
+
return convention.violating;
|
|
190
|
+
}
|
|
191
|
+
if ("like" in spec) return "zzprobestray";
|
|
192
|
+
const matcher = new RegExp(spec.regex);
|
|
193
|
+
const found = VIOLATING_CANDIDATES.find((candidate) => !matcher.test(candidate));
|
|
194
|
+
if (found === undefined) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`naming rule "${ruleName}" states /${spec.regex}/, which admits every name this ` +
|
|
197
|
+
`compiler can think of. A convention nothing can violate is a rule that never reports.`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return found;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// The probe is the node's own probe path with the subject replaced by a name the
|
|
204
|
+
// convention rejects — located by matching, so the compiler never has to reason
|
|
205
|
+
// about which segment of a glob the subject came from.
|
|
206
|
+
const namingProbeOf = (
|
|
207
|
+
patternSource: string,
|
|
208
|
+
subject: number,
|
|
209
|
+
probe: string,
|
|
210
|
+
violating: string,
|
|
211
|
+
): string => {
|
|
212
|
+
const found = new RegExp(patternSource, "d").exec(probe);
|
|
213
|
+
const span = found?.indices?.[subject];
|
|
214
|
+
if (span === undefined) return probe;
|
|
215
|
+
return probe.slice(0, span[0]) + violating + probe.slice(span[1]);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
type Denial = {
|
|
219
|
+
readonly match: string;
|
|
220
|
+
readonly matchNot: ReadonlyArray<string>;
|
|
221
|
+
readonly except: ReadonlyArray<string>;
|
|
222
|
+
readonly message: string;
|
|
223
|
+
// A concrete path the denial matches, so its probe aims at the shape it was
|
|
224
|
+
// written for rather than at one generic target for every denial.
|
|
225
|
+
readonly probe: string;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const mergeImports = (
|
|
229
|
+
frame: Frame,
|
|
230
|
+
spec: ImportsSpec | undefined,
|
|
231
|
+
aliases: Readonly<Record<string, string>>,
|
|
232
|
+
captures: CaptureIndex,
|
|
233
|
+
nextGroup: number,
|
|
234
|
+
): Pick<Frame, "allow" | "externals" | "importsMessage"> & {
|
|
235
|
+
readonly deny: ReadonlyArray<Denial>;
|
|
236
|
+
} => {
|
|
237
|
+
if (spec === undefined) {
|
|
238
|
+
return {
|
|
239
|
+
allow: frame.allow,
|
|
240
|
+
externals: frame.externals,
|
|
241
|
+
deny: [],
|
|
242
|
+
importsMessage: frame.importsMessage,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const compileAllow = (glob: string): string =>
|
|
247
|
+
prefixed(
|
|
248
|
+
globToRegexSource(expandAliases(glob, aliases), captures, { declaring: false, nextGroup })
|
|
249
|
+
.source,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const own = globsOf(spec.allow ?? []).map(compileAllow);
|
|
253
|
+
const external = spec.external ?? [];
|
|
254
|
+
const deny = (spec.deny ?? []).flatMap((entry) =>
|
|
255
|
+
globsOf(entry.match).map((glob) => ({
|
|
256
|
+
match: compileAllow(glob),
|
|
257
|
+
matchNot: globsOf(entry.matchNot ?? []).map(compileAllow),
|
|
258
|
+
except: globsOf(entry.except ?? []).map(compileAllow),
|
|
259
|
+
message: entry.message,
|
|
260
|
+
probe: probePathOf(expandAliases(glob, aliases), ""),
|
|
261
|
+
})),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// `reset` drops inherited ALLOWANCES only. A prohibition always accumulates, so
|
|
265
|
+
// resetting can never make a subtree quieter than its ancestors — the direction
|
|
266
|
+
// a mistake here would be dangerous in.
|
|
267
|
+
const dropping = spec.reset === true || spec.unrestricted === true;
|
|
268
|
+
return {
|
|
269
|
+
allow: dropping ? own : [...frame.allow, ...own],
|
|
270
|
+
externals: dropping ? external : [...frame.externals, ...external],
|
|
271
|
+
// Only what this node declares. A prohibition is emitted once, over its whole
|
|
272
|
+
// subtree, so descendants neither re-emit it nor can escape it — which is
|
|
273
|
+
// what makes `reset` structurally unable to make a subtree quieter.
|
|
274
|
+
deny,
|
|
275
|
+
importsMessage: spec.message ?? frame.importsMessage,
|
|
276
|
+
};
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
export const lowerManifest = (
|
|
280
|
+
manifest: Manifest,
|
|
281
|
+
languages: ReadonlyArray<ProbeLanguage> = [],
|
|
282
|
+
): LoweredRules => {
|
|
283
|
+
const aliases = manifest.aliases ?? {};
|
|
284
|
+
|
|
285
|
+
// The extension a synthetic probe file carries: the first extension of the
|
|
286
|
+
// language whose scope covers the probe's folder. A probe is matched by its
|
|
287
|
+
// folder, so the extension only makes it a file that language would have —
|
|
288
|
+
// which matters exactly when an allowlist admits `**/*.<ext>`, and would
|
|
289
|
+
// otherwise pass its probe while admitting every file.
|
|
290
|
+
const extensionFor = (folder: string): string => {
|
|
291
|
+
const scope = manifest.resolve.scopes.find((one) =>
|
|
292
|
+
new RegExp(one.files).test(`${folder}/zzprobe`),
|
|
293
|
+
);
|
|
294
|
+
const language = languages.find((one) => one.id === scope?.language);
|
|
295
|
+
return language?.extensions[0] ?? "";
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
// A synthetic file inside a folder glob: the folder with its wildcards filled,
|
|
299
|
+
// then the stem, then whatever extension the folder's language uses.
|
|
300
|
+
const probeIn = (folderGlob: string, stem: string): string =>
|
|
301
|
+
probePathOf(folderGlob, `${stem}${extensionFor(probePathOf(folderGlob, ""))}`);
|
|
302
|
+
|
|
303
|
+
// A synthetic file somewhere no node governs, written like a file of the
|
|
304
|
+
// language at `likeFolder` so that it is refused by a rule about that
|
|
305
|
+
// language's files and not merely by one about its own folder.
|
|
306
|
+
const probeOutside = (stem: string, likeFolder = "packages/zzprobe"): string =>
|
|
307
|
+
`packages/zzprobe/${stem}${extensionFor(likeFolder)}`;
|
|
308
|
+
|
|
309
|
+
// The folder layout rule must be proven with a basename the folder rejects.
|
|
310
|
+
// What that is depends on what the folder admits: a stray with the language's
|
|
311
|
+
// own extension is a file a folder admitting every such file is happy with.
|
|
312
|
+
const strayBasenameFor = (
|
|
313
|
+
admitted: ReadonlyArray<string>,
|
|
314
|
+
extension: string,
|
|
315
|
+
ruleName: string,
|
|
316
|
+
): string => {
|
|
317
|
+
const candidates = [`zzprobe-stray${extension}`, "zzprobe-stray", "zzprobe.stray.zz"];
|
|
318
|
+
const rejected = candidates.find(
|
|
319
|
+
(candidate) => !admitted.some((pattern) => new RegExp(pattern).test(candidate)),
|
|
320
|
+
);
|
|
321
|
+
if (rejected === undefined) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
`"${ruleName}" admits every file name this compiler can think of, so it enumerates ` +
|
|
324
|
+
`nothing. A folder that does not police its file names is \`layout: "open"\`; say so.`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
return rejected;
|
|
328
|
+
};
|
|
329
|
+
const imports: Array<ImportRule> = [];
|
|
330
|
+
const exports: Array<ExportRule> = [];
|
|
331
|
+
const members: Array<MemberRule> = [];
|
|
332
|
+
const surface: Array<SurfaceRule> = [];
|
|
333
|
+
const unrestrictedNodes: Array<string> = [];
|
|
334
|
+
const partialNodes: Array<string> = [];
|
|
335
|
+
const roots: Array<StructureRoot> = [];
|
|
336
|
+
const folders: Array<StructureFolder> = [];
|
|
337
|
+
const parity: Array<StructureParity> = [];
|
|
338
|
+
const namingRules: Array<StructureNaming> = [];
|
|
339
|
+
|
|
340
|
+
const walk = (
|
|
341
|
+
key: string,
|
|
342
|
+
node: ManifestNode,
|
|
343
|
+
parent: Frame,
|
|
344
|
+
name: string,
|
|
345
|
+
siblings: ReadonlyArray<string>,
|
|
346
|
+
): void => {
|
|
347
|
+
const literalSiblings = siblings
|
|
348
|
+
.filter((sibling) => sibling !== key)
|
|
349
|
+
.flatMap(alternativesOf)
|
|
350
|
+
.filter((sibling) => !/[*{]/.test(sibling));
|
|
351
|
+
const alternatives = alternativesOf(key).map((one) => expandAliases(one, aliases));
|
|
352
|
+
const [first = ""] = alternatives;
|
|
353
|
+
|
|
354
|
+
if (node.partial === true) partialNodes.push(name);
|
|
355
|
+
if (node.imports?.unrestricted === true) unrestrictedNodes.push(name);
|
|
356
|
+
if (alternatives.length > 1 && alternatives.some((one) => one.includes("{"))) {
|
|
357
|
+
throw new Error(
|
|
358
|
+
`key "${key}" both names several patterns and declares a capture. A capture has to come ` +
|
|
359
|
+
`from one place, so give the capturing pattern its own key.`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
// The probe, the path and every descendant hang off the first alternative;
|
|
363
|
+
// the rest only widen what the pattern matches.
|
|
364
|
+
const joinedGlob = parent.pathGlob === "" ? first : `${parent.pathGlob}/${first}`;
|
|
365
|
+
const compiledFirst = globToRegexSource(first, parent.captures, {
|
|
366
|
+
declaring: true,
|
|
367
|
+
nextGroup: parent.nextGroup,
|
|
368
|
+
});
|
|
369
|
+
const compiled =
|
|
370
|
+
alternatives.length === 1
|
|
371
|
+
? compiledFirst
|
|
372
|
+
: {
|
|
373
|
+
captures: compiledFirst.captures,
|
|
374
|
+
source: `(?:${alternatives
|
|
375
|
+
.map(
|
|
376
|
+
(one) =>
|
|
377
|
+
globToRegexSource(one, parent.captures, {
|
|
378
|
+
declaring: false,
|
|
379
|
+
nextGroup: parent.nextGroup,
|
|
380
|
+
}).source,
|
|
381
|
+
)
|
|
382
|
+
.join("|")})`,
|
|
383
|
+
};
|
|
384
|
+
// `(?!domain-services$|ports$)` — written by the compiler, from the fact that
|
|
385
|
+
// those keys are siblings of this one.
|
|
386
|
+
const guarded =
|
|
387
|
+
literalSiblings.length > 0 && /[*{]/.test(first)
|
|
388
|
+
? `(?!(?:${literalSiblings.map((one) => one.replace(/[.+^$()|[\]\\*?{}]/g, "\\$&")).join("|")})(?:/|$))${compiled.source}`
|
|
389
|
+
: compiled.source;
|
|
390
|
+
const pathSource = parent.pathSource === "" ? guarded : `${parent.pathSource}/${guarded}`;
|
|
391
|
+
const nextGroup =
|
|
392
|
+
parent.nextGroup +
|
|
393
|
+
(Object.keys(compiled.captures).length - Object.keys(parent.captures).length);
|
|
394
|
+
|
|
395
|
+
const merged = mergeImports(parent, node.imports, aliases, compiled.captures, nextGroup);
|
|
396
|
+
const ownDenials = merged.deny;
|
|
397
|
+
const frame: Frame = {
|
|
398
|
+
pathSource,
|
|
399
|
+
pathGlob: joinedGlob,
|
|
400
|
+
captures: compiled.captures,
|
|
401
|
+
nextGroup,
|
|
402
|
+
allow: merged.allow,
|
|
403
|
+
externals: merged.externals,
|
|
404
|
+
importsMessage: merged.importsMessage,
|
|
405
|
+
naming: node.name ?? parent.naming,
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const isFolder = isFolderKey(key) || node.children !== undefined;
|
|
409
|
+
const selfPattern = anchored(pathSource);
|
|
410
|
+
|
|
411
|
+
// Naming, in two shapes. A folder judges its own segment (when its key
|
|
412
|
+
// declares a capture) and the concept name of every file directly inside
|
|
413
|
+
// it; a file node judges what its own `*` matched, which is where "named
|
|
414
|
+
// after its folder" lives.
|
|
415
|
+
//
|
|
416
|
+
// A file's concept name is its basename up to the FIRST dot, not what a `*`
|
|
417
|
+
// matched: the key `*-live.<ext>` matches `todos.repository-live.<ext>`, whose
|
|
418
|
+
// wildcard spans a stereotype segment as well as the concept.
|
|
419
|
+
const naming = frame.naming;
|
|
420
|
+
if (naming !== undefined) {
|
|
421
|
+
const declaredHere = Object.keys(compiled.captures).filter(
|
|
422
|
+
(one) => parent.captures[one] === undefined,
|
|
423
|
+
);
|
|
424
|
+
const lastDeclared = declaredHere[declaredHere.length - 1];
|
|
425
|
+
const isLike = typeof naming === "object" && "like" in naming;
|
|
426
|
+
|
|
427
|
+
const emit = (
|
|
428
|
+
ruleName: string,
|
|
429
|
+
patterns: ReadonlyArray<string>,
|
|
430
|
+
subject: number,
|
|
431
|
+
probe: string,
|
|
432
|
+
sameAs?: number,
|
|
433
|
+
judging: "file" | "folder" = "file",
|
|
434
|
+
): void => {
|
|
435
|
+
namingRules.push({
|
|
436
|
+
name: ruleName,
|
|
437
|
+
message: namingMessageOf(naming, judging),
|
|
438
|
+
probe: {
|
|
439
|
+
path:
|
|
440
|
+
sameAs === undefined
|
|
441
|
+
? namingProbeOf(
|
|
442
|
+
patterns[0] ?? "",
|
|
443
|
+
subject,
|
|
444
|
+
probe,
|
|
445
|
+
violatingSampleFor(naming, ruleName),
|
|
446
|
+
)
|
|
447
|
+
: probe,
|
|
448
|
+
},
|
|
449
|
+
file: patterns,
|
|
450
|
+
subject,
|
|
451
|
+
...(sameAs === undefined
|
|
452
|
+
? {
|
|
453
|
+
convention:
|
|
454
|
+
typeof naming === "string"
|
|
455
|
+
? (CONVENTIONS[naming]?.source ?? "")
|
|
456
|
+
: "regex" in naming
|
|
457
|
+
? naming.regex
|
|
458
|
+
: "",
|
|
459
|
+
}
|
|
460
|
+
: { sameAs }),
|
|
461
|
+
});
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
if (isFolder && !isLike) {
|
|
465
|
+
if (lastDeclared !== undefined) {
|
|
466
|
+
const subject = compiled.captures[lastDeclared];
|
|
467
|
+
if (subject !== undefined) {
|
|
468
|
+
emit(
|
|
469
|
+
`${name}/naming-folder`,
|
|
470
|
+
[prefixed(`${pathSource}/`)],
|
|
471
|
+
subject,
|
|
472
|
+
probeIn(joinedGlob, "zzprobe"),
|
|
473
|
+
undefined,
|
|
474
|
+
"folder",
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
emit(
|
|
479
|
+
`${name}/naming`,
|
|
480
|
+
[anchored(`${pathSource}/([^/.]+)[^/]*`)],
|
|
481
|
+
nextGroup,
|
|
482
|
+
probeIn(joinedGlob, "zzprobe"),
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (!isFolder && isLike) {
|
|
487
|
+
const namingCompiled = alternatives.map((one) =>
|
|
488
|
+
globToRegexSource(one, parent.captures, {
|
|
489
|
+
declaring: true,
|
|
490
|
+
nextGroup: parent.nextGroup,
|
|
491
|
+
capturing: true,
|
|
492
|
+
}),
|
|
493
|
+
);
|
|
494
|
+
const [firstNaming] = namingCompiled;
|
|
495
|
+
const subject = firstNaming?.wildcards[firstNaming.wildcards.length - 1];
|
|
496
|
+
const sameAs =
|
|
497
|
+
typeof naming === "object" && "like" in naming
|
|
498
|
+
? parent.captures[naming.like.replace(/[{}]/g, "")]
|
|
499
|
+
: undefined;
|
|
500
|
+
if (typeof naming === "object" && "like" in naming && sameAs === undefined) {
|
|
501
|
+
throw new Error(
|
|
502
|
+
`naming at "${key}" is like ${naming.like}, which no ancestor path declares.`,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
if (subject !== undefined && sameAs !== undefined) {
|
|
506
|
+
emit(
|
|
507
|
+
`${name}/naming`,
|
|
508
|
+
namingCompiled.map((one) =>
|
|
509
|
+
anchored(
|
|
510
|
+
parent.pathSource === "" ? one.source : `${parent.pathSource}/${one.source}`,
|
|
511
|
+
),
|
|
512
|
+
),
|
|
513
|
+
subject,
|
|
514
|
+
probePathOf(joinedGlob, ""),
|
|
515
|
+
sameAs,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const childEntries = Object.entries(node.children ?? {});
|
|
522
|
+
// The nearest descendants — at any depth — that state their own import
|
|
523
|
+
// policy. A folder's allowlist covers its whole subtree except these, and
|
|
524
|
+
// each of them emits a rule over its own subtree in turn. Descent stops at a
|
|
525
|
+
// node that overrides, because everything below it is that node's business.
|
|
526
|
+
const overridingDescendants = (
|
|
527
|
+
from: ManifestNode,
|
|
528
|
+
atPath: string,
|
|
529
|
+
atCaptures: CaptureIndex,
|
|
530
|
+
atGroup: number,
|
|
531
|
+
): ReadonlyArray<string> =>
|
|
532
|
+
Object.entries(from.children ?? {}).flatMap(([childKey, child]) =>
|
|
533
|
+
alternativesOf(childKey).flatMap((one) => {
|
|
534
|
+
// `declaring` because a folder key may itself name a capture
|
|
535
|
+
// (`{subdomain}/`). The extra group is harmless in an exclusion, which
|
|
536
|
+
// is matched on its own rather than substituted into.
|
|
537
|
+
const childCompiled = globToRegexSource(one, atCaptures, {
|
|
538
|
+
declaring: true,
|
|
539
|
+
nextGroup: atGroup,
|
|
540
|
+
});
|
|
541
|
+
const source = `${atPath}/${childCompiled.source}`;
|
|
542
|
+
const childGroup =
|
|
543
|
+
atGroup + (Object.keys(childCompiled.captures).length - Object.keys(atCaptures).length);
|
|
544
|
+
if (child.imports !== undefined) {
|
|
545
|
+
return [isFolderKey(childKey) ? prefixed(`${source}/`) : anchored(source)];
|
|
546
|
+
}
|
|
547
|
+
return isFolderKey(childKey)
|
|
548
|
+
? overridingDescendants(child, source, childCompiled.captures, childGroup)
|
|
549
|
+
: [];
|
|
550
|
+
}),
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
const overridingChildren = overridingDescendants(
|
|
554
|
+
node,
|
|
555
|
+
pathSource,
|
|
556
|
+
compiled.captures,
|
|
557
|
+
nextGroup,
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
if (isFolder) {
|
|
561
|
+
const childKeys = childEntries;
|
|
562
|
+
const fileKeys = childKeys.filter(([childKey]) => !isFolderKey(childKey));
|
|
563
|
+
if (node.partial !== true) {
|
|
564
|
+
const ruleName = `${name}/layout`;
|
|
565
|
+
// An open folder still CLAIMS its folder — otherwise no rule governs
|
|
566
|
+
// it and the taxonomy root fires — it just admits any file name.
|
|
567
|
+
const admitted =
|
|
568
|
+
node.layout === "open"
|
|
569
|
+
? [ANY_FILE]
|
|
570
|
+
: fileKeys.flatMap(([childKey]) =>
|
|
571
|
+
alternativesOf(childKey).map((one) =>
|
|
572
|
+
anchored(
|
|
573
|
+
globToRegexSource(one, compiled.captures, { declaring: false, nextGroup })
|
|
574
|
+
.source,
|
|
575
|
+
),
|
|
576
|
+
),
|
|
577
|
+
);
|
|
578
|
+
const folderProbe = probePathOf(joinedGlob, "");
|
|
579
|
+
const stray =
|
|
580
|
+
node.layout === "open"
|
|
581
|
+
? `zzprobe-stray${extensionFor(folderProbe)}`
|
|
582
|
+
: strayBasenameFor(admitted, extensionFor(folderProbe), ruleName);
|
|
583
|
+
folders.push({
|
|
584
|
+
name: ruleName,
|
|
585
|
+
message: node.message ?? "This folder does not admit that file.",
|
|
586
|
+
probe: { path: probePathOf(joinedGlob, stray) },
|
|
587
|
+
folder: selfPattern,
|
|
588
|
+
files: admitted,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
const siblingKeys = childKeys.map(([childKey]) => childKey);
|
|
592
|
+
for (const [childKey, child] of childKeys) {
|
|
593
|
+
walk(childKey, child, frame, `${name}/${alternativesOf(childKey)[0] ?? ""}`, siblingKeys);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Outbound. One allowlist stands in for every "may not reach X" rule that
|
|
598
|
+
// would otherwise be written separately and far from here.
|
|
599
|
+
//
|
|
600
|
+
// Import policy belongs to a folder, so it lowers once per folder — matching
|
|
601
|
+
// that folder's direct children — rather than once per file kind inside it.
|
|
602
|
+
// A file node emits its own only when it says something its folder did not.
|
|
603
|
+
const emitsOwnImports = isFolder ? node.imports !== undefined : node.imports !== undefined;
|
|
604
|
+
const scope = isFolder ? prefixed(`${pathSource}/`) : selfPattern;
|
|
605
|
+
const scopeProbe = isFolder ? probeIn(joinedGlob, "zzprobe") : probePathOf(joinedGlob, "");
|
|
606
|
+
// The folder a probe of this node sits in, for "a file of the same language".
|
|
607
|
+
const ownFolder = isFolder
|
|
608
|
+
? probePathOf(joinedGlob, "")
|
|
609
|
+
: probePathOf(joinedGlob, "").replace(/\/[^/]*$/, "");
|
|
610
|
+
|
|
611
|
+
const admitsEverything = frame.allow.some((pattern) => pattern === "^.*" || pattern === "^");
|
|
612
|
+
const hasAllowlist =
|
|
613
|
+
(frame.allow.length > 0 || frame.externals.length > 0) && !admitsEverything;
|
|
614
|
+
|
|
615
|
+
if (emitsOwnImports && node.imports?.unrestricted !== true && !hasAllowlist) {
|
|
616
|
+
throw new Error(
|
|
617
|
+
`"${name}" states an \`imports\` policy with no allowlist. If that is deliberate — the ` +
|
|
618
|
+
`tier is not tightened yet and only its prohibitions apply — say \`unrestricted: true\`, ` +
|
|
619
|
+
`so the gap is a sentence someone wrote rather than an omission nobody noticed.`,
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (emitsOwnImports) {
|
|
624
|
+
const exemptions = overridingChildren.length > 0 ? { fromNot: overridingChildren } : {};
|
|
625
|
+
|
|
626
|
+
if (hasAllowlist) {
|
|
627
|
+
imports.push({
|
|
628
|
+
name: `${name}/imports`,
|
|
629
|
+
message: frame.importsMessage,
|
|
630
|
+
probe: { from: scopeProbe, to: probeOutside("nowhere", ownFolder) },
|
|
631
|
+
from: scope,
|
|
632
|
+
...exemptions,
|
|
633
|
+
toNot: [...frame.allow],
|
|
634
|
+
...(frame.externals.length > 0 ? { externals: [...frame.externals] } : {}),
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Prohibitions are emitted once, over this node's whole subtree, and carry no
|
|
640
|
+
// exemptions: a node cannot opt out of an ancestor's prohibition.
|
|
641
|
+
for (const [index, denial] of ownDenials.entries()) {
|
|
642
|
+
imports.push({
|
|
643
|
+
name: `${name}/deny-${String(index)}`,
|
|
644
|
+
message: denial.message,
|
|
645
|
+
probe: { from: scopeProbe, to: denial.probe },
|
|
646
|
+
from: isFolder ? prefixed(`${pathSource}/`) : selfPattern,
|
|
647
|
+
...(denial.except.length > 0 ? { fromNot: [...denial.except] } : {}),
|
|
648
|
+
to: denial.match,
|
|
649
|
+
...(denial.matchNot.length > 0 ? { toNot: [...denial.matchNot] } : {}),
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Inbound. "This file is private to X" belongs beside the file, not in a
|
|
654
|
+
// distant rule whose `from` side grows an exclusion for every new caller.
|
|
655
|
+
if (node.importedBy !== undefined) {
|
|
656
|
+
// An `importedBy` allowlist is matched against the IMPORTER, while the
|
|
657
|
+
// captures on this node were declared by the TARGET's path — so a
|
|
658
|
+
// `{capture}` here has nothing to resolve against and would compile to a
|
|
659
|
+
// pattern that never matches, silently over-reporting. Refuse it rather
|
|
660
|
+
// than emit a rule whose exemptions do not work.
|
|
661
|
+
for (const allowed of globsOf(node.importedBy.allow)) {
|
|
662
|
+
const referenced = allowed.match(/\{[a-zA-Z][a-zA-Z0-9]*\}/g) ?? [];
|
|
663
|
+
if (referenced.length > 0) {
|
|
664
|
+
throw new Error(
|
|
665
|
+
`"${name}" allows ${referenced.join(", ")} in importedBy, but a capture from this ` +
|
|
666
|
+
`node's own path cannot be used there: importedBy patterns are matched against the ` +
|
|
667
|
+
`importing file, and ${referenced[0] ?? ""} was declared by this file's path. Use a ` +
|
|
668
|
+
`wildcard (the barrel rules are what stop another module reaching in), or move the ` +
|
|
669
|
+
`restriction to that importer's own node as an \`imports\` allowlist.`,
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
const asTarget = (glob: string) =>
|
|
674
|
+
prefixed(
|
|
675
|
+
globToRegexSource(expandAliases(glob, aliases), compiled.captures, {
|
|
676
|
+
declaring: false,
|
|
677
|
+
nextGroup,
|
|
678
|
+
}).source,
|
|
679
|
+
);
|
|
680
|
+
// On a folder the restriction covers the whole subtree — "a module is
|
|
681
|
+
// private" is a statement about everything under it, not about the folder
|
|
682
|
+
// node itself.
|
|
683
|
+
const exempt = globsOf(node.importedBy.matchNot ?? []).map((glob) =>
|
|
684
|
+
anchored(
|
|
685
|
+
`${pathSource}/${globToRegexSource(glob, compiled.captures, { declaring: false, nextGroup }).source}`,
|
|
686
|
+
),
|
|
687
|
+
);
|
|
688
|
+
imports.push({
|
|
689
|
+
name: `${name}/imported-by`,
|
|
690
|
+
message: node.importedBy.message,
|
|
691
|
+
probe: { from: probeOutside("outsider", ownFolder), to: scopeProbe },
|
|
692
|
+
from: EVERY_FILE,
|
|
693
|
+
fromNot: globsOf(node.importedBy.allow).map(asTarget),
|
|
694
|
+
to: isFolder ? prefixed(`${pathSource}/`) : selfPattern,
|
|
695
|
+
...(exempt.length > 0 ? { toNot: exempt } : {}),
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
for (const [index, spec] of (node.members ?? []).entries()) {
|
|
700
|
+
const asRegex = (globs: string | ReadonlyArray<string>) =>
|
|
701
|
+
globsOf(globs).map((one) =>
|
|
702
|
+
anchored(
|
|
703
|
+
globToRegexSource(one, compiled.captures, { declaring: false, nextGroup }).source,
|
|
704
|
+
),
|
|
705
|
+
);
|
|
706
|
+
members.push({
|
|
707
|
+
name: `${name}/members-${String(index)}`,
|
|
708
|
+
message: spec.message,
|
|
709
|
+
// An authored probe replaces the synthetic site: the name is the one
|
|
710
|
+
// the author says the snippet declares, and the declaration is the
|
|
711
|
+
// parser's to find.
|
|
712
|
+
probe:
|
|
713
|
+
spec.probe === undefined
|
|
714
|
+
? {
|
|
715
|
+
from: scopeProbe,
|
|
716
|
+
name: probeMemberName(spec.match),
|
|
717
|
+
...(spec.in === undefined ? {} : { in: "ZzProbeRepositoryShape" }),
|
|
718
|
+
...(spec.declares?.[0] === undefined ? {} : { declares: spec.declares[0] }),
|
|
719
|
+
}
|
|
720
|
+
: { from: scopeProbe, name: spec.probe.name, source: spec.probe.source },
|
|
721
|
+
// On a folder the rule covers the subtree, as `imports` does — a
|
|
722
|
+
// vocabulary is a statement about every file in a tier. Selecting the
|
|
723
|
+
// folder's own path instead governed no file, and the probe, a folder
|
|
724
|
+
// path, passed regardless.
|
|
725
|
+
from: scope,
|
|
726
|
+
subject: spec.subject,
|
|
727
|
+
...(spec.in === undefined ? {} : { in: asRegex(spec.in) }),
|
|
728
|
+
...(spec.declares === undefined ? {} : { declares: [...spec.declares] }),
|
|
729
|
+
...(spec.match === undefined ? {} : { match: asRegex(spec.match) }),
|
|
730
|
+
...(spec.matchNot === undefined ? {} : { matchNot: asRegex(spec.matchNot) }),
|
|
731
|
+
...(spec.allow === undefined ? {} : { allow: asRegex(spec.allow) }),
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
for (const [index, spec] of (node.surface ?? []).entries()) {
|
|
736
|
+
const ruleName = `${name}/surface-${String(index)}`;
|
|
737
|
+
const asName = (globs: string | ReadonlyArray<string>) =>
|
|
738
|
+
globsOf(globs).map((one) =>
|
|
739
|
+
anchored(
|
|
740
|
+
globToRegexSource(one, compiled.captures, { declaring: false, nextGroup }).source,
|
|
741
|
+
),
|
|
742
|
+
);
|
|
743
|
+
const asPath = (glob: string) =>
|
|
744
|
+
prefixed(
|
|
745
|
+
globToRegexSource(expandAliases(glob, aliases), compiled.captures, {
|
|
746
|
+
declaring: false,
|
|
747
|
+
nextGroup,
|
|
748
|
+
}).source,
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
const demands = [spec.allow, spec.convention, spec.count].filter(
|
|
752
|
+
(one) => one !== undefined,
|
|
753
|
+
).length;
|
|
754
|
+
if (demands > 1) {
|
|
755
|
+
throw new Error(
|
|
756
|
+
`surface rule "${ruleName}" states more than one of allow, convention and count. ` +
|
|
757
|
+
`A rule makes one demand; write one entry per demand.`,
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const kind = spec.kinds?.[0] ?? "named";
|
|
762
|
+
const conventionSource =
|
|
763
|
+
spec.convention === undefined
|
|
764
|
+
? undefined
|
|
765
|
+
: typeof spec.convention === "string"
|
|
766
|
+
? CONVENTIONS[spec.convention]?.source
|
|
767
|
+
: spec.convention.regex;
|
|
768
|
+
|
|
769
|
+
// The synthetic probe: one site the rule must reject, or for `count`, a
|
|
770
|
+
// surface of the wrong size. A count nothing can violate — no minimum, no
|
|
771
|
+
// maximum — is a rule that never reports, and is refused here.
|
|
772
|
+
const siteName =
|
|
773
|
+
kind === "default"
|
|
774
|
+
? "default"
|
|
775
|
+
: kind === "namespace"
|
|
776
|
+
? "*"
|
|
777
|
+
: spec.convention !== undefined
|
|
778
|
+
? violatingSampleFor(spec.convention, ruleName)
|
|
779
|
+
: probeMemberName(spec.match);
|
|
780
|
+
const oneSite = {
|
|
781
|
+
name: siteName,
|
|
782
|
+
kind,
|
|
783
|
+
...(spec.declares?.[0] === undefined ? {} : { declares: spec.declares[0] }),
|
|
784
|
+
...(spec.reexport === undefined ? {} : { reexport: spec.reexport }),
|
|
785
|
+
};
|
|
786
|
+
const probeSites = (): ReadonlyArray<typeof oneSite> => {
|
|
787
|
+
if (spec.count === undefined) return [oneSite];
|
|
788
|
+
const min = spec.count.min ?? 0;
|
|
789
|
+
if (min > 0) return [];
|
|
790
|
+
if (spec.count.max === undefined) {
|
|
791
|
+
throw new Error(
|
|
792
|
+
`surface rule "${ruleName}" states a count with no minimum and no maximum, ` +
|
|
793
|
+
`which nothing can violate.`,
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
return Array.from({ length: spec.count.max + 1 }, (_, i) => ({
|
|
797
|
+
...oneSite,
|
|
798
|
+
name: `${siteName}${String(i)}`,
|
|
799
|
+
}));
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
surface.push({
|
|
803
|
+
name: ruleName,
|
|
804
|
+
message: spec.message,
|
|
805
|
+
probe:
|
|
806
|
+
spec.probe === undefined
|
|
807
|
+
? { from: scopeProbe, sites: probeSites() }
|
|
808
|
+
: { from: scopeProbe, source: spec.probe.source },
|
|
809
|
+
from: scope,
|
|
810
|
+
...(spec.except === undefined ? {} : { fromNot: globsOf(spec.except).map(asPath) }),
|
|
811
|
+
...(spec.kinds === undefined ? {} : { kinds: [...spec.kinds] }),
|
|
812
|
+
...(spec.declares === undefined ? {} : { declares: [...spec.declares] }),
|
|
813
|
+
...(spec.reexport === undefined ? {} : { reexport: spec.reexport }),
|
|
814
|
+
...(spec.match === undefined ? {} : { match: asName(spec.match) }),
|
|
815
|
+
...(spec.matchNot === undefined ? {} : { matchNot: asName(spec.matchNot) }),
|
|
816
|
+
...(spec.forbid === undefined ? {} : { forbid: spec.forbid }),
|
|
817
|
+
...(spec.allow === undefined ? {} : { allow: asName(spec.allow) }),
|
|
818
|
+
...(conventionSource === undefined ? {} : { convention: conventionSource }),
|
|
819
|
+
...(spec.count === undefined ? {} : { count: spec.count }),
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (node.requires !== undefined && node.requires.length > 0) {
|
|
824
|
+
const exempt = (node.requiresNot ?? []).map(
|
|
825
|
+
(basename) =>
|
|
826
|
+
`/${globToRegexSource(basename, compiled.captures, { declaring: false, nextGroup }).source}$`,
|
|
827
|
+
);
|
|
828
|
+
parity.push({
|
|
829
|
+
name: `${name}/requires`,
|
|
830
|
+
message: node.message ?? "This file needs its sibling.",
|
|
831
|
+
probe: { path: probePathOf(joinedGlob, "") },
|
|
832
|
+
file: selfPattern,
|
|
833
|
+
...(exempt.length > 0 ? { fileNot: exempt } : {}),
|
|
834
|
+
requires: [...node.requires],
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
const emptyFrame: Frame = {
|
|
840
|
+
pathSource: "",
|
|
841
|
+
pathGlob: "",
|
|
842
|
+
captures: {},
|
|
843
|
+
nextGroup: 1,
|
|
844
|
+
allow: [],
|
|
845
|
+
externals: [],
|
|
846
|
+
importsMessage: "This import is not on this folder's allowlist.",
|
|
847
|
+
naming: undefined,
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
// Repo-wide prohibitions: `from` is every file, so no tier can be written
|
|
851
|
+
// that escapes them.
|
|
852
|
+
const globalFrame: Frame = { ...emptyFrame };
|
|
853
|
+
for (const [index, denial] of mergeImports(
|
|
854
|
+
globalFrame,
|
|
855
|
+
{ unrestricted: true, deny: manifest.deny ?? [] },
|
|
856
|
+
aliases,
|
|
857
|
+
{},
|
|
858
|
+
1,
|
|
859
|
+
).deny.entries()) {
|
|
860
|
+
imports.push({
|
|
861
|
+
name: `repo/deny-${String(index)}`,
|
|
862
|
+
message: denial.message,
|
|
863
|
+
probe: { from: probeOutside("anywhere"), to: denial.probe },
|
|
864
|
+
from: EVERY_FILE,
|
|
865
|
+
...(denial.except.length > 0 ? { fromNot: [...denial.except] } : {}),
|
|
866
|
+
to: denial.match,
|
|
867
|
+
...(denial.matchNot.length > 0 ? { toNot: [...denial.matchNot] } : {}),
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
for (const [key, node] of Object.entries(manifest.tree)) {
|
|
872
|
+
// A taxonomy root is a region whose folders are enumerated. A key naming a
|
|
873
|
+
// single file has no folders to deny, and an open tree governs every folder
|
|
874
|
+
// it contains — neither has anything for a root to catch.
|
|
875
|
+
if ((isFolderKey(key) || node.children !== undefined) && node.layout !== "open")
|
|
876
|
+
roots.push({
|
|
877
|
+
name: `${stripSlash(key)}/taxonomy`,
|
|
878
|
+
message:
|
|
879
|
+
node.message ??
|
|
880
|
+
"This folder is not part of the taxonomy. Declare it in the manifest deliberately, or move the file into the folder that owns it.",
|
|
881
|
+
probe: { path: probeIn(`${expandAliases(stripSlash(key), aliases)}/zzprobe`, "stray") },
|
|
882
|
+
path: prefixed(
|
|
883
|
+
globToRegexSource(
|
|
884
|
+
expandAliases(stripSlash(key), aliases),
|
|
885
|
+
{},
|
|
886
|
+
{
|
|
887
|
+
declaring: true,
|
|
888
|
+
nextGroup: 1,
|
|
889
|
+
},
|
|
890
|
+
).source,
|
|
891
|
+
),
|
|
892
|
+
});
|
|
893
|
+
walk(
|
|
894
|
+
key,
|
|
895
|
+
node,
|
|
896
|
+
emptyFrame,
|
|
897
|
+
stripSlash(key)
|
|
898
|
+
.replace(/[^a-zA-Z0-9]+/g, "-")
|
|
899
|
+
.replace(/^-|-$/g, ""),
|
|
900
|
+
Object.keys(manifest.tree),
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
for (const rule of manifest.exports ?? []) {
|
|
905
|
+
const asPattern = (glob: string) =>
|
|
906
|
+
prefixed(
|
|
907
|
+
globToRegexSource(expandAliases(glob, aliases), {}, { declaring: false, nextGroup: 1 })
|
|
908
|
+
.source,
|
|
909
|
+
);
|
|
910
|
+
// The synthetic probe is a binding of the rule's first kind. A default
|
|
911
|
+
// binding is only ever named `default` and a namespace one `*`, so a rule
|
|
912
|
+
// that lists `symbols` alongside those kinds cannot cover its probe — and
|
|
913
|
+
// is refused at load, which is right: it could never fire on such a form.
|
|
914
|
+
const kind = rule.kinds?.[0] ?? "named";
|
|
915
|
+
const symbol =
|
|
916
|
+
rule.probe?.symbol ??
|
|
917
|
+
(kind === "namespace" ? "*" : kind === "default" ? "default" : rule.symbols?.[0]) ??
|
|
918
|
+
"zzProbeSymbol";
|
|
919
|
+
exports.push({
|
|
920
|
+
name: rule.name,
|
|
921
|
+
message: rule.message,
|
|
922
|
+
probe: {
|
|
923
|
+
from: probeOutside("anywhere"),
|
|
924
|
+
to: probePathOf(expandAliases(globsOf(rule.module)[0] ?? "", aliases), ""),
|
|
925
|
+
symbol,
|
|
926
|
+
kind,
|
|
927
|
+
...(rule.probe === undefined ? {} : { source: rule.probe.source }),
|
|
928
|
+
},
|
|
929
|
+
from: EVERY_FILE,
|
|
930
|
+
...(rule.except === undefined ? {} : { fromNot: globsOf(rule.except).map(asPattern) }),
|
|
931
|
+
to: globsOf(rule.module).map(asPattern),
|
|
932
|
+
...(rule.symbols === undefined ? {} : { symbols: [...rule.symbols] }),
|
|
933
|
+
...(rule.kinds === undefined ? {} : { kinds: [...rule.kinds] }),
|
|
934
|
+
...(rule.fix === undefined ? {} : { fix: rule.fix }),
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// Graph rules pass through with their globs resolved, and a probe built from
|
|
939
|
+
// the shape each is about: two files importing each other, a file nothing
|
|
940
|
+
// imports, a direct edge from `from` to `to` that touches no `via`.
|
|
941
|
+
const asGraphPattern = (glob: string) =>
|
|
942
|
+
prefixed(
|
|
943
|
+
globToRegexSource(expandAliases(glob, aliases), {}, { declaring: false, nextGroup: 1 })
|
|
944
|
+
.source,
|
|
945
|
+
);
|
|
946
|
+
const asGraphPatterns = (globs: string | ReadonlyArray<string> | undefined) =>
|
|
947
|
+
globs === undefined ? undefined : globsOf(globs).map(asGraphPattern);
|
|
948
|
+
// A synthetic file inside a graph scope. A glob whose last segment names a
|
|
949
|
+
// file shape (`src/**/*.<ext>`) is filled in place and keeps its extension; a
|
|
950
|
+
// folder glob (`src/**`) gets a file of its language's added beneath it.
|
|
951
|
+
const probeFileIn = (globs: string | ReadonlyArray<string>, stem: string): string => {
|
|
952
|
+
const glob = expandAliases(globsOf(globs)[0] ?? "", aliases);
|
|
953
|
+
const at = glob.lastIndexOf("/");
|
|
954
|
+
const last = glob.slice(at + 1);
|
|
955
|
+
if (!last.includes(".")) return probeIn(glob, stem);
|
|
956
|
+
return probePathOf(
|
|
957
|
+
at === -1 ? "" : glob.slice(0, at),
|
|
958
|
+
last.replace(/\{[a-zA-Z][a-zA-Z0-9]*\}|\*+/g, stem),
|
|
959
|
+
);
|
|
960
|
+
};
|
|
961
|
+
const graph: GraphConfig = {
|
|
962
|
+
cycles: (manifest.graph?.cycles ?? []).map((rule) => {
|
|
963
|
+
const a = probeFileIn(rule.within, "zz-alpha");
|
|
964
|
+
const b = probeFileIn(rule.within, "zz-beta");
|
|
965
|
+
return {
|
|
966
|
+
name: rule.name,
|
|
967
|
+
message: rule.message,
|
|
968
|
+
probe: {
|
|
969
|
+
edges: [
|
|
970
|
+
[a, b],
|
|
971
|
+
[b, a],
|
|
972
|
+
],
|
|
973
|
+
},
|
|
974
|
+
within: globsOf(rule.within).map(asGraphPattern),
|
|
975
|
+
...(rule.withinNot === undefined
|
|
976
|
+
? {}
|
|
977
|
+
: { withinNot: asGraphPatterns(rule.withinNot) ?? [] }),
|
|
978
|
+
};
|
|
979
|
+
}),
|
|
980
|
+
orphans: (manifest.graph?.orphans ?? []).map((rule) => ({
|
|
981
|
+
name: rule.name,
|
|
982
|
+
message: rule.message,
|
|
983
|
+
probe: { edges: [], files: [probeFileIn(rule.within, "zz-orphan")] },
|
|
984
|
+
within: globsOf(rule.within).map(asGraphPattern),
|
|
985
|
+
...(rule.withinNot === undefined ? {} : { withinNot: asGraphPatterns(rule.withinNot) ?? [] }),
|
|
986
|
+
entry: globsOf(rule.entry).map(asGraphPattern),
|
|
987
|
+
})),
|
|
988
|
+
reach: (manifest.graph?.reach ?? []).map((rule) => ({
|
|
989
|
+
name: rule.name,
|
|
990
|
+
message: rule.message,
|
|
991
|
+
probe: {
|
|
992
|
+
edges: [[probeFileIn(rule.from, "zz-origin"), probeFileIn(rule.to, "zz-target")]],
|
|
993
|
+
},
|
|
994
|
+
from: globsOf(rule.from).map(asGraphPattern),
|
|
995
|
+
...(rule.fromNot === undefined ? {} : { fromNot: asGraphPatterns(rule.fromNot) ?? [] }),
|
|
996
|
+
to: globsOf(rule.to).map(asGraphPattern),
|
|
997
|
+
...(rule.toNot === undefined ? {} : { toNot: asGraphPatterns(rule.toNot) ?? [] }),
|
|
998
|
+
...(rule.via === undefined ? {} : { via: asGraphPatterns(rule.via) ?? [] }),
|
|
999
|
+
})),
|
|
1000
|
+
};
|
|
1001
|
+
|
|
1002
|
+
return {
|
|
1003
|
+
imports,
|
|
1004
|
+
exports,
|
|
1005
|
+
members,
|
|
1006
|
+
surface,
|
|
1007
|
+
graph,
|
|
1008
|
+
adoption: { unrestricted: unrestrictedNodes, partial: partialNodes },
|
|
1009
|
+
structure: { roots, folders, parity, naming: namingRules },
|
|
1010
|
+
};
|
|
1011
|
+
};
|