@alveolus/arch 0.1.0-alpha.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/dist/bin.mjs +1 -1
- package/dist/cli-CwPCGjDg.mjs +2810 -0
- package/dist/cli-CwPCGjDg.mjs.map +1 -0
- package/dist/index.d.mts +480 -168
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/package.json +20 -3
- package/dist/cli-CW1zAnrP.mjs +0 -1229
- package/dist/cli-CW1zAnrP.mjs.map +0 -1
|
@@ -0,0 +1,2810 @@
|
|
|
1
|
+
import { Command, CommanderError, Option } from "commander";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, matchesGlob, normalize, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import picocolors from "picocolors";
|
|
7
|
+
import { createJiti } from "jiti";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { ModuleDeclarationKind, Node, Project, Scope, SyntaxKind, VariableDeclarationKind, ts } from "ts-morph";
|
|
10
|
+
//#region src/check/baseline.ts
|
|
11
|
+
var Baseline = class Baseline {
|
|
12
|
+
entries;
|
|
13
|
+
static fileName = "alveolus.baseline.json";
|
|
14
|
+
constructor(entries) {
|
|
15
|
+
this.entries = entries;
|
|
16
|
+
}
|
|
17
|
+
static of(violations) {
|
|
18
|
+
return new Baseline(violations.map(({ file, fingerprint, rule, symbol }) => ({
|
|
19
|
+
file,
|
|
20
|
+
fingerprint,
|
|
21
|
+
rule,
|
|
22
|
+
symbol
|
|
23
|
+
})));
|
|
24
|
+
}
|
|
25
|
+
static async load(path) {
|
|
26
|
+
if (!existsSync(path)) return new Baseline([]);
|
|
27
|
+
const content = JSON.parse(await readFile(path, "utf8"));
|
|
28
|
+
return new Baseline(content.violations ?? []);
|
|
29
|
+
}
|
|
30
|
+
get size() {
|
|
31
|
+
return this.entries.length;
|
|
32
|
+
}
|
|
33
|
+
get outdatedEntries() {
|
|
34
|
+
return this.entries.filter((entry) => entry.fingerprint === void 0).length;
|
|
35
|
+
}
|
|
36
|
+
newViolations(violations) {
|
|
37
|
+
return this.match(violations).fresh;
|
|
38
|
+
}
|
|
39
|
+
staleEntries(violations) {
|
|
40
|
+
return this.match(violations).stale;
|
|
41
|
+
}
|
|
42
|
+
match(violations) {
|
|
43
|
+
const remaining = /* @__PURE__ */ new Map();
|
|
44
|
+
for (const entry of this.entries) remaining.set(this.keyOf(entry), (remaining.get(this.keyOf(entry)) ?? 0) + 1);
|
|
45
|
+
const fresh = [];
|
|
46
|
+
for (const violation of violations) {
|
|
47
|
+
const key = this.keyOf(violation);
|
|
48
|
+
const count = remaining.get(key) ?? 0;
|
|
49
|
+
if (count > 0) remaining.set(key, count - 1);
|
|
50
|
+
else fresh.push(violation);
|
|
51
|
+
}
|
|
52
|
+
let stale = 0;
|
|
53
|
+
for (const count of remaining.values()) stale += count;
|
|
54
|
+
return {
|
|
55
|
+
fresh,
|
|
56
|
+
stale
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
async save(path) {
|
|
60
|
+
const violations = [...this.entries].sort((left, right) => this.keyOf(left).localeCompare(this.keyOf(right)));
|
|
61
|
+
await writeFile(path, `${JSON.stringify({ violations }, null, " ")}\n`);
|
|
62
|
+
}
|
|
63
|
+
keyOf(entry) {
|
|
64
|
+
return [
|
|
65
|
+
entry.rule,
|
|
66
|
+
entry.file,
|
|
67
|
+
entry.symbol,
|
|
68
|
+
entry.fingerprint ?? ""
|
|
69
|
+
].join("\n");
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/architecture/allowed-packages.ts
|
|
74
|
+
var AllowedPackages = class AllowedPackages {
|
|
75
|
+
packages;
|
|
76
|
+
constructor(packages = {}) {
|
|
77
|
+
this.packages = packages;
|
|
78
|
+
}
|
|
79
|
+
has(packageName) {
|
|
80
|
+
return Object.hasOwn(this.packages, packageName);
|
|
81
|
+
}
|
|
82
|
+
forbiddenNames(packageName, names) {
|
|
83
|
+
const allowed = this.packages[packageName];
|
|
84
|
+
if (allowed === void 0) return names;
|
|
85
|
+
if (allowed === true) return [];
|
|
86
|
+
return names.filter((name) => !allowed.includes(name));
|
|
87
|
+
}
|
|
88
|
+
allowedNames(packageName) {
|
|
89
|
+
const allowed = this.packages[packageName];
|
|
90
|
+
if (allowed === void 0 || allowed === true) return [];
|
|
91
|
+
return allowed;
|
|
92
|
+
}
|
|
93
|
+
with(other) {
|
|
94
|
+
const merged = { ...this.packages };
|
|
95
|
+
for (const [packageName, allowed] of Object.entries(other.packages)) {
|
|
96
|
+
const existing = merged[packageName];
|
|
97
|
+
if (existing === void 0) merged[packageName] = allowed;
|
|
98
|
+
else if (existing === true || allowed === true) merged[packageName] = true;
|
|
99
|
+
else merged[packageName] = [...existing, ...allowed];
|
|
100
|
+
}
|
|
101
|
+
return new AllowedPackages(merged);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/model/classes/class-declaration.ts
|
|
106
|
+
var ClassDeclaration = class {
|
|
107
|
+
name;
|
|
108
|
+
line;
|
|
109
|
+
isAbstract;
|
|
110
|
+
type;
|
|
111
|
+
heritage;
|
|
112
|
+
implemented;
|
|
113
|
+
members;
|
|
114
|
+
constructor(props) {
|
|
115
|
+
this.name = props.name;
|
|
116
|
+
this.line = props.line;
|
|
117
|
+
this.isAbstract = props.isAbstract;
|
|
118
|
+
this.type = props.type;
|
|
119
|
+
this.heritage = props.heritage;
|
|
120
|
+
this.implemented = props.implemented;
|
|
121
|
+
this.members = props.members;
|
|
122
|
+
}
|
|
123
|
+
get heldMembers() {
|
|
124
|
+
return this.members.filter((member) => member.kind === "field" || member.kind === "constructor parameter");
|
|
125
|
+
}
|
|
126
|
+
get publicSurface() {
|
|
127
|
+
return this.members.filter((member) => member.isPublicInstance && member.kind !== "setter" && this.isDeclared(member));
|
|
128
|
+
}
|
|
129
|
+
get typeArguments() {
|
|
130
|
+
return this.heritage?.typeArguments ?? [];
|
|
131
|
+
}
|
|
132
|
+
get extendsByName() {
|
|
133
|
+
return this.heritage === void 0 || this.heritage.isByName;
|
|
134
|
+
}
|
|
135
|
+
get isStaticOnly() {
|
|
136
|
+
const members = this.members.filter((member) => this.isDeclared(member));
|
|
137
|
+
return members.length > 0 && members.every((member) => member.isStatic);
|
|
138
|
+
}
|
|
139
|
+
isDeclared(member) {
|
|
140
|
+
return member.kind !== "constructor parameter" || member.isParameterProperty;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/model/classes/class-type.ts
|
|
145
|
+
var ClassType = class {
|
|
146
|
+
name;
|
|
147
|
+
declaredIn;
|
|
148
|
+
lineage;
|
|
149
|
+
constructor(name, declaredIn, lineage) {
|
|
150
|
+
this.name = name;
|
|
151
|
+
this.declaredIn = declaredIn;
|
|
152
|
+
this.lineage = lineage;
|
|
153
|
+
}
|
|
154
|
+
get key() {
|
|
155
|
+
return `${this.declaredIn ?? ""}#${this.name}`;
|
|
156
|
+
}
|
|
157
|
+
get isInstalled() {
|
|
158
|
+
return (this.declaredIn ?? "").split(/[\\/]/).includes("node_modules");
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
//#endregion
|
|
162
|
+
//#region src/model/classes/member.ts
|
|
163
|
+
var Member = class {
|
|
164
|
+
name;
|
|
165
|
+
line;
|
|
166
|
+
kind;
|
|
167
|
+
visibility;
|
|
168
|
+
isStatic;
|
|
169
|
+
isParameterProperty;
|
|
170
|
+
isCallable;
|
|
171
|
+
valueTypes;
|
|
172
|
+
parameterTypes;
|
|
173
|
+
returns;
|
|
174
|
+
constructor(props) {
|
|
175
|
+
this.name = props.name;
|
|
176
|
+
this.line = props.line;
|
|
177
|
+
this.kind = props.kind;
|
|
178
|
+
this.visibility = props.visibility;
|
|
179
|
+
this.isStatic = props.isStatic;
|
|
180
|
+
this.isParameterProperty = props.isParameterProperty;
|
|
181
|
+
this.isCallable = props.isCallable;
|
|
182
|
+
this.valueTypes = props.valueTypes;
|
|
183
|
+
this.parameterTypes = props.parameterTypes;
|
|
184
|
+
this.returns = props.returns;
|
|
185
|
+
}
|
|
186
|
+
get isPublicInstance() {
|
|
187
|
+
return this.visibility === "public" && !this.isStatic;
|
|
188
|
+
}
|
|
189
|
+
get isField() {
|
|
190
|
+
return this.kind === "field" || this.isParameterProperty;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
//#endregion
|
|
194
|
+
//#region src/model/dependencies/dependency.ts
|
|
195
|
+
var Dependency = class {
|
|
196
|
+
line;
|
|
197
|
+
form;
|
|
198
|
+
specifier;
|
|
199
|
+
names;
|
|
200
|
+
target;
|
|
201
|
+
constructor(line, form, specifier, names, target) {
|
|
202
|
+
this.line = line;
|
|
203
|
+
this.form = form;
|
|
204
|
+
this.specifier = specifier;
|
|
205
|
+
this.names = names;
|
|
206
|
+
this.target = target;
|
|
207
|
+
}
|
|
208
|
+
get label() {
|
|
209
|
+
return this.names.length === 0 ? this.specifier : this.names.join(", ");
|
|
210
|
+
}
|
|
211
|
+
isOnPackage(name) {
|
|
212
|
+
return this.target.kind === "package" && this.target.name === name;
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
//#endregion
|
|
216
|
+
//#region src/model/dependencies/global-use.ts
|
|
217
|
+
var GlobalUse = class {
|
|
218
|
+
line;
|
|
219
|
+
name;
|
|
220
|
+
origin;
|
|
221
|
+
effect;
|
|
222
|
+
constructor(line, name, origin, effect) {
|
|
223
|
+
this.line = line;
|
|
224
|
+
this.name = name;
|
|
225
|
+
this.origin = origin;
|
|
226
|
+
this.effect = effect;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
//#endregion
|
|
230
|
+
//#region src/model/project.ts
|
|
231
|
+
var Project$1 = class {
|
|
232
|
+
directory;
|
|
233
|
+
files;
|
|
234
|
+
filesByPath;
|
|
235
|
+
constructor(directory, files) {
|
|
236
|
+
this.directory = directory;
|
|
237
|
+
this.files = files;
|
|
238
|
+
this.filesByPath = new Map(files.map((file) => [file.path, file]));
|
|
239
|
+
}
|
|
240
|
+
file(path) {
|
|
241
|
+
return this.filesByPath.get(path);
|
|
242
|
+
}
|
|
243
|
+
classOf(type) {
|
|
244
|
+
if (type.declaredIn === void 0) return;
|
|
245
|
+
return this.file(type.declaredIn)?.classNamed(type.name);
|
|
246
|
+
}
|
|
247
|
+
relativePath(path) {
|
|
248
|
+
return relative(this.directory, path).split(sep).join("/");
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
//#endregion
|
|
252
|
+
//#region src/model/source-file.ts
|
|
253
|
+
var SourceFile = class {
|
|
254
|
+
path;
|
|
255
|
+
dependencies;
|
|
256
|
+
classes;
|
|
257
|
+
statements;
|
|
258
|
+
throws;
|
|
259
|
+
globals;
|
|
260
|
+
disables;
|
|
261
|
+
lines;
|
|
262
|
+
constructor(props) {
|
|
263
|
+
this.path = props.path;
|
|
264
|
+
this.dependencies = props.dependencies;
|
|
265
|
+
this.classes = props.classes;
|
|
266
|
+
this.statements = props.statements;
|
|
267
|
+
this.throws = props.throws;
|
|
268
|
+
this.globals = props.globals;
|
|
269
|
+
this.disables = props.disables;
|
|
270
|
+
this.lines = props.text.split(/\r?\n/);
|
|
271
|
+
}
|
|
272
|
+
lineText(line) {
|
|
273
|
+
return this.lines[line - 1] ?? "";
|
|
274
|
+
}
|
|
275
|
+
disableAbove(line) {
|
|
276
|
+
return this.disables.find((comment) => comment.target === line);
|
|
277
|
+
}
|
|
278
|
+
classNamed(name) {
|
|
279
|
+
return this.classes.find((candidate) => candidate.name === name);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
//#endregion
|
|
283
|
+
//#region src/model/statements/disable-comment.ts
|
|
284
|
+
var DisableComment = class {
|
|
285
|
+
line;
|
|
286
|
+
text;
|
|
287
|
+
constructor(line, text) {
|
|
288
|
+
this.line = line;
|
|
289
|
+
this.text = text;
|
|
290
|
+
}
|
|
291
|
+
get target() {
|
|
292
|
+
return this.line + 1;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
//#endregion
|
|
296
|
+
//#region src/model/statements/throw.ts
|
|
297
|
+
var Throw = class {
|
|
298
|
+
line;
|
|
299
|
+
form;
|
|
300
|
+
constructor(line, form) {
|
|
301
|
+
this.line = line;
|
|
302
|
+
this.form = form;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
//#endregion
|
|
306
|
+
//#region src/model/statements/top-level-statement.ts
|
|
307
|
+
var TopLevelStatement = class {
|
|
308
|
+
kind;
|
|
309
|
+
name;
|
|
310
|
+
line;
|
|
311
|
+
constructor(kind, name, line) {
|
|
312
|
+
this.kind = kind;
|
|
313
|
+
this.name = name;
|
|
314
|
+
this.line = line;
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
//#endregion
|
|
318
|
+
//#region src/conventions/building-blocks.ts
|
|
319
|
+
const buildingBlockPlaces = [
|
|
320
|
+
{
|
|
321
|
+
folder: "aggregates",
|
|
322
|
+
kind: "AggregateRoot",
|
|
323
|
+
layer: "domain",
|
|
324
|
+
suffix: ".aggregate.ts"
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
folder: "entities",
|
|
328
|
+
kind: "Entity",
|
|
329
|
+
layer: "domain",
|
|
330
|
+
suffix: ".entity.ts"
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
folder: "value-objects",
|
|
334
|
+
kind: "Identifier",
|
|
335
|
+
layer: "domain",
|
|
336
|
+
suffix: ".identifier.ts"
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
folder: "value-objects",
|
|
340
|
+
kind: "ValueObject",
|
|
341
|
+
layer: "domain",
|
|
342
|
+
suffix: ".value-object.ts"
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
folder: "events",
|
|
346
|
+
kind: "DomainEvent",
|
|
347
|
+
layer: "domain",
|
|
348
|
+
suffix: ".event.ts"
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
folder: "errors",
|
|
352
|
+
kind: "DomainError",
|
|
353
|
+
layer: "domain",
|
|
354
|
+
suffix: ".error.ts"
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
folder: "services",
|
|
358
|
+
kind: "DomainService",
|
|
359
|
+
layer: "domain",
|
|
360
|
+
suffix: ".service.ts"
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
folder: "commands",
|
|
364
|
+
kind: "CommandHandler",
|
|
365
|
+
layer: "application",
|
|
366
|
+
suffix: ".command.ts"
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
folder: "queries",
|
|
370
|
+
kind: "QueryHandler",
|
|
371
|
+
layer: "application",
|
|
372
|
+
suffix: ".query.ts"
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
folder: "translators",
|
|
376
|
+
kind: "EventTranslator",
|
|
377
|
+
layer: "application",
|
|
378
|
+
suffix: ".translator.ts"
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
concreteOnly: true,
|
|
382
|
+
folder: "adapters",
|
|
383
|
+
kind: "Port",
|
|
384
|
+
layer: "driven",
|
|
385
|
+
suffix: ".adapter.ts"
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
folder: "repositories",
|
|
389
|
+
kind: "CommandRepository",
|
|
390
|
+
layer: "domain",
|
|
391
|
+
suffix: ".repository.ts"
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
folder: "repositories",
|
|
395
|
+
kind: "QueryRepository",
|
|
396
|
+
layer: "domain",
|
|
397
|
+
suffix: ".repository.ts"
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
folder: "ports",
|
|
401
|
+
kind: "Port",
|
|
402
|
+
layer: "domain",
|
|
403
|
+
suffix: ".port.ts"
|
|
404
|
+
}
|
|
405
|
+
];
|
|
406
|
+
const markerPlaces = [{
|
|
407
|
+
folder: "adapters",
|
|
408
|
+
layer: "driven",
|
|
409
|
+
marker: "AntiCorruptionLayer",
|
|
410
|
+
suffix: ".adapter.ts"
|
|
411
|
+
}, {
|
|
412
|
+
layer: "driving",
|
|
413
|
+
marker: "OpenHostService"
|
|
414
|
+
}];
|
|
415
|
+
const extraDomainFolders = ["views"];
|
|
416
|
+
//#endregion
|
|
417
|
+
//#region src/conventions/core-api.ts
|
|
418
|
+
const corePackageName = "@alveolus/core";
|
|
419
|
+
const coreKinds = [
|
|
420
|
+
"AggregateRoot",
|
|
421
|
+
"Entity",
|
|
422
|
+
"ValueObject",
|
|
423
|
+
"Identifier",
|
|
424
|
+
"DomainEvent",
|
|
425
|
+
"DomainError",
|
|
426
|
+
"DomainService",
|
|
427
|
+
"Port",
|
|
428
|
+
"CommandRepository",
|
|
429
|
+
"QueryRepository",
|
|
430
|
+
"CommandHandler",
|
|
431
|
+
"QueryHandler",
|
|
432
|
+
"EventTranslator",
|
|
433
|
+
"EventPublisher",
|
|
434
|
+
"Outbox",
|
|
435
|
+
"UnitOfWork"
|
|
436
|
+
];
|
|
437
|
+
const coreMarkers = ["OpenHostService", "AntiCorruptionLayer"];
|
|
438
|
+
const coreDomainSymbols = [
|
|
439
|
+
"AggregateRoot",
|
|
440
|
+
"AnyAggregateRoot",
|
|
441
|
+
"AnyDomainError",
|
|
442
|
+
"AnyDomainEvent",
|
|
443
|
+
"AnyEntity",
|
|
444
|
+
"AnyIdentifier",
|
|
445
|
+
"AnySnapshot",
|
|
446
|
+
"AnyValueObject",
|
|
447
|
+
"Clock",
|
|
448
|
+
"CommandRepository",
|
|
449
|
+
"DomainError",
|
|
450
|
+
"DomainEvent",
|
|
451
|
+
"DomainEventProps",
|
|
452
|
+
"DomainService",
|
|
453
|
+
"Entity",
|
|
454
|
+
"Err",
|
|
455
|
+
"IdGenerator",
|
|
456
|
+
"Identifier",
|
|
457
|
+
"IdentifierValue",
|
|
458
|
+
"Ok",
|
|
459
|
+
"Port",
|
|
460
|
+
"QueryRepository",
|
|
461
|
+
"Result",
|
|
462
|
+
"SnapshotValue",
|
|
463
|
+
"ValueObject",
|
|
464
|
+
"View",
|
|
465
|
+
"andThen",
|
|
466
|
+
"combine",
|
|
467
|
+
"err",
|
|
468
|
+
"map",
|
|
469
|
+
"mapErr",
|
|
470
|
+
"ok"
|
|
471
|
+
];
|
|
472
|
+
const corePublishedLanguageSymbols = [
|
|
473
|
+
"AnyIntegrationEvent",
|
|
474
|
+
"IntegrationEvent",
|
|
475
|
+
"JsonValue",
|
|
476
|
+
"PublishedLanguage"
|
|
477
|
+
];
|
|
478
|
+
//#endregion
|
|
479
|
+
//#region src/conventions/layers.ts
|
|
480
|
+
const layers = [
|
|
481
|
+
"domain",
|
|
482
|
+
"application",
|
|
483
|
+
"published-language",
|
|
484
|
+
"driven",
|
|
485
|
+
"driving"
|
|
486
|
+
];
|
|
487
|
+
const layerImports = {
|
|
488
|
+
application: [
|
|
489
|
+
"domain",
|
|
490
|
+
"application",
|
|
491
|
+
"published-language"
|
|
492
|
+
],
|
|
493
|
+
driven: [
|
|
494
|
+
"domain",
|
|
495
|
+
"application",
|
|
496
|
+
"published-language",
|
|
497
|
+
"driven"
|
|
498
|
+
],
|
|
499
|
+
driving: [
|
|
500
|
+
"domain",
|
|
501
|
+
"application",
|
|
502
|
+
"published-language",
|
|
503
|
+
"driving"
|
|
504
|
+
],
|
|
505
|
+
"published-language": ["published-language"]
|
|
506
|
+
};
|
|
507
|
+
//#endregion
|
|
508
|
+
//#region src/architecture/paths/place.ts
|
|
509
|
+
var Place = class {
|
|
510
|
+
layer;
|
|
511
|
+
folder;
|
|
512
|
+
suffix;
|
|
513
|
+
constructor(spec) {
|
|
514
|
+
this.layer = spec.layer;
|
|
515
|
+
this.folder = spec.folder;
|
|
516
|
+
this.suffix = spec.suffix;
|
|
517
|
+
}
|
|
518
|
+
fits(location) {
|
|
519
|
+
if (!location.isInBoundedContext && !location.isInSharedKernel) return false;
|
|
520
|
+
if (location.layer !== this.layer) return false;
|
|
521
|
+
if (this.folder !== void 0 && location.folder !== this.folder) return false;
|
|
522
|
+
return this.suffix === void 0 || location.fileName.endsWith(this.suffix);
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
//#endregion
|
|
526
|
+
//#region src/architecture/building-blocks.ts
|
|
527
|
+
var BuildingBlocks = class {
|
|
528
|
+
core;
|
|
529
|
+
constructor(core) {
|
|
530
|
+
this.core = core;
|
|
531
|
+
}
|
|
532
|
+
placeOf(declaration) {
|
|
533
|
+
const kinds = this.core.kindsOf(declaration.type);
|
|
534
|
+
for (const entry of buildingBlockPlaces) if (kinds.includes(entry.kind) && !(entry.concreteOnly === true && declaration.isAbstract)) return new Place(entry);
|
|
535
|
+
}
|
|
536
|
+
markedPlacesOf(declaration) {
|
|
537
|
+
const markers = this.core.markersOf(declaration.implemented);
|
|
538
|
+
const places = [];
|
|
539
|
+
for (const entry of markerPlaces) if (markers.includes(entry.marker)) places.push({
|
|
540
|
+
marker: entry.marker,
|
|
541
|
+
place: new Place(entry)
|
|
542
|
+
});
|
|
543
|
+
return places;
|
|
544
|
+
}
|
|
545
|
+
foldersOf(layer) {
|
|
546
|
+
const folders = [];
|
|
547
|
+
for (const entry of buildingBlockPlaces) if (entry.layer === layer && entry.folder !== void 0 && !folders.includes(entry.folder)) folders.push(entry.folder);
|
|
548
|
+
if (layer === "domain") folders.push(...extraDomainFolders);
|
|
549
|
+
return folders;
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
//#endregion
|
|
553
|
+
//#region src/architecture/core-api.ts
|
|
554
|
+
var CoreApi = class {
|
|
555
|
+
get packageName() {
|
|
556
|
+
return corePackageName;
|
|
557
|
+
}
|
|
558
|
+
kindsOf(type) {
|
|
559
|
+
const kinds = [];
|
|
560
|
+
for (const ancestor of type.lineage) if (this.isCore(ancestor) && this.isKind(ancestor.name)) kinds.push(ancestor.name);
|
|
561
|
+
return kinds;
|
|
562
|
+
}
|
|
563
|
+
markersOf(implemented) {
|
|
564
|
+
const markers = [];
|
|
565
|
+
for (const type of implemented) if (this.isCore(type) && this.isMarker(type.name)) markers.push(type.name);
|
|
566
|
+
return markers;
|
|
567
|
+
}
|
|
568
|
+
isResult(shape) {
|
|
569
|
+
if (shape.alias !== void 0 && shape.alias.name === "Result" && this.isCore(shape.alias)) return true;
|
|
570
|
+
return shape.union.every((member) => (member.name === "Ok" || member.name === "Err") && this.isCore(member));
|
|
571
|
+
}
|
|
572
|
+
isDomainSymbol(name) {
|
|
573
|
+
return coreDomainSymbols.includes(name);
|
|
574
|
+
}
|
|
575
|
+
isPublishedLanguageSymbol(name) {
|
|
576
|
+
return corePublishedLanguageSymbols.includes(name);
|
|
577
|
+
}
|
|
578
|
+
isKind(name) {
|
|
579
|
+
return coreKinds.includes(name);
|
|
580
|
+
}
|
|
581
|
+
isMarker(name) {
|
|
582
|
+
return coreMarkers.includes(name);
|
|
583
|
+
}
|
|
584
|
+
isCore(type) {
|
|
585
|
+
return type.packageName === corePackageName;
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
//#endregion
|
|
589
|
+
//#region src/architecture/layers.ts
|
|
590
|
+
var Layers = class {
|
|
591
|
+
isLayer(name) {
|
|
592
|
+
return layers.includes(name);
|
|
593
|
+
}
|
|
594
|
+
importableFrom(layer) {
|
|
595
|
+
return layerImports[layer];
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
//#endregion
|
|
599
|
+
//#region src/architecture/paths/layer-shape.ts
|
|
600
|
+
var LayerShape = class {
|
|
601
|
+
blocks;
|
|
602
|
+
extraFolders;
|
|
603
|
+
constructor(blocks, extraFolders) {
|
|
604
|
+
this.blocks = blocks;
|
|
605
|
+
this.extraFolders = extraFolders;
|
|
606
|
+
}
|
|
607
|
+
issueWith(location) {
|
|
608
|
+
const layer = location.layer;
|
|
609
|
+
const folders = location.foldersInLayer;
|
|
610
|
+
if (layer === "domain" || layer === "application") return this.kindFolderIssue(layer, folders);
|
|
611
|
+
if (layer === "published-language" && folders.length > 0) return { kind: "nestedPublishedLanguage" };
|
|
612
|
+
if (layer === "driven" && folders.length < 2) return { kind: "drivenWithoutTechnology" };
|
|
613
|
+
if (layer === "driven" && folders.length > 2) return { kind: "drivenTooDeep" };
|
|
614
|
+
if (layer === "driving" && folders.length === 0) return { kind: "drivingWithoutTechnology" };
|
|
615
|
+
}
|
|
616
|
+
kindFolderIssue(layer, folders) {
|
|
617
|
+
const expected = [...this.blocks.foldersOf(layer), ...this.extraFolders[layer] ?? []];
|
|
618
|
+
const example = `${layer}/${expected[0]}/`;
|
|
619
|
+
if (folders.length === 0) return {
|
|
620
|
+
example,
|
|
621
|
+
kind: "directlyInLayer",
|
|
622
|
+
layer
|
|
623
|
+
};
|
|
624
|
+
if (folders.length > 1) return {
|
|
625
|
+
example,
|
|
626
|
+
kind: "tooDeep",
|
|
627
|
+
layer
|
|
628
|
+
};
|
|
629
|
+
const [folder] = folders;
|
|
630
|
+
if (folder !== void 0 && !expected.includes(folder)) return {
|
|
631
|
+
folder,
|
|
632
|
+
folders: expected,
|
|
633
|
+
kind: "unknownFolder",
|
|
634
|
+
layer
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
//#endregion
|
|
639
|
+
//#region src/architecture/paths/location.ts
|
|
640
|
+
var Location = class {
|
|
641
|
+
area;
|
|
642
|
+
context;
|
|
643
|
+
layer;
|
|
644
|
+
folder;
|
|
645
|
+
foldersInLayer;
|
|
646
|
+
fileName;
|
|
647
|
+
isCompositionRoot;
|
|
648
|
+
unseen;
|
|
649
|
+
constructor(props) {
|
|
650
|
+
this.area = props.area;
|
|
651
|
+
this.context = props.context;
|
|
652
|
+
this.layer = props.layer;
|
|
653
|
+
this.folder = props.folder;
|
|
654
|
+
this.foldersInLayer = props.foldersInLayer ?? [];
|
|
655
|
+
this.fileName = props.fileName;
|
|
656
|
+
this.isCompositionRoot = props.isCompositionRoot ?? false;
|
|
657
|
+
this.unseen = props.unseen;
|
|
658
|
+
}
|
|
659
|
+
get isInBoundedContext() {
|
|
660
|
+
return this.area === "context";
|
|
661
|
+
}
|
|
662
|
+
get isInSharedKernel() {
|
|
663
|
+
return this.area === "shared-kernel";
|
|
664
|
+
}
|
|
665
|
+
get isAtRoot() {
|
|
666
|
+
return this.area === "root";
|
|
667
|
+
}
|
|
668
|
+
get isOutside() {
|
|
669
|
+
return this.area === "outside";
|
|
670
|
+
}
|
|
671
|
+
get isInLayer() {
|
|
672
|
+
return this.layer !== void 0;
|
|
673
|
+
}
|
|
674
|
+
isSameContextAs(other) {
|
|
675
|
+
return this.context !== void 0 && this.context === other.context;
|
|
676
|
+
}
|
|
677
|
+
isOtherBoundedContextThan(other) {
|
|
678
|
+
return this.isInBoundedContext && !this.isSameContextAs(other);
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
//#endregion
|
|
682
|
+
//#region src/architecture/paths/layout.ts
|
|
683
|
+
var Layout = class {
|
|
684
|
+
settings;
|
|
685
|
+
constructor(settings) {
|
|
686
|
+
this.settings = settings;
|
|
687
|
+
}
|
|
688
|
+
locate(path) {
|
|
689
|
+
const file = resolve(path);
|
|
690
|
+
const fileName = basename(file);
|
|
691
|
+
const folder = this.contextFolderOf(file);
|
|
692
|
+
if (folder === void 0) return new Location({
|
|
693
|
+
area: dirname(file) === this.settings.rootDir ? "root" : "outside",
|
|
694
|
+
fileName
|
|
695
|
+
});
|
|
696
|
+
const area = folder.isSharedKernel ? "shared-kernel" : "context";
|
|
697
|
+
const context = folder.name;
|
|
698
|
+
const directories = this.directoriesBetween(folder.dir, file);
|
|
699
|
+
if (directories.length === 0) return new Location({
|
|
700
|
+
area,
|
|
701
|
+
context,
|
|
702
|
+
fileName,
|
|
703
|
+
isCompositionRoot: this.isCompositionRoot(fileName)
|
|
704
|
+
});
|
|
705
|
+
const layerIndex = this.layerIndexOf(directories, folder.isSharedKernel);
|
|
706
|
+
if (layerIndex === void 0) return new Location({
|
|
707
|
+
area,
|
|
708
|
+
context,
|
|
709
|
+
fileName,
|
|
710
|
+
isCompositionRoot: folder.isSharedKernel && directories.length === 1 && this.isCompositionRoot(fileName)
|
|
711
|
+
});
|
|
712
|
+
const layer = directories[layerIndex];
|
|
713
|
+
if (layer === void 0 || !this.isLayer(layer)) return new Location({
|
|
714
|
+
area,
|
|
715
|
+
context,
|
|
716
|
+
fileName
|
|
717
|
+
});
|
|
718
|
+
const foldersInLayer = directories.slice(layerIndex + 1);
|
|
719
|
+
const subfolder = foldersInLayer.at(-1);
|
|
720
|
+
return new Location({
|
|
721
|
+
area,
|
|
722
|
+
context,
|
|
723
|
+
fileName,
|
|
724
|
+
foldersInLayer,
|
|
725
|
+
layer,
|
|
726
|
+
...subfolder === void 0 ? {} : { folder: subfolder }
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
layerIndexOf(directories, isSharedKernel) {
|
|
730
|
+
if (this.isLayer(directories[0] ?? "")) return 0;
|
|
731
|
+
if (isSharedKernel && this.isLayer(directories[1] ?? "")) return 1;
|
|
732
|
+
}
|
|
733
|
+
isLayer(name) {
|
|
734
|
+
return layers.includes(name);
|
|
735
|
+
}
|
|
736
|
+
isCompositionRoot(fileName) {
|
|
737
|
+
return matchesGlob(fileName, this.settings.compositionRoot);
|
|
738
|
+
}
|
|
739
|
+
contextFolderOf(path) {
|
|
740
|
+
return this.settings.contextFolders.filter((folder) => this.isInside(folder.dir, path)).sort((left, right) => right.dir.length - left.dir.length)[0];
|
|
741
|
+
}
|
|
742
|
+
directoriesBetween(dir, path) {
|
|
743
|
+
const between = relative(dir, dirname(path));
|
|
744
|
+
return between === "" ? [] : between.split(sep);
|
|
745
|
+
}
|
|
746
|
+
isInside(dir, path) {
|
|
747
|
+
const offset = relative(dir, path);
|
|
748
|
+
return offset !== "" && !offset.startsWith("..") && !isAbsolute(offset);
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
//#endregion
|
|
752
|
+
//#region src/architecture/architecture.ts
|
|
753
|
+
var Architecture = class {
|
|
754
|
+
project;
|
|
755
|
+
settings;
|
|
756
|
+
core = new CoreApi();
|
|
757
|
+
layers = new Layers();
|
|
758
|
+
blocks = new BuildingBlocks(this.core);
|
|
759
|
+
shape;
|
|
760
|
+
layout;
|
|
761
|
+
locations = /* @__PURE__ */ new Map();
|
|
762
|
+
constructor(project, settings) {
|
|
763
|
+
this.project = project;
|
|
764
|
+
this.settings = settings;
|
|
765
|
+
this.layout = new Layout(settings);
|
|
766
|
+
this.shape = new LayerShape(this.blocks, settings.extraFolders);
|
|
767
|
+
}
|
|
768
|
+
get files() {
|
|
769
|
+
return this.project.files;
|
|
770
|
+
}
|
|
771
|
+
get domainDependencies() {
|
|
772
|
+
return this.settings.domainDependencies;
|
|
773
|
+
}
|
|
774
|
+
get applicationDependencies() {
|
|
775
|
+
return this.settings.applicationDependencies;
|
|
776
|
+
}
|
|
777
|
+
get contextMap() {
|
|
778
|
+
return this.settings.contextMap;
|
|
779
|
+
}
|
|
780
|
+
locationOf(file) {
|
|
781
|
+
return this.locate(file.path);
|
|
782
|
+
}
|
|
783
|
+
locationOfTarget(target) {
|
|
784
|
+
if (target.visibility !== "analysed") return new Location({
|
|
785
|
+
area: "outside",
|
|
786
|
+
fileName: basename(target.path),
|
|
787
|
+
unseen: target.visibility
|
|
788
|
+
});
|
|
789
|
+
return this.locate(target.path);
|
|
790
|
+
}
|
|
791
|
+
shapeIssueOf(location) {
|
|
792
|
+
return this.shape.issueWith(location);
|
|
793
|
+
}
|
|
794
|
+
relativePath(path) {
|
|
795
|
+
return this.project.relativePath(path);
|
|
796
|
+
}
|
|
797
|
+
kindsOf(subject) {
|
|
798
|
+
return this.core.kindsOf(this.typeOf(subject));
|
|
799
|
+
}
|
|
800
|
+
kindOf(subject) {
|
|
801
|
+
return this.kindsOf(subject)[0];
|
|
802
|
+
}
|
|
803
|
+
is(subject, kind) {
|
|
804
|
+
return this.kindsOf(subject).includes(kind);
|
|
805
|
+
}
|
|
806
|
+
isBuildingBlock(subject) {
|
|
807
|
+
return this.kindsOf(subject).length > 0;
|
|
808
|
+
}
|
|
809
|
+
implementsMarker(declaration, marker) {
|
|
810
|
+
return this.core.markersOf(declaration.implemented).includes(marker);
|
|
811
|
+
}
|
|
812
|
+
returnsResult(member) {
|
|
813
|
+
return member.returns !== void 0 && this.core.isResult(member.returns);
|
|
814
|
+
}
|
|
815
|
+
classOf(type) {
|
|
816
|
+
return this.project.classOf(type);
|
|
817
|
+
}
|
|
818
|
+
locate(path) {
|
|
819
|
+
let location = this.locations.get(path);
|
|
820
|
+
if (location === void 0) {
|
|
821
|
+
location = this.layout.locate(path);
|
|
822
|
+
this.locations.set(path, location);
|
|
823
|
+
}
|
|
824
|
+
return location;
|
|
825
|
+
}
|
|
826
|
+
typeOf(subject) {
|
|
827
|
+
return subject instanceof ClassDeclaration ? subject.type : subject;
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
//#endregion
|
|
831
|
+
//#region src/architecture/context-map.ts
|
|
832
|
+
var ContextMap = class ContextMap {
|
|
833
|
+
upstreams;
|
|
834
|
+
constructor(upstreams) {
|
|
835
|
+
this.upstreams = upstreams;
|
|
836
|
+
}
|
|
837
|
+
static ofEdges(edges) {
|
|
838
|
+
const upstreams = {};
|
|
839
|
+
for (const { from, to } of edges) {
|
|
840
|
+
const known = upstreams[from] ?? [];
|
|
841
|
+
if (!known.includes(to)) upstreams[from] = [...known, to];
|
|
842
|
+
}
|
|
843
|
+
return new ContextMap(upstreams);
|
|
844
|
+
}
|
|
845
|
+
get contexts() {
|
|
846
|
+
const names = new Set(Object.keys(this.upstreams));
|
|
847
|
+
for (const targets of Object.values(this.upstreams)) for (const target of targets) names.add(target);
|
|
848
|
+
return [...names];
|
|
849
|
+
}
|
|
850
|
+
allows(from, to) {
|
|
851
|
+
return (this.upstreams[from] ?? []).includes(to);
|
|
852
|
+
}
|
|
853
|
+
cycle() {
|
|
854
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
855
|
+
const done = /* @__PURE__ */ new Set();
|
|
856
|
+
for (const start of this.contexts) {
|
|
857
|
+
const found = this.cycleFrom(start, [], visiting, done);
|
|
858
|
+
if (found !== void 0) return found;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
cycleThrough(from, to) {
|
|
862
|
+
return this.reaches(to, from, /* @__PURE__ */ new Set());
|
|
863
|
+
}
|
|
864
|
+
cycleFrom(context, path, visiting, done) {
|
|
865
|
+
if (done.has(context)) return;
|
|
866
|
+
if (visiting.has(context)) return [...path.slice(path.indexOf(context)), context];
|
|
867
|
+
visiting.add(context);
|
|
868
|
+
for (const upstream of this.upstreams[context] ?? []) {
|
|
869
|
+
const found = this.cycleFrom(upstream, [...path, context], visiting, done);
|
|
870
|
+
if (found !== void 0) return found;
|
|
871
|
+
}
|
|
872
|
+
visiting.delete(context);
|
|
873
|
+
done.add(context);
|
|
874
|
+
}
|
|
875
|
+
reaches(from, target, seen) {
|
|
876
|
+
if (from === target) return true;
|
|
877
|
+
if (seen.has(from)) return false;
|
|
878
|
+
seen.add(from);
|
|
879
|
+
return (this.upstreams[from] ?? []).some((upstream) => this.reaches(upstream, target, seen));
|
|
880
|
+
}
|
|
881
|
+
};
|
|
882
|
+
//#endregion
|
|
883
|
+
//#region src/rules/framework/wording.ts
|
|
884
|
+
var Wording = class {
|
|
885
|
+
target(target, architecture) {
|
|
886
|
+
return `${architecture.relativePath(target.path)} (${this.location(architecture.locationOfTarget(target))})`;
|
|
887
|
+
}
|
|
888
|
+
location(location) {
|
|
889
|
+
if (location.unseen === "ignored") return "ignored by the analysis";
|
|
890
|
+
if (location.unseen === "unresolved") return "not resolved by the analysis";
|
|
891
|
+
if (location.isOutside) return "outside the declared bounded contexts and shared kernel";
|
|
892
|
+
if (location.isAtRoot) return "at the root";
|
|
893
|
+
const role = location.isCompositionRoot ? "composition root" : location.layer ?? "outside the layers";
|
|
894
|
+
return `${location.context} ${role}`;
|
|
895
|
+
}
|
|
896
|
+
place(place) {
|
|
897
|
+
return place.folder === void 0 ? `${place.layer}/` : `${place.layer}/${place.folder}/*${place.suffix ?? ".ts"}`;
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
//#endregion
|
|
901
|
+
//#region src/rules/framework/rule.ts
|
|
902
|
+
var Rule = class {
|
|
903
|
+
wording = new Wording();
|
|
904
|
+
finding(file, line, symbol, messageId, data = {}) {
|
|
905
|
+
return {
|
|
906
|
+
data,
|
|
907
|
+
file,
|
|
908
|
+
line,
|
|
909
|
+
messageId,
|
|
910
|
+
symbol
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
//#endregion
|
|
915
|
+
//#region src/rules/framework/class-rule.ts
|
|
916
|
+
var ClassRule = class extends Rule {
|
|
917
|
+
check(architecture) {
|
|
918
|
+
const findings = [];
|
|
919
|
+
for (const file of architecture.files) for (const codeClass of file.classes) findings.push(...this.findingsFor(codeClass, file, architecture));
|
|
920
|
+
return findings;
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
//#endregion
|
|
924
|
+
//#region src/rules/framework/import-rule.ts
|
|
925
|
+
var ImportRule = class extends Rule {
|
|
926
|
+
check(architecture) {
|
|
927
|
+
const findings = [];
|
|
928
|
+
for (const file of architecture.files) {
|
|
929
|
+
if (!this.appliesTo(file, architecture)) continue;
|
|
930
|
+
for (const dependency of file.dependencies) {
|
|
931
|
+
const finding = this.findingFor(dependency, file, architecture);
|
|
932
|
+
if (finding !== void 0) findings.push(finding);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return findings;
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
//#endregion
|
|
939
|
+
//#region src/rules/framework/injection-rule.ts
|
|
940
|
+
var InjectionRule = class extends ClassRule {
|
|
941
|
+
findingsFor(codeClass, file, architecture) {
|
|
942
|
+
if (!architecture.is(codeClass, this.holder)) return [];
|
|
943
|
+
const findings = [];
|
|
944
|
+
for (const member of codeClass.heldMembers) for (const type of member.valueTypes) if (!this.accepts(type, architecture)) {
|
|
945
|
+
const data = {
|
|
946
|
+
class: codeClass.name,
|
|
947
|
+
kind: this.describeKind(type, architecture),
|
|
948
|
+
type: type.name
|
|
949
|
+
};
|
|
950
|
+
findings.push(this.finding(file, member.line, `${codeClass.name}.${member.name}`, "foreign", data));
|
|
951
|
+
}
|
|
952
|
+
return findings;
|
|
953
|
+
}
|
|
954
|
+
accepts(type, architecture) {
|
|
955
|
+
if (this.forbidden.some((kind) => architecture.is(type, kind))) return false;
|
|
956
|
+
if (this.allowed.some((kind) => architecture.is(type, kind))) return true;
|
|
957
|
+
return type.isInstalled && !architecture.isBuildingBlock(type);
|
|
958
|
+
}
|
|
959
|
+
describeKind(type, architecture) {
|
|
960
|
+
const kind = architecture.kindOf(type);
|
|
961
|
+
if (kind === void 0) return "a class that extends no building block";
|
|
962
|
+
return `${/^[AEIO]/.test(kind) ? "an" : "a"} ${kind}`;
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
//#endregion
|
|
966
|
+
//#region src/rules/layers/no-driving-shortcut.rule.ts
|
|
967
|
+
const shortcuts = [
|
|
968
|
+
"CommandRepository",
|
|
969
|
+
"QueryRepository",
|
|
970
|
+
"Port",
|
|
971
|
+
"AggregateRoot",
|
|
972
|
+
"Entity",
|
|
973
|
+
"DomainService"
|
|
974
|
+
];
|
|
975
|
+
var NoDrivingShortcutRule = class extends ImportRule {
|
|
976
|
+
meta = {
|
|
977
|
+
description: "A driving adapter importing a port, a repository, an aggregate, an entity or a domain service instead of calling a handler.",
|
|
978
|
+
id: "layers/no-driving-shortcut",
|
|
979
|
+
messages: { shortcut: "Imports {name}, {kind}: a driving adapter calls the command and query handlers, never the ports, repositories or aggregates of the domain." }
|
|
980
|
+
};
|
|
981
|
+
appliesTo(file, architecture) {
|
|
982
|
+
return architecture.locationOf(file).layer === "driving";
|
|
983
|
+
}
|
|
984
|
+
findingFor(dependency, file, architecture) {
|
|
985
|
+
if (dependency.target.kind !== "file") return;
|
|
986
|
+
const target = architecture.project.file(dependency.target.path);
|
|
987
|
+
if (target === void 0) return;
|
|
988
|
+
for (const codeClass of this.importedClasses(dependency, target)) {
|
|
989
|
+
const kind = this.shortcutKindOf(codeClass, architecture);
|
|
990
|
+
if (kind !== void 0) {
|
|
991
|
+
const article = /^[AEIO]/.test(kind) ? "an" : "a";
|
|
992
|
+
return this.finding(file, dependency.line, codeClass.name, "shortcut", {
|
|
993
|
+
kind: `${article} ${kind}`,
|
|
994
|
+
name: codeClass.name
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
importedClasses(dependency, target) {
|
|
1000
|
+
if (dependency.names.includes("*")) return [...target.classes];
|
|
1001
|
+
const classes = [];
|
|
1002
|
+
for (const name of dependency.names) {
|
|
1003
|
+
const codeClass = target.classNamed(name);
|
|
1004
|
+
if (codeClass !== void 0) classes.push(codeClass);
|
|
1005
|
+
}
|
|
1006
|
+
return classes;
|
|
1007
|
+
}
|
|
1008
|
+
shortcutKindOf(codeClass, architecture) {
|
|
1009
|
+
return architecture.kindsOf(codeClass).find((kind) => shortcuts.includes(kind));
|
|
1010
|
+
}
|
|
1011
|
+
};
|
|
1012
|
+
//#endregion
|
|
1013
|
+
//#region src/rules/layers/no-impure-domain.rule.ts
|
|
1014
|
+
var NoImpureDomainRule = class extends ImportRule {
|
|
1015
|
+
meta = {
|
|
1016
|
+
description: "The domain importing a framework, a database, another layer or a package not allowed, or using the host, the clock or randomness.",
|
|
1017
|
+
id: "layers/no-impure-domain",
|
|
1018
|
+
messages: {
|
|
1019
|
+
clock: "The domain reads the system clock with {name}: receive the time from the Clock port.",
|
|
1020
|
+
coreSymbol: "The domain imports {names} from @alveolus/core: only domain building blocks and Result are allowed.",
|
|
1021
|
+
hostGlobal: "The domain uses {name}, a global of the host: reach it through a port.",
|
|
1022
|
+
otherLayer: "The domain imports {target}: it may only import the domain.",
|
|
1023
|
+
packageName: "The domain imports {names} from {package}: domainDependencies only allows {allowed}.",
|
|
1024
|
+
randomness: "The domain draws a random value with {name}: receive it from a port, such as IdGenerator.",
|
|
1025
|
+
undeclaredPackage: "The domain imports {package}: add it to domainDependencies if the domain really needs it."
|
|
1026
|
+
}
|
|
1027
|
+
};
|
|
1028
|
+
check(architecture) {
|
|
1029
|
+
const findings = super.check(architecture);
|
|
1030
|
+
for (const file of architecture.files) if (this.appliesTo(file, architecture)) findings.push(...this.impureGlobals(file));
|
|
1031
|
+
return findings;
|
|
1032
|
+
}
|
|
1033
|
+
appliesTo(file, architecture) {
|
|
1034
|
+
return architecture.locationOf(file).layer === "domain";
|
|
1035
|
+
}
|
|
1036
|
+
findingFor(dependency, file, architecture) {
|
|
1037
|
+
const { line, label } = dependency;
|
|
1038
|
+
const target = dependency.target;
|
|
1039
|
+
if (target.kind === "file") {
|
|
1040
|
+
if (this.isDomainReachable(architecture.locationOfTarget(target), architecture.locationOf(file))) return;
|
|
1041
|
+
return this.finding(file, line, label, "otherLayer", { target: this.wording.target(target, architecture) });
|
|
1042
|
+
}
|
|
1043
|
+
if (target.name === architecture.core.packageName) {
|
|
1044
|
+
const forbidden = dependency.names.filter((name) => !architecture.core.isDomainSymbol(name));
|
|
1045
|
+
return forbidden.length === 0 ? void 0 : this.finding(file, line, label, "coreSymbol", { names: forbidden.join(", ") });
|
|
1046
|
+
}
|
|
1047
|
+
const allowed = architecture.domainDependencies;
|
|
1048
|
+
if (!allowed.has(target.name)) return this.finding(file, line, label, "undeclaredPackage", { package: target.name });
|
|
1049
|
+
const forbidden = allowed.forbiddenNames(target.name, dependency.names);
|
|
1050
|
+
if (forbidden.length === 0) return;
|
|
1051
|
+
return this.finding(file, line, label, "packageName", {
|
|
1052
|
+
allowed: allowed.allowedNames(target.name).join(", "),
|
|
1053
|
+
names: forbidden.join(", "),
|
|
1054
|
+
package: target.name
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
impureGlobals(file) {
|
|
1058
|
+
const findings = [];
|
|
1059
|
+
for (const use of file.globals) {
|
|
1060
|
+
const messageId = this.messageFor(use);
|
|
1061
|
+
if (messageId !== void 0) findings.push(this.finding(file, use.line, use.name, messageId, { name: use.name }));
|
|
1062
|
+
}
|
|
1063
|
+
return findings;
|
|
1064
|
+
}
|
|
1065
|
+
messageFor(use) {
|
|
1066
|
+
if (use.origin === "host") return "hostGlobal";
|
|
1067
|
+
return use.effect;
|
|
1068
|
+
}
|
|
1069
|
+
isDomainReachable(to, from) {
|
|
1070
|
+
return to.layer === "domain" && (to.isSameContextAs(from) || to.isInSharedKernel);
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
//#endregion
|
|
1074
|
+
//#region src/rules/layers/no-outward-import.rule.ts
|
|
1075
|
+
var NoOutwardImportRule = class extends ImportRule {
|
|
1076
|
+
meta = {
|
|
1077
|
+
description: "A dependency pointing away from the domain, a file outside the layers or in the wrong folder of its layer.",
|
|
1078
|
+
id: "layers/no-outward-import",
|
|
1079
|
+
messages: {
|
|
1080
|
+
compositionRoots: "{context} has {count} composition roots ({names}): keep one, and move the rest into the layers.",
|
|
1081
|
+
directlyInLayer: "The file sits directly in {layer}/: put it in the folder of its kind, such as {example}.",
|
|
1082
|
+
drivenTooDeep: "The file is nested too deep: driven/ holds driven/<technology>/<folder>/, such as driven/pg/adapters/.",
|
|
1083
|
+
drivenWithoutTechnology: "The file is not under a technology: driven/ holds driven/<technology>/<folder>/, such as driven/pg/adapters/.",
|
|
1084
|
+
drivingWithoutTechnology: "The file is not under a technology: driving/ holds driving/<technology>/, such as driving/http/.",
|
|
1085
|
+
nestedPublishedLanguage: "The file is nested in published-language/: the published language holds its files directly.",
|
|
1086
|
+
outsideContexts: "The file is outside the bounded contexts and the shared kernel declared in alveolus.config.ts: move it, or add it to ignore.",
|
|
1087
|
+
outsideLayers: "The file is outside the layers: move it to domain/, application/, published-language/, driven/ or driving/.",
|
|
1088
|
+
outwardLayer: "The {layer} layer imports {target}: it may only import {allowed}.",
|
|
1089
|
+
packageName: "The application imports {names} from {package}: applicationDependencies only allows {allowed}.",
|
|
1090
|
+
publishedLanguageCore: "The published language imports {names} from @alveolus/core: only published-language types are allowed.",
|
|
1091
|
+
rootImport: "Files at the root import composition roots only, not {target}.",
|
|
1092
|
+
tooDeep: "The file is nested too deep: {layer}/ holds one folder per kind, such as {example}.",
|
|
1093
|
+
undeclaredPackage: "The application imports {package}: add it to applicationDependencies if the application really needs it.",
|
|
1094
|
+
unknownFolder: "{layer}/{folder}/ is no folder of the {layer}: use {folders}.",
|
|
1095
|
+
wiring: "Imports {target}: only the composition root wires the layers."
|
|
1096
|
+
}
|
|
1097
|
+
};
|
|
1098
|
+
check(architecture) {
|
|
1099
|
+
return [
|
|
1100
|
+
...this.misplacedFiles(architecture),
|
|
1101
|
+
...this.extraCompositionRoots(architecture),
|
|
1102
|
+
...super.check(architecture)
|
|
1103
|
+
];
|
|
1104
|
+
}
|
|
1105
|
+
appliesTo(file, architecture) {
|
|
1106
|
+
const location = architecture.locationOf(file);
|
|
1107
|
+
return location.isAtRoot || location.isCompositionRoot || location.isInLayer && location.layer !== "domain";
|
|
1108
|
+
}
|
|
1109
|
+
findingFor(dependency, file, architecture) {
|
|
1110
|
+
const from = architecture.locationOf(file);
|
|
1111
|
+
const target = dependency.target;
|
|
1112
|
+
if (target.kind === "file") {
|
|
1113
|
+
const problem = this.layerProblem(from, architecture.locationOfTarget(target), architecture);
|
|
1114
|
+
if (problem === void 0) return;
|
|
1115
|
+
return this.finding(file, dependency.line, dependency.label, problem.messageId, {
|
|
1116
|
+
...problem.data,
|
|
1117
|
+
target: this.wording.target(target, architecture)
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
if (from.layer === "application") return this.applicationPackageFinding(dependency, target.name, file, architecture);
|
|
1121
|
+
if (from.layer === "published-language" && target.name === architecture.core.packageName) {
|
|
1122
|
+
const forbidden = dependency.names.filter((name) => !architecture.core.isPublishedLanguageSymbol(name));
|
|
1123
|
+
return forbidden.length === 0 ? void 0 : this.finding(file, dependency.line, dependency.label, "publishedLanguageCore", { names: forbidden.join(", ") });
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
misplacedFiles(architecture) {
|
|
1127
|
+
const findings = [];
|
|
1128
|
+
for (const file of architecture.files) {
|
|
1129
|
+
const location = architecture.locationOf(file);
|
|
1130
|
+
if (location.isOutside) findings.push(this.finding(file, 1, location.fileName, "outsideContexts"));
|
|
1131
|
+
else if (!location.isAtRoot && !location.isInLayer && !location.isCompositionRoot) findings.push(this.finding(file, 1, location.fileName, "outsideLayers"));
|
|
1132
|
+
else if (location.isInLayer) {
|
|
1133
|
+
const issue = architecture.shapeIssueOf(location);
|
|
1134
|
+
if (issue !== void 0) findings.push(this.finding(file, 1, location.fileName, issue.kind, this.shapeData(issue)));
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
return findings;
|
|
1138
|
+
}
|
|
1139
|
+
shapeData(issue) {
|
|
1140
|
+
if (issue.kind === "unknownFolder") return {
|
|
1141
|
+
folder: issue.folder,
|
|
1142
|
+
folders: issue.folders.map((name) => `${name}/`).join(", "),
|
|
1143
|
+
layer: issue.layer
|
|
1144
|
+
};
|
|
1145
|
+
if (issue.kind === "directlyInLayer" || issue.kind === "tooDeep") return {
|
|
1146
|
+
example: issue.example,
|
|
1147
|
+
layer: issue.layer
|
|
1148
|
+
};
|
|
1149
|
+
return {};
|
|
1150
|
+
}
|
|
1151
|
+
extraCompositionRoots(architecture) {
|
|
1152
|
+
const byFolder = /* @__PURE__ */ new Map();
|
|
1153
|
+
for (const file of architecture.files) if (architecture.locationOf(file).isCompositionRoot) {
|
|
1154
|
+
const folder = dirname(file.path);
|
|
1155
|
+
byFolder.set(folder, [...byFolder.get(folder) ?? [], file]);
|
|
1156
|
+
}
|
|
1157
|
+
const findings = [];
|
|
1158
|
+
for (const roots of byFolder.values()) {
|
|
1159
|
+
if (roots.length < 2) continue;
|
|
1160
|
+
const names = roots.map((root) => basename(root.path)).sort();
|
|
1161
|
+
for (const root of roots) {
|
|
1162
|
+
const location = architecture.locationOf(root);
|
|
1163
|
+
const data = {
|
|
1164
|
+
context: location.context ?? "",
|
|
1165
|
+
count: roots.length,
|
|
1166
|
+
names: names.join(", ")
|
|
1167
|
+
};
|
|
1168
|
+
findings.push(this.finding(root, 1, location.fileName, "compositionRoots", data));
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
return findings;
|
|
1172
|
+
}
|
|
1173
|
+
layerProblem(from, to, architecture) {
|
|
1174
|
+
if (from.isAtRoot) return to.isInBoundedContext && !to.isCompositionRoot ? {
|
|
1175
|
+
data: {},
|
|
1176
|
+
messageId: "rootImport"
|
|
1177
|
+
} : void 0;
|
|
1178
|
+
if (from.isCompositionRoot || to.isOtherBoundedContextThan(from)) return;
|
|
1179
|
+
if (to.isCompositionRoot) return {
|
|
1180
|
+
data: {},
|
|
1181
|
+
messageId: "wiring"
|
|
1182
|
+
};
|
|
1183
|
+
const layer = from.layer;
|
|
1184
|
+
if (layer === void 0 || layer === "domain") return;
|
|
1185
|
+
const allowed = architecture.layers.importableFrom(layer);
|
|
1186
|
+
if (to.layer !== void 0 && allowed.includes(to.layer)) return;
|
|
1187
|
+
return {
|
|
1188
|
+
data: {
|
|
1189
|
+
allowed: allowed.join(", "),
|
|
1190
|
+
layer
|
|
1191
|
+
},
|
|
1192
|
+
messageId: "outwardLayer"
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
applicationPackageFinding(dependency, packageName, file, architecture) {
|
|
1196
|
+
if (packageName === architecture.core.packageName) return;
|
|
1197
|
+
const { line, label } = dependency;
|
|
1198
|
+
const allowed = architecture.applicationDependencies;
|
|
1199
|
+
if (!allowed.has(packageName)) return this.finding(file, line, label, "undeclaredPackage", { package: packageName });
|
|
1200
|
+
const forbidden = allowed.forbiddenNames(packageName, dependency.names);
|
|
1201
|
+
if (forbidden.length === 0) return;
|
|
1202
|
+
return this.finding(file, line, label, "packageName", {
|
|
1203
|
+
allowed: allowed.allowedNames(packageName).join(", "),
|
|
1204
|
+
names: forbidden.join(", "),
|
|
1205
|
+
package: packageName
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
};
|
|
1209
|
+
//#endregion
|
|
1210
|
+
//#region src/rules/layers/no-portless-adapter.rule.ts
|
|
1211
|
+
var NoPortlessAdapterRule = class extends ClassRule {
|
|
1212
|
+
meta = {
|
|
1213
|
+
description: "A driven adapter that extends no port, a port declared outside the domain.",
|
|
1214
|
+
id: "layers/no-portless-adapter",
|
|
1215
|
+
messages: {
|
|
1216
|
+
portless: "{class} is a driven adapter but extends no Port: extend the port it implements.",
|
|
1217
|
+
portOutsideDomain: "The port {class} is declared outside the domain: move it to domain/ports/ or domain/repositories/."
|
|
1218
|
+
}
|
|
1219
|
+
};
|
|
1220
|
+
findingsFor(codeClass, file, architecture) {
|
|
1221
|
+
const location = architecture.locationOf(file);
|
|
1222
|
+
const isPort = architecture.is(codeClass, "Port");
|
|
1223
|
+
if (this.isAdaptersFolder(location) && !isPort) return [this.finding(file, codeClass.line, codeClass.name, "portless", { class: codeClass.name })];
|
|
1224
|
+
if (isPort && codeClass.isAbstract && !this.isPortsFolder(location) && !this.isAdaptersFolder(location)) return [this.finding(file, codeClass.line, codeClass.name, "portOutsideDomain", { class: codeClass.name })];
|
|
1225
|
+
return [];
|
|
1226
|
+
}
|
|
1227
|
+
isAdaptersFolder(location) {
|
|
1228
|
+
return location.layer === "driven" && location.folder === "adapters";
|
|
1229
|
+
}
|
|
1230
|
+
isPortsFolder(location) {
|
|
1231
|
+
return location.layer === "domain" && (location.folder === "ports" || location.folder === "repositories");
|
|
1232
|
+
}
|
|
1233
|
+
};
|
|
1234
|
+
//#endregion
|
|
1235
|
+
//#region src/rules/strategic/no-cross-context-import.rule.ts
|
|
1236
|
+
var NoCrossContextImportRule = class extends ImportRule {
|
|
1237
|
+
meta = {
|
|
1238
|
+
description: "An import from another bounded context that is not its open host service, a composition root that re-exports.",
|
|
1239
|
+
id: "strategic/no-cross-context-import",
|
|
1240
|
+
messages: {
|
|
1241
|
+
notOpenHostService: "Imports {target}: only an OpenHostService of another bounded context may be imported.",
|
|
1242
|
+
outsideAntiCorruptionLayer: "Uses the open host service of {context} outside an AntiCorruptionLayer: translate it in an anti-corruption layer.",
|
|
1243
|
+
publishedLanguage: "Imports the published language of {context}: redeclare the fields you read in your own published-language/.",
|
|
1244
|
+
reexport: "The composition root re-exports {names}: it exports its own module only, so that no other context reaches through it.",
|
|
1245
|
+
sharedKernel: "The shared kernel imports no bounded context, but imports {target}."
|
|
1246
|
+
}
|
|
1247
|
+
};
|
|
1248
|
+
appliesTo(file, architecture) {
|
|
1249
|
+
const location = architecture.locationOf(file);
|
|
1250
|
+
return location.isInBoundedContext || location.isInSharedKernel;
|
|
1251
|
+
}
|
|
1252
|
+
findingFor(dependency, file, architecture) {
|
|
1253
|
+
const from = architecture.locationOf(file);
|
|
1254
|
+
if (from.isCompositionRoot && dependency.form === "re-export") return this.finding(file, dependency.line, dependency.label, "reexport", { names: dependency.label });
|
|
1255
|
+
const target = dependency.target;
|
|
1256
|
+
if (target.kind !== "file") return;
|
|
1257
|
+
const to = architecture.locationOfTarget(target);
|
|
1258
|
+
if (!to.isOtherBoundedContextThan(from)) return;
|
|
1259
|
+
if (from.isInSharedKernel) return this.finding(file, dependency.line, dependency.label, "sharedKernel", { target: this.wording.target(target, architecture) });
|
|
1260
|
+
if (from.isCompositionRoot && to.isCompositionRoot) return;
|
|
1261
|
+
if (to.layer === "published-language") return this.finding(file, dependency.line, dependency.label, "publishedLanguage", { context: to.context ?? "" });
|
|
1262
|
+
if (!this.importsOnlyOpenHostServices(dependency, target.path, architecture)) return this.finding(file, dependency.line, dependency.label, "notOpenHostService", { target: this.wording.target(target, architecture) });
|
|
1263
|
+
if (!from.isCompositionRoot && !this.declaresAntiCorruptionLayer(file, architecture)) return this.finding(file, dependency.line, dependency.label, "outsideAntiCorruptionLayer", { context: to.context ?? "" });
|
|
1264
|
+
}
|
|
1265
|
+
importsOnlyOpenHostServices(dependency, path, architecture) {
|
|
1266
|
+
const target = architecture.project.file(path);
|
|
1267
|
+
if (target === void 0 || dependency.names.length === 0) return false;
|
|
1268
|
+
return dependency.names.every((name) => {
|
|
1269
|
+
const declaration = target.classNamed(name);
|
|
1270
|
+
return declaration !== void 0 && architecture.implementsMarker(declaration, "OpenHostService");
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
declaresAntiCorruptionLayer(file, architecture) {
|
|
1274
|
+
return file.classes.some((codeClass) => architecture.implementsMarker(codeClass, "AntiCorruptionLayer"));
|
|
1275
|
+
}
|
|
1276
|
+
};
|
|
1277
|
+
//#endregion
|
|
1278
|
+
//#region src/rules/strategic/no-fat-shared-kernel.rule.ts
|
|
1279
|
+
const owned = [
|
|
1280
|
+
"AggregateRoot",
|
|
1281
|
+
"Entity",
|
|
1282
|
+
"DomainEvent",
|
|
1283
|
+
"DomainService",
|
|
1284
|
+
"CommandRepository",
|
|
1285
|
+
"QueryRepository",
|
|
1286
|
+
"CommandHandler",
|
|
1287
|
+
"QueryHandler",
|
|
1288
|
+
"EventTranslator"
|
|
1289
|
+
];
|
|
1290
|
+
var NoFatSharedKernelRule = class extends ClassRule {
|
|
1291
|
+
meta = {
|
|
1292
|
+
description: "An aggregate, an entity, an event, a domain service, a repository or a handler in the shared kernel.",
|
|
1293
|
+
id: "strategic/no-fat-shared-kernel",
|
|
1294
|
+
messages: { owned: "{class} is {kind} in the shared kernel: it belongs to one bounded context; the shared kernel holds value objects, ports and their adapters." }
|
|
1295
|
+
};
|
|
1296
|
+
findingsFor(codeClass, file, architecture) {
|
|
1297
|
+
if (!architecture.locationOf(file).isInSharedKernel) return [];
|
|
1298
|
+
const kind = architecture.kindsOf(codeClass).find((candidate) => owned.includes(candidate));
|
|
1299
|
+
if (kind === void 0) return [];
|
|
1300
|
+
const article = /^[AEIO]/.test(kind) ? "an" : "a";
|
|
1301
|
+
return [this.finding(file, codeClass.line, codeClass.name, "owned", {
|
|
1302
|
+
class: codeClass.name,
|
|
1303
|
+
kind: `${article} ${kind}`
|
|
1304
|
+
})];
|
|
1305
|
+
}
|
|
1306
|
+
};
|
|
1307
|
+
//#endregion
|
|
1308
|
+
//#region src/rules/strategic/no-leaky-host-service.rule.ts
|
|
1309
|
+
var NoLeakyHostServiceRule = class extends ClassRule {
|
|
1310
|
+
meta = {
|
|
1311
|
+
description: "An open host service that exposes a class of its context instead of the published language.",
|
|
1312
|
+
id: "strategic/no-leaky-host-service",
|
|
1313
|
+
messages: { exposes: "{member} exposes {type}, {kind}{owner}: an open host service speaks the published language." }
|
|
1314
|
+
};
|
|
1315
|
+
findingsFor(codeClass, file, architecture) {
|
|
1316
|
+
if (!architecture.implementsMarker(codeClass, "OpenHostService")) return [];
|
|
1317
|
+
const findings = /* @__PURE__ */ new Map();
|
|
1318
|
+
for (const member of codeClass.publicSurface) {
|
|
1319
|
+
const symbol = `${codeClass.name}.${member.name}`;
|
|
1320
|
+
for (const type of [...member.parameterTypes, ...member.valueTypes]) {
|
|
1321
|
+
const key = `${symbol}:${type.name}`;
|
|
1322
|
+
if (this.leaks(type, architecture) && !findings.has(key)) findings.set(key, this.finding(file, member.line, symbol, "exposes", this.describe(symbol, type, architecture)));
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
return [...findings.values()];
|
|
1326
|
+
}
|
|
1327
|
+
leaks(type, architecture) {
|
|
1328
|
+
if (type.isInstalled) return false;
|
|
1329
|
+
const declaringFile = type.declaredIn === void 0 ? void 0 : architecture.project.file(type.declaredIn);
|
|
1330
|
+
return declaringFile === void 0 || !architecture.locationOf(declaringFile).isInSharedKernel;
|
|
1331
|
+
}
|
|
1332
|
+
describe(symbol, type, architecture) {
|
|
1333
|
+
const kind = architecture.kindOf(type) ?? "class";
|
|
1334
|
+
const article = /^[AEIO]/.test(kind) ? "an" : "a";
|
|
1335
|
+
const declaringFile = type.declaredIn === void 0 ? void 0 : architecture.project.file(type.declaredIn);
|
|
1336
|
+
const context = declaringFile === void 0 ? void 0 : architecture.locationOf(declaringFile).context;
|
|
1337
|
+
return {
|
|
1338
|
+
kind: `${article} ${kind}`,
|
|
1339
|
+
member: symbol,
|
|
1340
|
+
owner: context === void 0 ? "" : ` of ${context}`,
|
|
1341
|
+
type: type.name
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
};
|
|
1345
|
+
//#endregion
|
|
1346
|
+
//#region src/rules/strategic/no-unmapped-context.rule.ts
|
|
1347
|
+
var NoUnmappedContextRule = class extends Rule {
|
|
1348
|
+
meta = {
|
|
1349
|
+
description: "A bounded context consuming one the context map does not allow, or two contexts that depend on each other.",
|
|
1350
|
+
id: "strategic/no-unmapped-context",
|
|
1351
|
+
messages: {
|
|
1352
|
+
cycle: "{from} consumes {to}, which consumes {from} back: two contexts that depend on each other can no longer change alone; declare a contextMap and reverse one dependency.",
|
|
1353
|
+
unmapped: "{from} consumes {to}, which the context map does not allow: add {to} to contextMap.{from}, or reverse the dependency."
|
|
1354
|
+
}
|
|
1355
|
+
};
|
|
1356
|
+
check(architecture) {
|
|
1357
|
+
const consumptions = this.consumptionsIn(architecture);
|
|
1358
|
+
const declared = architecture.contextMap;
|
|
1359
|
+
const findings = [];
|
|
1360
|
+
if (declared !== void 0) {
|
|
1361
|
+
for (const consumption of consumptions) if (!declared.allows(consumption.from, consumption.to)) findings.push(this.consumptionFinding(consumption, "unmapped"));
|
|
1362
|
+
return findings;
|
|
1363
|
+
}
|
|
1364
|
+
const observed = ContextMap.ofEdges(consumptions);
|
|
1365
|
+
for (const consumption of consumptions) if (observed.cycleThrough(consumption.from, consumption.to)) findings.push(this.consumptionFinding(consumption, "cycle"));
|
|
1366
|
+
return findings;
|
|
1367
|
+
}
|
|
1368
|
+
consumptionsIn(architecture) {
|
|
1369
|
+
const consumptions = [];
|
|
1370
|
+
for (const file of architecture.files) {
|
|
1371
|
+
const from = architecture.locationOf(file);
|
|
1372
|
+
if (!from.isInBoundedContext || from.context === void 0) continue;
|
|
1373
|
+
for (const dependency of file.dependencies) {
|
|
1374
|
+
if (dependency.target.kind !== "file") continue;
|
|
1375
|
+
const to = architecture.locationOfTarget(dependency.target);
|
|
1376
|
+
if (to.isOtherBoundedContextThan(from) && to.context !== void 0) consumptions.push({
|
|
1377
|
+
dependency,
|
|
1378
|
+
file,
|
|
1379
|
+
from: from.context,
|
|
1380
|
+
to: to.context
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
return consumptions;
|
|
1385
|
+
}
|
|
1386
|
+
consumptionFinding(consumption, messageId) {
|
|
1387
|
+
const { dependency, file, from, to } = consumption;
|
|
1388
|
+
return this.finding(file, dependency.line, dependency.label, messageId, {
|
|
1389
|
+
from,
|
|
1390
|
+
to
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
//#endregion
|
|
1395
|
+
//#region src/rules/tactical/aggregates/no-aggregate-reference.rule.ts
|
|
1396
|
+
const holders = [
|
|
1397
|
+
"Entity",
|
|
1398
|
+
"ValueObject",
|
|
1399
|
+
"DomainEvent"
|
|
1400
|
+
];
|
|
1401
|
+
var NoAggregateReferenceRule = class extends Rule {
|
|
1402
|
+
meta = {
|
|
1403
|
+
description: "An aggregate holding another aggregate instead of its identifier, an entity held by two aggregates.",
|
|
1404
|
+
id: "tactical/no-aggregate-reference",
|
|
1405
|
+
messages: {
|
|
1406
|
+
aggregateInTypeArgument: "{class} holds the aggregate {type} in its {parameter}: reference it by its identifier instead.",
|
|
1407
|
+
heldAggregate: "{member} holds the aggregate {type}: reference it by its identifier instead.",
|
|
1408
|
+
sharedEntity: "{member} holds the entity {entity}, which {others} {verb} too: an entity belongs to one aggregate."
|
|
1409
|
+
}
|
|
1410
|
+
};
|
|
1411
|
+
check(architecture) {
|
|
1412
|
+
const findings = [];
|
|
1413
|
+
for (const file of architecture.files) for (const codeClass of file.classes) findings.push(...this.heldAggregates(codeClass, file, architecture));
|
|
1414
|
+
findings.push(...this.sharedEntities(architecture));
|
|
1415
|
+
return findings;
|
|
1416
|
+
}
|
|
1417
|
+
heldAggregates(codeClass, file, architecture) {
|
|
1418
|
+
if (!holders.some((kind) => architecture.is(codeClass, kind))) return [];
|
|
1419
|
+
const findings = [];
|
|
1420
|
+
for (const member of codeClass.heldMembers) for (const type of member.valueTypes) if (architecture.is(type, "AggregateRoot")) {
|
|
1421
|
+
const symbol = `${codeClass.name}.${member.name}`;
|
|
1422
|
+
findings.push(this.finding(file, member.line, symbol, "heldAggregate", {
|
|
1423
|
+
member: symbol,
|
|
1424
|
+
type: type.name
|
|
1425
|
+
}));
|
|
1426
|
+
}
|
|
1427
|
+
for (const argument of codeClass.typeArguments) for (const type of argument.types) if (architecture.is(type, "AggregateRoot")) {
|
|
1428
|
+
const data = {
|
|
1429
|
+
class: codeClass.name,
|
|
1430
|
+
parameter: argument.parameter,
|
|
1431
|
+
type: type.name
|
|
1432
|
+
};
|
|
1433
|
+
findings.push(this.finding(file, argument.line, `${codeClass.name}.${argument.parameter}`, "aggregateInTypeArgument", data));
|
|
1434
|
+
}
|
|
1435
|
+
return findings;
|
|
1436
|
+
}
|
|
1437
|
+
sharedEntities(architecture) {
|
|
1438
|
+
const holdings = this.holdingsIn(architecture);
|
|
1439
|
+
const findings = [];
|
|
1440
|
+
for (const holding of holdings) {
|
|
1441
|
+
const others = this.otherOwners(holding, holdings);
|
|
1442
|
+
if (others.length > 0) {
|
|
1443
|
+
const symbol = `${holding.aggregate.name}.${holding.through.name}`;
|
|
1444
|
+
const data = {
|
|
1445
|
+
entity: holding.entity.name,
|
|
1446
|
+
member: symbol,
|
|
1447
|
+
others: others.join(" and "),
|
|
1448
|
+
verb: others.length === 1 ? "holds" : "hold"
|
|
1449
|
+
};
|
|
1450
|
+
findings.push(this.finding(holding.file, holding.through.line, symbol, "sharedEntity", data));
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
return findings;
|
|
1454
|
+
}
|
|
1455
|
+
otherOwners(holding, holdings) {
|
|
1456
|
+
const others = /* @__PURE__ */ new Set();
|
|
1457
|
+
for (const other of holdings) if (other.aggregate !== holding.aggregate && other.entity.key === holding.entity.key) others.add(other.aggregate.name);
|
|
1458
|
+
return [...others].sort();
|
|
1459
|
+
}
|
|
1460
|
+
holdingsIn(architecture) {
|
|
1461
|
+
const holdings = [];
|
|
1462
|
+
for (const file of architecture.files) for (const aggregate of file.classes) {
|
|
1463
|
+
if (!architecture.is(aggregate, "AggregateRoot")) continue;
|
|
1464
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1465
|
+
for (const through of this.heldBy(aggregate)) for (const type of through.types) for (const entity of this.entitiesReachedFrom(type, architecture, visited)) holdings.push({
|
|
1466
|
+
aggregate,
|
|
1467
|
+
entity,
|
|
1468
|
+
file,
|
|
1469
|
+
through
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
return holdings;
|
|
1473
|
+
}
|
|
1474
|
+
entitiesReachedFrom(type, architecture, visited) {
|
|
1475
|
+
if (architecture.is(type, "AggregateRoot") || visited.has(type.key)) return [];
|
|
1476
|
+
visited.add(type.key);
|
|
1477
|
+
const isEntity = architecture.is(type, "Entity");
|
|
1478
|
+
const reached = isEntity ? [type] : [];
|
|
1479
|
+
const declaration = architecture.classOf(type);
|
|
1480
|
+
if (declaration === void 0 || !(isEntity || architecture.is(type, "ValueObject"))) return reached;
|
|
1481
|
+
for (const held of this.heldBy(declaration)) for (const inner of held.types) reached.push(...this.entitiesReachedFrom(inner, architecture, visited));
|
|
1482
|
+
return reached;
|
|
1483
|
+
}
|
|
1484
|
+
heldBy(codeClass) {
|
|
1485
|
+
const held = codeClass.heldMembers.map((member) => ({
|
|
1486
|
+
line: member.line,
|
|
1487
|
+
name: member.name,
|
|
1488
|
+
types: member.valueTypes
|
|
1489
|
+
}));
|
|
1490
|
+
for (const argument of codeClass.typeArguments) held.push({
|
|
1491
|
+
line: argument.line,
|
|
1492
|
+
name: argument.parameter,
|
|
1493
|
+
types: argument.types
|
|
1494
|
+
});
|
|
1495
|
+
return held;
|
|
1496
|
+
}
|
|
1497
|
+
};
|
|
1498
|
+
//#endregion
|
|
1499
|
+
//#region src/rules/tactical/building-blocks/no-loose-code.rule.ts
|
|
1500
|
+
const guarded$2 = /* @__PURE__ */ new Set(["domain", "application"]);
|
|
1501
|
+
const adapters = /* @__PURE__ */ new Set(["driven", "driving"]);
|
|
1502
|
+
const expectedBlocks = {
|
|
1503
|
+
application: "CommandHandler, QueryHandler or EventTranslator",
|
|
1504
|
+
domain: "AggregateRoot, Entity, ValueObject, Identifier, DomainEvent, DomainError, DomainService or a Port"
|
|
1505
|
+
};
|
|
1506
|
+
const statementMessages = {
|
|
1507
|
+
"class expression": "classExpression",
|
|
1508
|
+
"computed constant": "computedConstant",
|
|
1509
|
+
enum: "enum",
|
|
1510
|
+
function: "function",
|
|
1511
|
+
"mutable variable": "mutableVariable",
|
|
1512
|
+
namespace: "namespace",
|
|
1513
|
+
statement: "statement"
|
|
1514
|
+
};
|
|
1515
|
+
var NoLooseCodeRule = class extends Rule {
|
|
1516
|
+
meta = {
|
|
1517
|
+
description: "Code outside a building block in the domain or the application, outside a class in an adapter layer, anything but the module class in a composition root.",
|
|
1518
|
+
id: "tactical/no-loose-code",
|
|
1519
|
+
messages: {
|
|
1520
|
+
adapterDeclaration: "The {kind} {name} has no place in an adapter layer: adapters are classes; make it a method of the adapter, or a mapper class of its own.",
|
|
1521
|
+
adapterStatement: "A statement runs when the module loads: an adapter layer holds classes, which the composition root wires.",
|
|
1522
|
+
classExpression: "{name} is a class expression: declare it as a class that extends a building block.",
|
|
1523
|
+
compositionRootDeclaration: "The {kind} {name} has no place in a composition root: it holds its module class only.",
|
|
1524
|
+
compositionRootStatement: "A statement runs when the module loads: the composition root holds its module class only.",
|
|
1525
|
+
computedConstant: "The constant {name} is computed when the module loads: keep top-level constants to plain data.",
|
|
1526
|
+
enum: "The enum {name} has no place here: use a union of literal types, or a ValueObject when it has behaviour.",
|
|
1527
|
+
extendsExpression: "{name} extends an expression: extend a class by its name, so that what it is stays readable.",
|
|
1528
|
+
function: "The function {name} floats outside any class: make it a method of a value object or of a DomainService.",
|
|
1529
|
+
mutableVariable: "{name} is module state: keep state in aggregates, not in modules.",
|
|
1530
|
+
namespace: "The namespace {name} groups loose code: make it a method of a value object or of a DomainService.",
|
|
1531
|
+
plainClass: "{name} extends no building block: extend {blocks}.",
|
|
1532
|
+
statement: "A statement runs when the module loads: move it into a method.",
|
|
1533
|
+
staticOnly: "{name} only has static members: a class of functions is no building block; make them methods of the value object they work on, or of a DomainService."
|
|
1534
|
+
}
|
|
1535
|
+
};
|
|
1536
|
+
check(architecture) {
|
|
1537
|
+
const findings = [];
|
|
1538
|
+
for (const file of architecture.files) {
|
|
1539
|
+
const location = architecture.locationOf(file);
|
|
1540
|
+
if (guarded$2.has(location.layer)) {
|
|
1541
|
+
findings.push(...this.looseClasses(file, location.layer ?? "domain", architecture));
|
|
1542
|
+
findings.push(...this.looseStatements(file));
|
|
1543
|
+
} else if (location.isCompositionRoot && location.isInBoundedContext) findings.push(...this.compositionRootStatements(file));
|
|
1544
|
+
else if (adapters.has(location.layer)) findings.push(...this.adapterStatements(file));
|
|
1545
|
+
}
|
|
1546
|
+
return findings;
|
|
1547
|
+
}
|
|
1548
|
+
looseClasses(file, layer, architecture) {
|
|
1549
|
+
const findings = [];
|
|
1550
|
+
for (const codeClass of file.classes) {
|
|
1551
|
+
const messageId = this.classMessage(codeClass, architecture);
|
|
1552
|
+
if (messageId !== void 0) findings.push(this.finding(file, codeClass.line, codeClass.name, messageId, {
|
|
1553
|
+
blocks: expectedBlocks[layer] ?? "",
|
|
1554
|
+
name: codeClass.name
|
|
1555
|
+
}));
|
|
1556
|
+
}
|
|
1557
|
+
return findings;
|
|
1558
|
+
}
|
|
1559
|
+
classMessage(codeClass, architecture) {
|
|
1560
|
+
if (!codeClass.extendsByName) return "extendsExpression";
|
|
1561
|
+
if (!architecture.isBuildingBlock(codeClass)) return "plainClass";
|
|
1562
|
+
return codeClass.isStaticOnly ? "staticOnly" : void 0;
|
|
1563
|
+
}
|
|
1564
|
+
looseStatements(file) {
|
|
1565
|
+
return file.statements.map((statement) => this.statementFinding(file, statement, statementMessages[statement.kind]));
|
|
1566
|
+
}
|
|
1567
|
+
compositionRootStatements(file) {
|
|
1568
|
+
return file.statements.map((statement) => this.statementFinding(file, statement, statement.kind === "statement" ? "compositionRootStatement" : "compositionRootDeclaration"));
|
|
1569
|
+
}
|
|
1570
|
+
adapterStatements(file) {
|
|
1571
|
+
return file.statements.map((statement) => this.statementFinding(file, statement, statement.kind === "statement" ? "adapterStatement" : "adapterDeclaration"));
|
|
1572
|
+
}
|
|
1573
|
+
statementFinding(file, statement, messageId) {
|
|
1574
|
+
return this.finding(file, statement.line, statement.name, messageId, {
|
|
1575
|
+
kind: statement.kind,
|
|
1576
|
+
name: statement.name
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
};
|
|
1580
|
+
//#endregion
|
|
1581
|
+
//#region src/rules/tactical/building-blocks/no-misplaced-class.rule.ts
|
|
1582
|
+
const markerWording = {
|
|
1583
|
+
AntiCorruptionLayer: "driven/<technology>/adapters/*.adapter.ts, as an adapter of a port",
|
|
1584
|
+
OpenHostService: "driving/<technology>/"
|
|
1585
|
+
};
|
|
1586
|
+
var NoMisplacedClassRule = class extends Rule {
|
|
1587
|
+
meta = {
|
|
1588
|
+
description: "A class in the wrong folder or file, two classes in one file.",
|
|
1589
|
+
id: "tactical/no-misplaced-class",
|
|
1590
|
+
messages: {
|
|
1591
|
+
misplacedKind: "{name} belongs in {place}.",
|
|
1592
|
+
misplacedMarker: "{name} implements {marker}: it belongs in {place}.",
|
|
1593
|
+
sharedFile: "{name} shares its file with {first}: one class per file."
|
|
1594
|
+
}
|
|
1595
|
+
};
|
|
1596
|
+
check(architecture) {
|
|
1597
|
+
const findings = [];
|
|
1598
|
+
for (const file of architecture.files) {
|
|
1599
|
+
findings.push(...this.extraClasses(file));
|
|
1600
|
+
const [first] = file.classes;
|
|
1601
|
+
const finding = first === void 0 ? void 0 : this.misplacement(first, file, architecture.locationOf(file), architecture);
|
|
1602
|
+
if (finding !== void 0) findings.push(finding);
|
|
1603
|
+
}
|
|
1604
|
+
return findings;
|
|
1605
|
+
}
|
|
1606
|
+
extraClasses(file) {
|
|
1607
|
+
const [first, ...others] = file.classes;
|
|
1608
|
+
const findings = [];
|
|
1609
|
+
for (const codeClass of others) findings.push(this.finding(file, codeClass.line, codeClass.name, "sharedFile", {
|
|
1610
|
+
first: first?.name ?? "",
|
|
1611
|
+
name: codeClass.name
|
|
1612
|
+
}));
|
|
1613
|
+
return findings;
|
|
1614
|
+
}
|
|
1615
|
+
misplacement(codeClass, file, location, architecture) {
|
|
1616
|
+
for (const { marker, place } of architecture.blocks.markedPlacesOf(codeClass)) if (!place.fits(location)) return this.finding(file, codeClass.line, codeClass.name, "misplacedMarker", {
|
|
1617
|
+
marker,
|
|
1618
|
+
name: codeClass.name,
|
|
1619
|
+
place: markerWording[marker]
|
|
1620
|
+
});
|
|
1621
|
+
const place = architecture.blocks.placeOf(codeClass);
|
|
1622
|
+
if (place === void 0 || place.fits(location)) return;
|
|
1623
|
+
return this.finding(file, codeClass.line, codeClass.name, "misplacedKind", {
|
|
1624
|
+
name: codeClass.name,
|
|
1625
|
+
place: this.wording.place(place)
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
};
|
|
1629
|
+
//#endregion
|
|
1630
|
+
//#region src/rules/tactical/command-handlers/no-foreign-command-dependency.rule.ts
|
|
1631
|
+
var NoForeignCommandDependencyRule = class extends InjectionRule {
|
|
1632
|
+
meta = {
|
|
1633
|
+
description: "A command handler receiving a query repository, an event publisher, another handler or a plain class.",
|
|
1634
|
+
id: "tactical/no-foreign-command-dependency",
|
|
1635
|
+
messages: { foreign: "The CommandHandler {class} receives {type}, {kind}: a command handler receives command repositories, ports, event translators, domain services and value objects; events leave through the outbox, never a publisher." }
|
|
1636
|
+
};
|
|
1637
|
+
holder = "CommandHandler";
|
|
1638
|
+
forbidden = ["QueryRepository", "EventPublisher"];
|
|
1639
|
+
allowed = [
|
|
1640
|
+
"CommandRepository",
|
|
1641
|
+
"Port",
|
|
1642
|
+
"EventTranslator",
|
|
1643
|
+
"DomainService",
|
|
1644
|
+
"ValueObject",
|
|
1645
|
+
"Identifier"
|
|
1646
|
+
];
|
|
1647
|
+
};
|
|
1648
|
+
//#endregion
|
|
1649
|
+
//#region src/rules/tactical/domain-errors/no-thrown-failure.rule.ts
|
|
1650
|
+
const exempt = /* @__PURE__ */ new Set([
|
|
1651
|
+
"equals",
|
|
1652
|
+
"toSnapshot",
|
|
1653
|
+
"toString",
|
|
1654
|
+
"toJSON",
|
|
1655
|
+
"valueOf"
|
|
1656
|
+
]);
|
|
1657
|
+
const guarded$1 = /* @__PURE__ */ new Set(["domain", "application"]);
|
|
1658
|
+
var NoThrownFailureRule = class extends Rule {
|
|
1659
|
+
meta = {
|
|
1660
|
+
description: "A business failure thrown instead of returned, an entity method without a Result, a setter.",
|
|
1661
|
+
id: "tactical/no-thrown-failure",
|
|
1662
|
+
messages: {
|
|
1663
|
+
noResult: "{member} must return a Result: expose reads as getters and return business failures as values.",
|
|
1664
|
+
rejected: "A failure is rejected: return it in a Result instead.",
|
|
1665
|
+
setter: "{member} is a setter: change the state through a business method that returns a Result.",
|
|
1666
|
+
thrown: "A failure is thrown: return it in a Result instead."
|
|
1667
|
+
}
|
|
1668
|
+
};
|
|
1669
|
+
check(architecture) {
|
|
1670
|
+
const findings = [];
|
|
1671
|
+
for (const file of architecture.files) {
|
|
1672
|
+
findings.push(...this.operationsWithoutResult(file, architecture));
|
|
1673
|
+
if (guarded$1.has(architecture.locationOf(file).layer)) findings.push(...this.raisedFailures(file));
|
|
1674
|
+
}
|
|
1675
|
+
return findings;
|
|
1676
|
+
}
|
|
1677
|
+
operationsWithoutResult(file, architecture) {
|
|
1678
|
+
const findings = [];
|
|
1679
|
+
for (const codeClass of file.classes) {
|
|
1680
|
+
if (!architecture.is(codeClass, "Entity")) continue;
|
|
1681
|
+
for (const member of codeClass.members) {
|
|
1682
|
+
const messageId = this.messageFor(member, architecture);
|
|
1683
|
+
if (messageId !== void 0) {
|
|
1684
|
+
const symbol = `${codeClass.name}.${member.name}`;
|
|
1685
|
+
findings.push(this.finding(file, member.line, symbol, messageId, { member: symbol }));
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
return findings;
|
|
1690
|
+
}
|
|
1691
|
+
messageFor(member, architecture) {
|
|
1692
|
+
if (!member.isPublicInstance || exempt.has(member.name) || member.name.startsWith("[")) return;
|
|
1693
|
+
if (member.kind === "setter") return "setter";
|
|
1694
|
+
return (member.kind === "method" || member.kind === "field" && member.isCallable) && !architecture.returnsResult(member) ? "noResult" : void 0;
|
|
1695
|
+
}
|
|
1696
|
+
raisedFailures(file) {
|
|
1697
|
+
const findings = [];
|
|
1698
|
+
for (const thrown of file.throws) findings.push(this.finding(file, thrown.line, thrown.form, thrown.form === "throw" ? "thrown" : "rejected"));
|
|
1699
|
+
return findings;
|
|
1700
|
+
}
|
|
1701
|
+
};
|
|
1702
|
+
//#endregion
|
|
1703
|
+
//#region src/rules/tactical/domain-services/no-stateful-service.rule.ts
|
|
1704
|
+
var NoStatefulServiceRule = class extends InjectionRule {
|
|
1705
|
+
meta = {
|
|
1706
|
+
description: "A domain service holding a port, a repository or another service.",
|
|
1707
|
+
id: "tactical/no-stateful-service",
|
|
1708
|
+
messages: { foreign: "The DomainService {class} holds {type}, {kind}: a domain service holds configuration only; the command handler passes it what it needs." }
|
|
1709
|
+
};
|
|
1710
|
+
holder = "DomainService";
|
|
1711
|
+
forbidden = [];
|
|
1712
|
+
allowed = ["ValueObject", "Identifier"];
|
|
1713
|
+
};
|
|
1714
|
+
//#endregion
|
|
1715
|
+
//#region src/rules/tactical/entities/no-public-field.rule.ts
|
|
1716
|
+
const guarded = [
|
|
1717
|
+
"Entity",
|
|
1718
|
+
"ValueObject",
|
|
1719
|
+
"Identifier"
|
|
1720
|
+
];
|
|
1721
|
+
var NoPublicFieldRule = class extends ClassRule {
|
|
1722
|
+
meta = {
|
|
1723
|
+
description: "A public field on an aggregate, an entity, a value object or an identifier.",
|
|
1724
|
+
id: "tactical/no-public-field",
|
|
1725
|
+
messages: { publicField: "{member} is a public field: keep the state private, and expose what callers need through a getter." }
|
|
1726
|
+
};
|
|
1727
|
+
findingsFor(codeClass, file, architecture) {
|
|
1728
|
+
if (!guarded.some((kind) => architecture.is(codeClass, kind))) return [];
|
|
1729
|
+
const findings = [];
|
|
1730
|
+
for (const member of codeClass.members) if (member.isField && member.visibility === "public" && !member.isStatic) {
|
|
1731
|
+
const symbol = `${codeClass.name}.${member.name}`;
|
|
1732
|
+
findings.push(this.finding(file, member.line, symbol, "publicField", { member: symbol }));
|
|
1733
|
+
}
|
|
1734
|
+
return findings;
|
|
1735
|
+
}
|
|
1736
|
+
};
|
|
1737
|
+
//#endregion
|
|
1738
|
+
//#region src/rules/tactical/query-handlers/no-foreign-query-dependency.rule.ts
|
|
1739
|
+
var NoForeignQueryDependencyRule = class extends InjectionRule {
|
|
1740
|
+
meta = {
|
|
1741
|
+
description: "A query handler receiving what writes or changes state.",
|
|
1742
|
+
id: "tactical/no-foreign-query-dependency",
|
|
1743
|
+
messages: { foreign: "The QueryHandler {class} receives {type}, {kind}: a query handler receives query repositories, ports that do not write, and value objects." }
|
|
1744
|
+
};
|
|
1745
|
+
holder = "QueryHandler";
|
|
1746
|
+
forbidden = [
|
|
1747
|
+
"CommandRepository",
|
|
1748
|
+
"Outbox",
|
|
1749
|
+
"UnitOfWork",
|
|
1750
|
+
"EventPublisher"
|
|
1751
|
+
];
|
|
1752
|
+
allowed = [
|
|
1753
|
+
"QueryRepository",
|
|
1754
|
+
"Port",
|
|
1755
|
+
"ValueObject",
|
|
1756
|
+
"Identifier"
|
|
1757
|
+
];
|
|
1758
|
+
};
|
|
1759
|
+
//#endregion
|
|
1760
|
+
//#region src/rules/tooling/disable-directive.ts
|
|
1761
|
+
var DisableDirective = class {
|
|
1762
|
+
rule;
|
|
1763
|
+
reason;
|
|
1764
|
+
constructor(text) {
|
|
1765
|
+
const match = /^([a-z]+\/[a-z-]+)?\s*(?::\s*(.+?))?\s*$/.exec(text);
|
|
1766
|
+
this.rule = match?.[1];
|
|
1767
|
+
this.reason = match?.[2];
|
|
1768
|
+
}
|
|
1769
|
+
get isComplete() {
|
|
1770
|
+
return this.rule !== void 0 && this.reason !== void 0;
|
|
1771
|
+
}
|
|
1772
|
+
};
|
|
1773
|
+
//#endregion
|
|
1774
|
+
//#region src/rules/tooling/no-loose-disable.rule.ts
|
|
1775
|
+
var NoLooseDisableRule = class extends Rule {
|
|
1776
|
+
knownRules;
|
|
1777
|
+
meta = {
|
|
1778
|
+
description: "A disable comment that names no known rule, gives no reason, or disables nothing.",
|
|
1779
|
+
id: "tooling/no-loose-disable",
|
|
1780
|
+
messages: {
|
|
1781
|
+
noReason: "The disable comment gives no reason: write `// alveolus-disable-next-line {rule}: <why this line keeps its violation>`.",
|
|
1782
|
+
noRule: "The disable comment names no rule: write `// alveolus-disable-next-line <rule-id>: <reason>`.",
|
|
1783
|
+
unknownRule: "The disable comment names {rule}, which is no rule: check the id on the rules page.",
|
|
1784
|
+
unused: "The disable comment disables nothing: the line below breaks {rule} no more; remove the comment."
|
|
1785
|
+
}
|
|
1786
|
+
};
|
|
1787
|
+
constructor(knownRules) {
|
|
1788
|
+
super();
|
|
1789
|
+
this.knownRules = knownRules;
|
|
1790
|
+
}
|
|
1791
|
+
check(architecture) {
|
|
1792
|
+
const findings = [];
|
|
1793
|
+
for (const file of architecture.files) for (const comment of file.disables) {
|
|
1794
|
+
const finding = this.malformed(file, comment);
|
|
1795
|
+
if (finding !== void 0) findings.push(finding);
|
|
1796
|
+
}
|
|
1797
|
+
return findings;
|
|
1798
|
+
}
|
|
1799
|
+
unused(file, comment) {
|
|
1800
|
+
const directive = new DisableDirective(comment.text);
|
|
1801
|
+
return this.finding(file, comment.line, "alveolus-disable-next-line", "unused", { rule: directive.rule ?? "" });
|
|
1802
|
+
}
|
|
1803
|
+
malformed(file, comment) {
|
|
1804
|
+
const directive = new DisableDirective(comment.text);
|
|
1805
|
+
if (directive.rule === void 0) return this.finding(file, comment.line, "alveolus-disable-next-line", "noRule");
|
|
1806
|
+
if (!this.knownRules.includes(directive.rule)) return this.finding(file, comment.line, "alveolus-disable-next-line", "unknownRule", { rule: directive.rule });
|
|
1807
|
+
if (directive.reason === void 0) return this.finding(file, comment.line, "alveolus-disable-next-line", "noReason", { rule: directive.rule });
|
|
1808
|
+
}
|
|
1809
|
+
};
|
|
1810
|
+
//#endregion
|
|
1811
|
+
//#region src/rules/registry.ts
|
|
1812
|
+
const ruleIds = [
|
|
1813
|
+
"strategic/no-cross-context-import",
|
|
1814
|
+
"strategic/no-leaky-host-service",
|
|
1815
|
+
"strategic/no-unmapped-context",
|
|
1816
|
+
"strategic/no-fat-shared-kernel",
|
|
1817
|
+
"layers/no-impure-domain",
|
|
1818
|
+
"layers/no-outward-import",
|
|
1819
|
+
"layers/no-portless-adapter",
|
|
1820
|
+
"layers/no-driving-shortcut",
|
|
1821
|
+
"tactical/no-aggregate-reference",
|
|
1822
|
+
"tactical/no-public-field",
|
|
1823
|
+
"tactical/no-foreign-command-dependency",
|
|
1824
|
+
"tactical/no-foreign-query-dependency",
|
|
1825
|
+
"tactical/no-stateful-service",
|
|
1826
|
+
"tactical/no-thrown-failure",
|
|
1827
|
+
"tactical/no-misplaced-class",
|
|
1828
|
+
"tactical/no-loose-code",
|
|
1829
|
+
"tooling/no-loose-disable"
|
|
1830
|
+
];
|
|
1831
|
+
var RuleRegistry = class {
|
|
1832
|
+
rules = [
|
|
1833
|
+
new NoCrossContextImportRule(),
|
|
1834
|
+
new NoLeakyHostServiceRule(),
|
|
1835
|
+
new NoUnmappedContextRule(),
|
|
1836
|
+
new NoFatSharedKernelRule(),
|
|
1837
|
+
new NoImpureDomainRule(),
|
|
1838
|
+
new NoOutwardImportRule(),
|
|
1839
|
+
new NoPortlessAdapterRule(),
|
|
1840
|
+
new NoDrivingShortcutRule(),
|
|
1841
|
+
new NoAggregateReferenceRule(),
|
|
1842
|
+
new NoPublicFieldRule(),
|
|
1843
|
+
new NoForeignCommandDependencyRule(),
|
|
1844
|
+
new NoForeignQueryDependencyRule(),
|
|
1845
|
+
new NoStatefulServiceRule(),
|
|
1846
|
+
new NoThrownFailureRule(),
|
|
1847
|
+
new NoMisplacedClassRule(),
|
|
1848
|
+
new NoLooseCodeRule(),
|
|
1849
|
+
new NoLooseDisableRule(ruleIds)
|
|
1850
|
+
];
|
|
1851
|
+
get looseDisable() {
|
|
1852
|
+
return this.rules.find((rule) => rule instanceof NoLooseDisableRule);
|
|
1853
|
+
}
|
|
1854
|
+
get ids() {
|
|
1855
|
+
return this.rules.map((rule) => rule.meta.id);
|
|
1856
|
+
}
|
|
1857
|
+
};
|
|
1858
|
+
//#endregion
|
|
1859
|
+
//#region src/check/fingerprint.ts
|
|
1860
|
+
const length = 8;
|
|
1861
|
+
var Fingerprint = class {
|
|
1862
|
+
of(text) {
|
|
1863
|
+
const normalized = text.trim().replace(/\s+/g, " ");
|
|
1864
|
+
return createHash("sha1").update(normalized).digest("hex").slice(0, length);
|
|
1865
|
+
}
|
|
1866
|
+
};
|
|
1867
|
+
//#endregion
|
|
1868
|
+
//#region src/check/checker.ts
|
|
1869
|
+
var Checker = class {
|
|
1870
|
+
importer;
|
|
1871
|
+
rules;
|
|
1872
|
+
fingerprint = new Fingerprint();
|
|
1873
|
+
constructor(importer, rules) {
|
|
1874
|
+
this.importer = importer;
|
|
1875
|
+
this.rules = rules;
|
|
1876
|
+
}
|
|
1877
|
+
check(settings) {
|
|
1878
|
+
const project = this.importer.read(settings);
|
|
1879
|
+
if (project.files.length === 0) throw new Error(`No file to analyse under ${settings.rootDir}: check root and ignore in alveolus.config.ts, and include in your tsconfig.`);
|
|
1880
|
+
if (!this.importer.resolves("@alveolus/core", settings)) throw new Error(`${corePackageName} cannot be imported from ${settings.rootDir}: install it, or map it in the paths of your tsconfig. Without it, no building block can be recognised.`);
|
|
1881
|
+
const architecture = new Architecture(project, settings);
|
|
1882
|
+
const outcome = {
|
|
1883
|
+
files: project.files.length,
|
|
1884
|
+
suppressed: [],
|
|
1885
|
+
violations: []
|
|
1886
|
+
};
|
|
1887
|
+
const used = /* @__PURE__ */ new Set();
|
|
1888
|
+
for (const rule of this.rules) {
|
|
1889
|
+
const severity = settings.severityOf(rule.meta.id);
|
|
1890
|
+
if (severity !== "off") this.collect(rule, severity, architecture, outcome, used);
|
|
1891
|
+
}
|
|
1892
|
+
this.collectUnusedDisables(architecture, settings, outcome, used);
|
|
1893
|
+
outcome.violations.sort((left, right) => this.compare(left, right));
|
|
1894
|
+
return outcome;
|
|
1895
|
+
}
|
|
1896
|
+
run(rule, architecture) {
|
|
1897
|
+
return rule.check(architecture).map((finding) => this.violationOf(rule, "error", finding, architecture));
|
|
1898
|
+
}
|
|
1899
|
+
collect(rule, severity, architecture, outcome, used) {
|
|
1900
|
+
for (const finding of rule.check(architecture)) {
|
|
1901
|
+
const violation = this.violationOf(rule, severity, finding, architecture);
|
|
1902
|
+
const comment = finding.file.disableAbove(finding.line);
|
|
1903
|
+
const directive = comment === void 0 ? void 0 : new DisableDirective(comment.text);
|
|
1904
|
+
if (comment !== void 0 && directive?.isComplete === true && directive.rule === rule.meta.id) {
|
|
1905
|
+
used.add(`${finding.file.path}:${comment.line}`);
|
|
1906
|
+
outcome.suppressed.push({
|
|
1907
|
+
reason: directive.reason ?? "",
|
|
1908
|
+
violation
|
|
1909
|
+
});
|
|
1910
|
+
} else outcome.violations.push(violation);
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
collectUnusedDisables(architecture, settings, outcome, used) {
|
|
1914
|
+
const rule = this.rules.find((candidate) => candidate instanceof NoLooseDisableRule);
|
|
1915
|
+
if (!(rule instanceof NoLooseDisableRule)) return;
|
|
1916
|
+
const severity = settings.severityOf(rule.meta.id);
|
|
1917
|
+
if (severity === "off") return;
|
|
1918
|
+
for (const file of architecture.files) for (const comment of file.disables) if (new DisableDirective(comment.text).isComplete && !used.has(`${file.path}:${comment.line}`)) outcome.violations.push(this.violationOf(rule, severity, rule.unused(file, comment), architecture));
|
|
1919
|
+
}
|
|
1920
|
+
violationOf(rule, severity, finding, architecture) {
|
|
1921
|
+
return {
|
|
1922
|
+
file: architecture.relativePath(finding.file.path),
|
|
1923
|
+
fingerprint: this.fingerprint.of(finding.file.lineText(finding.line)),
|
|
1924
|
+
line: finding.line,
|
|
1925
|
+
message: this.messageOf(rule, finding),
|
|
1926
|
+
rule: rule.meta.id,
|
|
1927
|
+
severity,
|
|
1928
|
+
symbol: finding.symbol
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
messageOf(rule, finding) {
|
|
1932
|
+
const template = rule.meta.messages[finding.messageId];
|
|
1933
|
+
if (template === void 0) throw new Error(`${rule.meta.id} has no message "${finding.messageId}".`);
|
|
1934
|
+
return template.replace(/\{(\w+)\}/g, (placeholder, key) => this.fill(rule, finding, key, placeholder));
|
|
1935
|
+
}
|
|
1936
|
+
fill(rule, finding, key, placeholder) {
|
|
1937
|
+
const value = finding.data[key];
|
|
1938
|
+
if (value === void 0) throw new Error(`${rule.meta.id}: the message "${finding.messageId}" needs ${placeholder}, which the finding does not give.`);
|
|
1939
|
+
return String(value);
|
|
1940
|
+
}
|
|
1941
|
+
compare(left, right) {
|
|
1942
|
+
return left.file.localeCompare(right.file) || left.line - right.line || left.rule.localeCompare(right.rule);
|
|
1943
|
+
}
|
|
1944
|
+
};
|
|
1945
|
+
//#endregion
|
|
1946
|
+
//#region src/check/report.ts
|
|
1947
|
+
const docsUrl = "https://alveolus.dev/";
|
|
1948
|
+
const sarifLevels = {
|
|
1949
|
+
error: "error",
|
|
1950
|
+
info: "note",
|
|
1951
|
+
warn: "warning"
|
|
1952
|
+
};
|
|
1953
|
+
var Report = class {
|
|
1954
|
+
input;
|
|
1955
|
+
colors;
|
|
1956
|
+
constructor(input, colored = false) {
|
|
1957
|
+
this.input = input;
|
|
1958
|
+
this.colors = picocolors.createColors(colored);
|
|
1959
|
+
}
|
|
1960
|
+
text() {
|
|
1961
|
+
const blocks = [];
|
|
1962
|
+
for (const [file, violations] of this.byFile()) blocks.push(this.block(file, violations));
|
|
1963
|
+
return `${[...blocks, this.summary()].join("\n\n")}\n`;
|
|
1964
|
+
}
|
|
1965
|
+
json() {
|
|
1966
|
+
const { baselined, files, stale, suppressed, violations } = this.input;
|
|
1967
|
+
const disabled = suppressed.map(({ reason, violation }) => ({
|
|
1968
|
+
...violation,
|
|
1969
|
+
reason
|
|
1970
|
+
}));
|
|
1971
|
+
return `${JSON.stringify({
|
|
1972
|
+
baselined,
|
|
1973
|
+
files,
|
|
1974
|
+
stale,
|
|
1975
|
+
suppressed: disabled,
|
|
1976
|
+
violations
|
|
1977
|
+
}, null, " ")}\n`;
|
|
1978
|
+
}
|
|
1979
|
+
sarif(rules) {
|
|
1980
|
+
const driver = {
|
|
1981
|
+
informationUri: docsUrl,
|
|
1982
|
+
name: "alveolus",
|
|
1983
|
+
rules: rules.map((rule) => ({
|
|
1984
|
+
helpUri: `${docsUrl}rules/${rule.id}`,
|
|
1985
|
+
id: rule.id,
|
|
1986
|
+
shortDescription: { text: rule.description }
|
|
1987
|
+
}))
|
|
1988
|
+
};
|
|
1989
|
+
const log = {
|
|
1990
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
1991
|
+
runs: [{
|
|
1992
|
+
results: this.input.violations.map((violation) => ({
|
|
1993
|
+
level: sarifLevels[violation.severity],
|
|
1994
|
+
locations: [{ physicalLocation: {
|
|
1995
|
+
artifactLocation: {
|
|
1996
|
+
uri: violation.file,
|
|
1997
|
+
uriBaseId: "%SRCROOT%"
|
|
1998
|
+
},
|
|
1999
|
+
region: { startLine: violation.line }
|
|
2000
|
+
} }],
|
|
2001
|
+
message: { text: violation.message },
|
|
2002
|
+
partialFingerprints: { "alveolus/v1": violation.fingerprint },
|
|
2003
|
+
ruleId: violation.rule
|
|
2004
|
+
})),
|
|
2005
|
+
tool: { driver }
|
|
2006
|
+
}],
|
|
2007
|
+
version: "2.1.0"
|
|
2008
|
+
};
|
|
2009
|
+
return `${JSON.stringify(log, null, " ")}\n`;
|
|
2010
|
+
}
|
|
2011
|
+
byFile() {
|
|
2012
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
2013
|
+
for (const violation of this.input.violations) byFile.set(violation.file, [...byFile.get(violation.file) ?? [], violation]);
|
|
2014
|
+
return byFile;
|
|
2015
|
+
}
|
|
2016
|
+
block(file, violations) {
|
|
2017
|
+
const lines = violations.map((violation) => ` ${violation.line} ${this.severity(violation.severity)} ${violation.rule}: ${violation.message}`);
|
|
2018
|
+
return [this.colors.bold(file), ...lines].join("\n");
|
|
2019
|
+
}
|
|
2020
|
+
severity(severity) {
|
|
2021
|
+
const { dim, red, yellow } = this.colors;
|
|
2022
|
+
if (severity === "error") return red("error");
|
|
2023
|
+
if (severity === "warn") return yellow("warn");
|
|
2024
|
+
return dim("info");
|
|
2025
|
+
}
|
|
2026
|
+
summary() {
|
|
2027
|
+
const { dim, green } = this.colors;
|
|
2028
|
+
const counted = this.counts();
|
|
2029
|
+
const head = counted.length === 0 ? green("No violation") : counted.join(", ");
|
|
2030
|
+
const parts = [];
|
|
2031
|
+
if (this.input.baselined > 0) parts.push(`${this.input.baselined} in the baseline`);
|
|
2032
|
+
if (this.input.stale > 0) parts.push(`${this.input.stale} fixed`);
|
|
2033
|
+
if (this.input.suppressed.length > 0) parts.push(`${this.input.suppressed.length} disabled`);
|
|
2034
|
+
const where = ` in ${this.input.files} file${this.input.files === 1 ? "" : "s"}`;
|
|
2035
|
+
return parts.length === 0 ? `${head}${where}` : `${head}${where}${dim(` (${parts.join(", ")})`)}`;
|
|
2036
|
+
}
|
|
2037
|
+
counts() {
|
|
2038
|
+
const { dim, red, yellow } = this.colors;
|
|
2039
|
+
const errors = this.input.violations.filter((violation) => violation.severity === "error").length;
|
|
2040
|
+
const warnings = this.input.violations.filter((violation) => violation.severity === "warn").length;
|
|
2041
|
+
const infos = this.input.violations.filter((violation) => violation.severity === "info").length;
|
|
2042
|
+
const counted = [];
|
|
2043
|
+
if (errors > 0) counted.push(red(`${errors} error${errors === 1 ? "" : "s"}`));
|
|
2044
|
+
if (warnings > 0) counted.push(yellow(`${warnings} warning${warnings === 1 ? "" : "s"}`));
|
|
2045
|
+
if (infos > 0) counted.push(dim(`${infos} info${infos === 1 ? "" : "s"}`));
|
|
2046
|
+
return counted;
|
|
2047
|
+
}
|
|
2048
|
+
};
|
|
2049
|
+
//#endregion
|
|
2050
|
+
//#region src/config/config.ts
|
|
2051
|
+
const testFiles = [
|
|
2052
|
+
"**/*.spec.ts",
|
|
2053
|
+
"**/*.test.ts",
|
|
2054
|
+
"**/*.e2e-spec.ts",
|
|
2055
|
+
"**/*.fixture.ts",
|
|
2056
|
+
"**/*.fixtures.ts",
|
|
2057
|
+
"**/*.stories.ts",
|
|
2058
|
+
"**/__tests__/**",
|
|
2059
|
+
"**/__mocks__/**"
|
|
2060
|
+
];
|
|
2061
|
+
var Config = class {
|
|
2062
|
+
projectDir;
|
|
2063
|
+
rootDir;
|
|
2064
|
+
tsConfigPath;
|
|
2065
|
+
compositionRoot;
|
|
2066
|
+
domainDependencies;
|
|
2067
|
+
applicationDependencies;
|
|
2068
|
+
contextFolders;
|
|
2069
|
+
extraFolders;
|
|
2070
|
+
contextMap;
|
|
2071
|
+
ignored;
|
|
2072
|
+
rules;
|
|
2073
|
+
constructor(config, projectDir) {
|
|
2074
|
+
this.projectDir = resolve(projectDir);
|
|
2075
|
+
this.rootDir = resolve(projectDir, config.root);
|
|
2076
|
+
this.tsConfigPath = resolve(projectDir, config.tsconfig ?? "tsconfig.json");
|
|
2077
|
+
this.compositionRoot = config.compositionRoot ?? "*.module.ts";
|
|
2078
|
+
this.domainDependencies = new AllowedPackages(config.domainDependencies);
|
|
2079
|
+
this.applicationDependencies = this.domainDependencies.with(new AllowedPackages(config.applicationDependencies));
|
|
2080
|
+
this.ignored = [...testFiles, ...config.ignore ?? []];
|
|
2081
|
+
this.extraFolders = config.layout?.extraFolders ?? {};
|
|
2082
|
+
this.contextMap = config.contextMap === void 0 ? void 0 : this.validContextMap(config.contextMap, Object.keys(config.boundedContexts));
|
|
2083
|
+
this.rules = config.rules;
|
|
2084
|
+
const sharedKernel = config.sharedKernel ?? "shared-kernel";
|
|
2085
|
+
this.contextFolders = [...Object.entries(config.boundedContexts).map(([name, folder]) => ({
|
|
2086
|
+
dir: resolve(this.rootDir, folder),
|
|
2087
|
+
isSharedKernel: false,
|
|
2088
|
+
name
|
|
2089
|
+
})), {
|
|
2090
|
+
dir: resolve(this.rootDir, sharedKernel),
|
|
2091
|
+
isSharedKernel: true,
|
|
2092
|
+
name: "shared kernel"
|
|
2093
|
+
}];
|
|
2094
|
+
}
|
|
2095
|
+
validContextMap(upstreams, contexts) {
|
|
2096
|
+
const map = new ContextMap(upstreams);
|
|
2097
|
+
const unknown = map.contexts.filter((name) => !contexts.includes(name));
|
|
2098
|
+
if (unknown.length > 0) throw new Error(`contextMap names ${unknown.join(", ")}, which boundedContexts does not declare.`);
|
|
2099
|
+
const cycle = map.cycle();
|
|
2100
|
+
if (cycle !== void 0) throw new Error(`contextMap has a cycle: ${cycle.join(" → ")}. Two contexts that depend on each other can no longer change alone: reverse one dependency.`);
|
|
2101
|
+
return map;
|
|
2102
|
+
}
|
|
2103
|
+
isIgnored(path) {
|
|
2104
|
+
const projectPath = relative(this.projectDir, path).split(sep).join("/");
|
|
2105
|
+
return this.ignored.some((glob) => matchesGlob(projectPath, glob));
|
|
2106
|
+
}
|
|
2107
|
+
severityOf(rule) {
|
|
2108
|
+
return this.rules?.[rule] ?? "error";
|
|
2109
|
+
}
|
|
2110
|
+
};
|
|
2111
|
+
//#endregion
|
|
2112
|
+
//#region src/config/config-loader.ts
|
|
2113
|
+
const packageDependencies = z.record(z.string(), z.union([z.literal(true), z.array(z.string())]));
|
|
2114
|
+
const ruleSetting = z.enum([
|
|
2115
|
+
"error",
|
|
2116
|
+
"warn",
|
|
2117
|
+
"info",
|
|
2118
|
+
"off"
|
|
2119
|
+
]);
|
|
2120
|
+
const schema = z.strictObject({
|
|
2121
|
+
applicationDependencies: packageDependencies.exactOptional(),
|
|
2122
|
+
boundedContexts: z.record(z.string(), z.string()),
|
|
2123
|
+
compositionRoot: z.string().exactOptional(),
|
|
2124
|
+
contextMap: z.record(z.string(), z.array(z.string())).exactOptional(),
|
|
2125
|
+
domainDependencies: packageDependencies.exactOptional(),
|
|
2126
|
+
ignore: z.array(z.string()).exactOptional(),
|
|
2127
|
+
layout: z.strictObject({ extraFolders: z.strictObject({
|
|
2128
|
+
application: z.array(z.string()).exactOptional(),
|
|
2129
|
+
domain: z.array(z.string()).exactOptional()
|
|
2130
|
+
}).exactOptional() }).exactOptional(),
|
|
2131
|
+
root: z.string(),
|
|
2132
|
+
rules: z.strictObject(Object.fromEntries(new RuleRegistry().ids.map((id) => [id, ruleSetting.exactOptional()]))).exactOptional(),
|
|
2133
|
+
sharedKernel: z.string().exactOptional(),
|
|
2134
|
+
tsconfig: z.string().exactOptional()
|
|
2135
|
+
});
|
|
2136
|
+
var ConfigLoader = class ConfigLoader {
|
|
2137
|
+
static fileName = "alveolus.config.ts";
|
|
2138
|
+
async load(projectDir, configFile = ConfigLoader.fileName) {
|
|
2139
|
+
const path = resolve(projectDir, configFile);
|
|
2140
|
+
if (!existsSync(path)) throw new Error(`No configuration found at ${path}.`);
|
|
2141
|
+
const exported = await createJiti(import.meta.url).import(path, { default: true });
|
|
2142
|
+
return new Config(this.validate(exported, path), projectDir);
|
|
2143
|
+
}
|
|
2144
|
+
validate(exported, path) {
|
|
2145
|
+
const parsed = schema.safeParse(exported);
|
|
2146
|
+
if (!parsed.success) throw new Error(`Invalid configuration in ${path}:\n${z.prettifyError(parsed.error)}`);
|
|
2147
|
+
return parsed.data;
|
|
2148
|
+
}
|
|
2149
|
+
};
|
|
2150
|
+
//#endregion
|
|
2151
|
+
//#region src/importer/importer.ts
|
|
2152
|
+
var Importer = class {};
|
|
2153
|
+
//#endregion
|
|
2154
|
+
//#region src/importer/ts-morph/package-names.ts
|
|
2155
|
+
var PackageNames = class {
|
|
2156
|
+
fileSystem;
|
|
2157
|
+
cache = /* @__PURE__ */ new Map();
|
|
2158
|
+
constructor(fileSystem) {
|
|
2159
|
+
this.fileSystem = fileSystem;
|
|
2160
|
+
}
|
|
2161
|
+
ofFile(path) {
|
|
2162
|
+
return this.ofDirectory(dirname(path));
|
|
2163
|
+
}
|
|
2164
|
+
ofDirectory(directory) {
|
|
2165
|
+
if (this.cache.has(directory)) return this.cache.get(directory);
|
|
2166
|
+
const name = this.readManifest(directory) ?? this.ofParent(directory);
|
|
2167
|
+
this.cache.set(directory, name);
|
|
2168
|
+
return name;
|
|
2169
|
+
}
|
|
2170
|
+
ofParent(directory) {
|
|
2171
|
+
const parent = dirname(directory);
|
|
2172
|
+
return parent === directory ? void 0 : this.ofDirectory(parent);
|
|
2173
|
+
}
|
|
2174
|
+
readManifest(directory) {
|
|
2175
|
+
const manifest = join(directory, "package.json");
|
|
2176
|
+
if (!this.fileSystem.fileExistsSync(manifest)) return;
|
|
2177
|
+
const content = JSON.parse(this.fileSystem.readFileSync(manifest));
|
|
2178
|
+
return typeof content.name === "string" ? content.name : void 0;
|
|
2179
|
+
}
|
|
2180
|
+
};
|
|
2181
|
+
//#endregion
|
|
2182
|
+
//#region src/importer/ts-morph/readers/class-reader.ts
|
|
2183
|
+
var ClassReader = class {
|
|
2184
|
+
types;
|
|
2185
|
+
constructor(types) {
|
|
2186
|
+
this.types = types;
|
|
2187
|
+
}
|
|
2188
|
+
read(declaration) {
|
|
2189
|
+
return new ClassDeclaration({
|
|
2190
|
+
heritage: this.heritageOf(declaration),
|
|
2191
|
+
implemented: declaration.getImplements().map((clause) => this.types.namedTypeOf(clause.getType())),
|
|
2192
|
+
isAbstract: declaration.isAbstract(),
|
|
2193
|
+
line: declaration.getStartLineNumber(),
|
|
2194
|
+
members: declaration.getMembers().flatMap((member) => this.membersOf(member)),
|
|
2195
|
+
name: declaration.getName() ?? "default",
|
|
2196
|
+
type: this.types.classTypeOf(declaration.getType())
|
|
2197
|
+
});
|
|
2198
|
+
}
|
|
2199
|
+
membersOf(member) {
|
|
2200
|
+
if (Node.isConstructorDeclaration(member)) return this.constructorParametersOf(member);
|
|
2201
|
+
if (Node.isPropertyDeclaration(member)) {
|
|
2202
|
+
const type = member.getType();
|
|
2203
|
+
const [signature] = type.getCallSignatures();
|
|
2204
|
+
return [this.member(member, "field", {
|
|
2205
|
+
isCallable: signature !== void 0,
|
|
2206
|
+
returns: signature === void 0 ? void 0 : this.types.returnShapeOf(signature.getReturnType()),
|
|
2207
|
+
valueTypes: this.types.classTypesIn(type)
|
|
2208
|
+
})];
|
|
2209
|
+
}
|
|
2210
|
+
if (Node.isMethodDeclaration(member)) {
|
|
2211
|
+
const returnType = member.getReturnType();
|
|
2212
|
+
const parameterTypes = member.getParameters().flatMap((parameter) => this.types.classTypesIn(parameter.getType()));
|
|
2213
|
+
return [this.member(member, "method", {
|
|
2214
|
+
parameterTypes,
|
|
2215
|
+
returns: this.types.returnShapeOf(returnType),
|
|
2216
|
+
valueTypes: this.types.classTypesIn(returnType)
|
|
2217
|
+
})];
|
|
2218
|
+
}
|
|
2219
|
+
if (Node.isGetAccessorDeclaration(member)) {
|
|
2220
|
+
const returnType = member.getReturnType();
|
|
2221
|
+
return [this.member(member, "getter", {
|
|
2222
|
+
returns: this.types.returnShapeOf(returnType),
|
|
2223
|
+
valueTypes: this.types.classTypesIn(returnType)
|
|
2224
|
+
})];
|
|
2225
|
+
}
|
|
2226
|
+
if (Node.isSetAccessorDeclaration(member)) return [this.member(member, "setter", {})];
|
|
2227
|
+
return [];
|
|
2228
|
+
}
|
|
2229
|
+
member(member, kind, types) {
|
|
2230
|
+
const name = Node.hasName(member) ? member.getName() : "";
|
|
2231
|
+
const isStatic = Node.isStaticable(member) && member.isStatic();
|
|
2232
|
+
const scope = Node.isScoped(member) ? member.getScope() : Scope.Public;
|
|
2233
|
+
return new Member({
|
|
2234
|
+
isCallable: types.isCallable ?? false,
|
|
2235
|
+
isParameterProperty: false,
|
|
2236
|
+
isStatic,
|
|
2237
|
+
kind,
|
|
2238
|
+
line: member.getStartLineNumber(),
|
|
2239
|
+
name,
|
|
2240
|
+
parameterTypes: types.parameterTypes ?? [],
|
|
2241
|
+
returns: types.returns,
|
|
2242
|
+
valueTypes: types.valueTypes ?? [],
|
|
2243
|
+
visibility: name.startsWith("#") ? "private" : this.visibilityOf(scope)
|
|
2244
|
+
});
|
|
2245
|
+
}
|
|
2246
|
+
constructorParametersOf(declaration) {
|
|
2247
|
+
return declaration.getParameters().map((parameter) => this.constructorParameter(parameter));
|
|
2248
|
+
}
|
|
2249
|
+
constructorParameter(parameter) {
|
|
2250
|
+
return new Member({
|
|
2251
|
+
isCallable: false,
|
|
2252
|
+
isParameterProperty: parameter.isParameterProperty(),
|
|
2253
|
+
isStatic: false,
|
|
2254
|
+
kind: "constructor parameter",
|
|
2255
|
+
line: parameter.getStartLineNumber(),
|
|
2256
|
+
name: parameter.getName(),
|
|
2257
|
+
parameterTypes: [],
|
|
2258
|
+
returns: void 0,
|
|
2259
|
+
valueTypes: this.types.classTypesIn(parameter.getType()),
|
|
2260
|
+
visibility: this.visibilityOf(parameter.getScope() ?? Scope.Public)
|
|
2261
|
+
});
|
|
2262
|
+
}
|
|
2263
|
+
heritageOf(declaration) {
|
|
2264
|
+
const clause = declaration.getExtends();
|
|
2265
|
+
if (clause === void 0) return;
|
|
2266
|
+
const parameters = declaration.getBaseClass()?.getTypeParameters() ?? [];
|
|
2267
|
+
const typeArguments = clause.getTypeArguments().map((argument, index) => ({
|
|
2268
|
+
line: argument.getStartLineNumber(),
|
|
2269
|
+
parameter: parameters[index]?.getName() ?? argument.getText(),
|
|
2270
|
+
types: this.types.classTypesIn(argument.getType())
|
|
2271
|
+
}));
|
|
2272
|
+
return {
|
|
2273
|
+
isByName: this.namesAClass(clause.getExpression()),
|
|
2274
|
+
typeArguments
|
|
2275
|
+
};
|
|
2276
|
+
}
|
|
2277
|
+
namesAClass(expression) {
|
|
2278
|
+
if (!Node.isIdentifier(expression) && !Node.isPropertyAccessExpression(expression)) return false;
|
|
2279
|
+
const symbol = expression.getSymbol();
|
|
2280
|
+
const declarations = (symbol?.getAliasedSymbol() ?? symbol)?.getDeclarations() ?? [];
|
|
2281
|
+
return declarations.length === 0 || declarations.some((candidate) => Node.isClassDeclaration(candidate));
|
|
2282
|
+
}
|
|
2283
|
+
visibilityOf(scope) {
|
|
2284
|
+
if (scope === Scope.Private) return "private";
|
|
2285
|
+
if (scope === Scope.Protected) return "protected";
|
|
2286
|
+
return "public";
|
|
2287
|
+
}
|
|
2288
|
+
};
|
|
2289
|
+
//#endregion
|
|
2290
|
+
//#region src/importer/ts-morph/readers/dependency-reader.ts
|
|
2291
|
+
const everything = ["*"];
|
|
2292
|
+
var DependencyReader = class {
|
|
2293
|
+
project;
|
|
2294
|
+
scope;
|
|
2295
|
+
constructor(project, scope) {
|
|
2296
|
+
this.project = project;
|
|
2297
|
+
this.scope = scope;
|
|
2298
|
+
}
|
|
2299
|
+
read(file, globals) {
|
|
2300
|
+
const dependencies = [];
|
|
2301
|
+
for (const reference of this.referencesOf(file)) dependencies.push(new Dependency(reference.line, reference.form, reference.specifier ?? reference.text, reference.names, this.targetOf(reference, file)));
|
|
2302
|
+
for (const global of globals) if (global.origin === "project") dependencies.push(new Dependency(global.line, "global", global.declaredIn, [global.name], this.fileTarget(normalize(global.declaredIn))));
|
|
2303
|
+
return dependencies.sort((left, right) => left.line - right.line);
|
|
2304
|
+
}
|
|
2305
|
+
referencesOf(file) {
|
|
2306
|
+
const references = [];
|
|
2307
|
+
for (const declaration of file.getImportDeclarations()) references.push(this.fromDeclaration(declaration, "import"));
|
|
2308
|
+
for (const declaration of file.getExportDeclarations()) if (declaration.hasModuleSpecifier()) references.push(this.fromDeclaration(declaration, "re-export"));
|
|
2309
|
+
for (const declaration of file.getDescendantsOfKind(SyntaxKind.ImportEqualsDeclaration)) references.push(...this.fromImportEquals(declaration));
|
|
2310
|
+
for (const node of file.getDescendantsOfKind(SyntaxKind.ImportType)) references.push(this.fromImportType(node));
|
|
2311
|
+
for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) references.push(...this.fromCall(call));
|
|
2312
|
+
return references;
|
|
2313
|
+
}
|
|
2314
|
+
fromDeclaration(declaration, form) {
|
|
2315
|
+
const specifier = declaration.getModuleSpecifierValue() ?? "";
|
|
2316
|
+
return {
|
|
2317
|
+
form,
|
|
2318
|
+
line: declaration.getStartLineNumber(),
|
|
2319
|
+
names: this.declaredNames(declaration),
|
|
2320
|
+
specifier,
|
|
2321
|
+
text: specifier
|
|
2322
|
+
};
|
|
2323
|
+
}
|
|
2324
|
+
declaredNames(declaration) {
|
|
2325
|
+
if (declaration.isKind(SyntaxKind.ExportDeclaration)) return declaration.isNamespaceExport() ? [...everything] : declaration.getNamedExports().map((named) => named.getName());
|
|
2326
|
+
const names = declaration.getNamedImports().map((named) => named.getName());
|
|
2327
|
+
if (declaration.getDefaultImport() !== void 0) names.unshift("default");
|
|
2328
|
+
if (declaration.getNamespaceImport() !== void 0) names.unshift("*");
|
|
2329
|
+
return names;
|
|
2330
|
+
}
|
|
2331
|
+
fromImportEquals(declaration) {
|
|
2332
|
+
const reference = declaration.getModuleReference();
|
|
2333
|
+
if (!reference.isKind(SyntaxKind.ExternalModuleReference)) return [];
|
|
2334
|
+
return [this.reference(declaration, "require", reference.getExpressionOrThrow(), everything)];
|
|
2335
|
+
}
|
|
2336
|
+
fromImportType(node) {
|
|
2337
|
+
const argument = node.getArgument();
|
|
2338
|
+
const literal = argument.isKind(SyntaxKind.LiteralType) ? argument.getLiteral() : argument;
|
|
2339
|
+
const qualifier = node.getQualifier();
|
|
2340
|
+
const names = qualifier === void 0 ? everything : [qualifier.getText().split(".")[0] ?? "*"];
|
|
2341
|
+
return this.reference(node, "inline type", literal, names);
|
|
2342
|
+
}
|
|
2343
|
+
fromCall(call) {
|
|
2344
|
+
const callee = call.getExpression();
|
|
2345
|
+
const [argument] = call.getArguments();
|
|
2346
|
+
if (argument === void 0) return [];
|
|
2347
|
+
if (callee.isKind(SyntaxKind.ImportKeyword)) return [this.reference(call, "dynamic import", argument, everything)];
|
|
2348
|
+
if (callee.isKind(SyntaxKind.Identifier) && callee.getText() === "require") return [this.reference(call, "require", argument, everything)];
|
|
2349
|
+
return [];
|
|
2350
|
+
}
|
|
2351
|
+
reference(node, form, specifierNode, names) {
|
|
2352
|
+
const isLiteral = specifierNode.isKind(SyntaxKind.StringLiteral) || specifierNode.isKind(SyntaxKind.NoSubstitutionTemplateLiteral);
|
|
2353
|
+
return {
|
|
2354
|
+
form,
|
|
2355
|
+
line: node.getStartLineNumber(),
|
|
2356
|
+
names,
|
|
2357
|
+
specifier: isLiteral ? specifierNode.getLiteralValue() : void 0,
|
|
2358
|
+
text: specifierNode.getText()
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
targetOf(reference, file) {
|
|
2362
|
+
const directory = file.getDirectoryPath();
|
|
2363
|
+
if (reference.specifier === void 0) return {
|
|
2364
|
+
kind: "file",
|
|
2365
|
+
path: normalize(join(directory, reference.text)),
|
|
2366
|
+
visibility: "unresolved"
|
|
2367
|
+
};
|
|
2368
|
+
const resolved = this.resolve(reference.specifier, file);
|
|
2369
|
+
if (resolved !== void 0 && this.isInsideProject(resolved)) return this.fileTarget(resolved);
|
|
2370
|
+
if (reference.specifier.startsWith(".")) return {
|
|
2371
|
+
kind: "file",
|
|
2372
|
+
path: normalize(join(directory, reference.specifier)),
|
|
2373
|
+
visibility: "unresolved"
|
|
2374
|
+
};
|
|
2375
|
+
return {
|
|
2376
|
+
kind: "package",
|
|
2377
|
+
name: this.packageNameOf(reference.specifier)
|
|
2378
|
+
};
|
|
2379
|
+
}
|
|
2380
|
+
fileTarget(path) {
|
|
2381
|
+
return {
|
|
2382
|
+
kind: "file",
|
|
2383
|
+
path,
|
|
2384
|
+
visibility: this.scope.isIgnored(path) ? "ignored" : "analysed"
|
|
2385
|
+
};
|
|
2386
|
+
}
|
|
2387
|
+
resolve(specifier, file) {
|
|
2388
|
+
const resolved = ts.resolveModuleName(specifier, file.getFilePath(), this.project.getCompilerOptions(), this.project.getModuleResolutionHost()).resolvedModule?.resolvedFileName;
|
|
2389
|
+
return resolved === void 0 ? void 0 : normalize(resolved);
|
|
2390
|
+
}
|
|
2391
|
+
packageNameOf(specifier) {
|
|
2392
|
+
const segments = specifier.split("/");
|
|
2393
|
+
const length = specifier.startsWith("@") ? 2 : 1;
|
|
2394
|
+
return segments.slice(0, length).join("/");
|
|
2395
|
+
}
|
|
2396
|
+
isInsideProject(path) {
|
|
2397
|
+
const offset = relative(this.scope.projectDir, path);
|
|
2398
|
+
return offset !== "" && !offset.startsWith("..") && !isAbsolute(offset) && !offset.split(/[\\/]/).includes("node_modules");
|
|
2399
|
+
}
|
|
2400
|
+
};
|
|
2401
|
+
//#endregion
|
|
2402
|
+
//#region src/importer/ts-morph/readers/disable-reader.ts
|
|
2403
|
+
const marker = /^\s*\/\/\s*alveolus-disable-next-line\b\s*(.*?)\s*$/;
|
|
2404
|
+
var DisableReader = class {
|
|
2405
|
+
read(file) {
|
|
2406
|
+
const comments = [];
|
|
2407
|
+
const lines = file.getFullText().split(/\r?\n/);
|
|
2408
|
+
for (const [index, line] of lines.entries()) {
|
|
2409
|
+
const text = marker.exec(line)?.[1];
|
|
2410
|
+
if (text !== void 0) comments.push(new DisableComment(index + 1, text));
|
|
2411
|
+
}
|
|
2412
|
+
return comments;
|
|
2413
|
+
}
|
|
2414
|
+
};
|
|
2415
|
+
//#endregion
|
|
2416
|
+
//#region src/importer/ts-morph/readers/global-reader.ts
|
|
2417
|
+
const ecmaScriptLibrary = /^lib\.(es|decorators).*\.d\.ts$/;
|
|
2418
|
+
var GlobalReader = class {
|
|
2419
|
+
projectDir;
|
|
2420
|
+
constructor(projectDir) {
|
|
2421
|
+
this.projectDir = projectDir;
|
|
2422
|
+
}
|
|
2423
|
+
read(file) {
|
|
2424
|
+
const references = [];
|
|
2425
|
+
for (const identifier of file.getDescendantsOfKind(SyntaxKind.Identifier)) {
|
|
2426
|
+
const declaringFile = this.globalDeclaringFileOf(identifier);
|
|
2427
|
+
if (declaringFile === void 0 || declaringFile === file) continue;
|
|
2428
|
+
const origin = this.originOf(declaringFile.getFilePath());
|
|
2429
|
+
const effect = origin === "ecmascript" ? this.effectOf(identifier) : void 0;
|
|
2430
|
+
references.push({
|
|
2431
|
+
declaredIn: declaringFile.getFilePath(),
|
|
2432
|
+
effect: effect?.effect,
|
|
2433
|
+
line: identifier.getStartLineNumber(),
|
|
2434
|
+
name: effect?.name ?? identifier.getText(),
|
|
2435
|
+
origin
|
|
2436
|
+
});
|
|
2437
|
+
}
|
|
2438
|
+
return references;
|
|
2439
|
+
}
|
|
2440
|
+
globalDeclaringFileOf(identifier) {
|
|
2441
|
+
const symbol = identifier.getSymbol();
|
|
2442
|
+
const declaration = symbol?.getValueDeclaration() ?? symbol?.getDeclarations()[0];
|
|
2443
|
+
if (declaration === void 0 || !this.isGlobalScope(declaration)) return;
|
|
2444
|
+
return declaration.getSourceFile();
|
|
2445
|
+
}
|
|
2446
|
+
isGlobalScope(declaration) {
|
|
2447
|
+
const container = (Node.isVariableDeclaration(declaration) ? declaration.getVariableStatement() : declaration)?.getParent();
|
|
2448
|
+
if (Node.isSourceFile(container)) return !ts.isExternalModule(container.compilerNode);
|
|
2449
|
+
const module = container?.getParent();
|
|
2450
|
+
return Node.isModuleBlock(container) && Node.isModuleDeclaration(module) && module.getDeclarationKind() === ModuleDeclarationKind.Global;
|
|
2451
|
+
}
|
|
2452
|
+
originOf(path) {
|
|
2453
|
+
if (this.isInsideProject(path)) return "project";
|
|
2454
|
+
return ecmaScriptLibrary.test(basename(path)) ? "ecmascript" : "host";
|
|
2455
|
+
}
|
|
2456
|
+
effectOf(identifier) {
|
|
2457
|
+
const name = identifier.getText();
|
|
2458
|
+
const parent = identifier.getParent();
|
|
2459
|
+
if (name === "Date") {
|
|
2460
|
+
if (Node.isNewExpression(parent) && parent.getExpression() === identifier && parent.getArguments().length === 0) return {
|
|
2461
|
+
effect: "clock",
|
|
2462
|
+
name: "new Date()"
|
|
2463
|
+
};
|
|
2464
|
+
if (Node.isCallExpression(parent) && parent.getExpression() === identifier) return {
|
|
2465
|
+
effect: "clock",
|
|
2466
|
+
name: "Date()"
|
|
2467
|
+
};
|
|
2468
|
+
if (this.isMemberAccess(parent, identifier, "now")) return {
|
|
2469
|
+
effect: "clock",
|
|
2470
|
+
name: "Date.now"
|
|
2471
|
+
};
|
|
2472
|
+
}
|
|
2473
|
+
if (name === "Math" && this.isMemberAccess(parent, identifier, "random")) return {
|
|
2474
|
+
effect: "randomness",
|
|
2475
|
+
name: "Math.random"
|
|
2476
|
+
};
|
|
2477
|
+
}
|
|
2478
|
+
isMemberAccess(parent, object, member) {
|
|
2479
|
+
return Node.isPropertyAccessExpression(parent) && parent.getExpression() === object && parent.getName() === member;
|
|
2480
|
+
}
|
|
2481
|
+
isInsideProject(path) {
|
|
2482
|
+
const offset = relative(this.projectDir, path);
|
|
2483
|
+
return offset !== "" && !offset.startsWith("..") && !isAbsolute(offset) && !offset.split(/[\\/]/).includes("node_modules");
|
|
2484
|
+
}
|
|
2485
|
+
};
|
|
2486
|
+
//#endregion
|
|
2487
|
+
//#region src/importer/ts-morph/readers/statement-reader.ts
|
|
2488
|
+
const declarative = /* @__PURE__ */ new Set([
|
|
2489
|
+
SyntaxKind.ImportDeclaration,
|
|
2490
|
+
SyntaxKind.ImportEqualsDeclaration,
|
|
2491
|
+
SyntaxKind.ExportDeclaration,
|
|
2492
|
+
SyntaxKind.ClassDeclaration,
|
|
2493
|
+
SyntaxKind.InterfaceDeclaration,
|
|
2494
|
+
SyntaxKind.TypeAliasDeclaration,
|
|
2495
|
+
SyntaxKind.EmptyStatement
|
|
2496
|
+
]);
|
|
2497
|
+
const literals = /* @__PURE__ */ new Set([
|
|
2498
|
+
SyntaxKind.StringLiteral,
|
|
2499
|
+
SyntaxKind.NumericLiteral,
|
|
2500
|
+
SyntaxKind.BigIntLiteral,
|
|
2501
|
+
SyntaxKind.NoSubstitutionTemplateLiteral,
|
|
2502
|
+
SyntaxKind.RegularExpressionLiteral,
|
|
2503
|
+
SyntaxKind.TrueKeyword,
|
|
2504
|
+
SyntaxKind.FalseKeyword,
|
|
2505
|
+
SyntaxKind.NullKeyword
|
|
2506
|
+
]);
|
|
2507
|
+
var StatementReader = class {
|
|
2508
|
+
read(file) {
|
|
2509
|
+
return file.getStatements().flatMap((statement) => this.declarationsOf(statement));
|
|
2510
|
+
}
|
|
2511
|
+
declarationsOf(statement) {
|
|
2512
|
+
if (declarative.has(statement.getKind())) return [];
|
|
2513
|
+
const line = statement.getStartLineNumber();
|
|
2514
|
+
if (Node.isFunctionDeclaration(statement)) return [new TopLevelStatement("function", statement.getName() ?? "default", line)];
|
|
2515
|
+
if (Node.isEnumDeclaration(statement)) return [new TopLevelStatement("enum", statement.getName(), line)];
|
|
2516
|
+
if (Node.isModuleDeclaration(statement)) return statement.getDeclarationKind() === ModuleDeclarationKind.Global ? [] : [new TopLevelStatement("namespace", statement.getName(), line)];
|
|
2517
|
+
if (Node.isVariableStatement(statement)) return this.variablesOf(statement);
|
|
2518
|
+
if (Node.isExportAssignment(statement)) return this.isData(statement.getExpression()) ? [] : [new TopLevelStatement("computed constant", "default", line)];
|
|
2519
|
+
return [new TopLevelStatement("statement", statement.getKindName(), line)];
|
|
2520
|
+
}
|
|
2521
|
+
variablesOf(statement) {
|
|
2522
|
+
const isConstant = statement.getDeclarationKind() === VariableDeclarationKind.Const;
|
|
2523
|
+
const declarations = [];
|
|
2524
|
+
for (const variable of statement.getDeclarations()) {
|
|
2525
|
+
const kind = isConstant ? this.constantKindOf(variable) : "mutable variable";
|
|
2526
|
+
if (kind !== void 0) declarations.push(new TopLevelStatement(kind, variable.getName(), variable.getStartLineNumber()));
|
|
2527
|
+
}
|
|
2528
|
+
return declarations;
|
|
2529
|
+
}
|
|
2530
|
+
constantKindOf(variable) {
|
|
2531
|
+
const initializer = variable.getInitializer();
|
|
2532
|
+
if (initializer === void 0) return;
|
|
2533
|
+
const value = this.unwrapped(initializer);
|
|
2534
|
+
if (Node.isClassExpression(value)) return "class expression";
|
|
2535
|
+
if (value.getType().getCallSignatures().length > 0) return "function";
|
|
2536
|
+
return this.isData(initializer) ? void 0 : "computed constant";
|
|
2537
|
+
}
|
|
2538
|
+
isData(expression) {
|
|
2539
|
+
const value = this.unwrapped(expression);
|
|
2540
|
+
if (literals.has(value.getKind())) return true;
|
|
2541
|
+
if (Node.isPrefixUnaryExpression(value)) return this.isData(value.getOperand());
|
|
2542
|
+
if (Node.isBinaryExpression(value)) return this.isData(value.getLeft()) && this.isData(value.getRight());
|
|
2543
|
+
if (Node.isTemplateExpression(value)) return value.getTemplateSpans().every((span) => this.isData(span.getExpression()));
|
|
2544
|
+
if (Node.isArrayLiteralExpression(value)) return value.getElements().every((element) => this.isData(Node.isSpreadElement(element) ? element.getExpression() : element));
|
|
2545
|
+
if (Node.isObjectLiteralExpression(value)) return value.getProperties().every((property) => this.isDataProperty(property));
|
|
2546
|
+
if (Node.isIdentifier(value) || Node.isPropertyAccessExpression(value)) return value.getType().getCallSignatures().length === 0;
|
|
2547
|
+
return false;
|
|
2548
|
+
}
|
|
2549
|
+
isDataProperty(property) {
|
|
2550
|
+
if (Node.isPropertyAssignment(property)) {
|
|
2551
|
+
const initializer = property.getInitializer();
|
|
2552
|
+
return initializer !== void 0 && this.isData(initializer);
|
|
2553
|
+
}
|
|
2554
|
+
if (Node.isShorthandPropertyAssignment(property)) return property.getNameNode().getType().getCallSignatures().length === 0;
|
|
2555
|
+
if (Node.isSpreadAssignment(property)) return this.isData(property.getExpression());
|
|
2556
|
+
return false;
|
|
2557
|
+
}
|
|
2558
|
+
unwrapped(expression) {
|
|
2559
|
+
if (Node.isParenthesizedExpression(expression) || Node.isAsExpression(expression) || Node.isSatisfiesExpression(expression) || Node.isNonNullExpression(expression)) return this.unwrapped(expression.getExpression());
|
|
2560
|
+
if (Node.isTypeAssertion(expression)) return this.unwrapped(expression.getExpression());
|
|
2561
|
+
return expression;
|
|
2562
|
+
}
|
|
2563
|
+
};
|
|
2564
|
+
//#endregion
|
|
2565
|
+
//#region src/importer/ts-morph/readers/throw-reader.ts
|
|
2566
|
+
var ThrowReader = class {
|
|
2567
|
+
read(file) {
|
|
2568
|
+
const throws = [];
|
|
2569
|
+
for (const statement of file.getDescendantsOfKind(SyntaxKind.ThrowStatement)) throws.push(new Throw(statement.getStartLineNumber(), "throw"));
|
|
2570
|
+
for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) if (call.getExpression().getText() === "Promise.reject") throws.push(new Throw(call.getStartLineNumber(), "Promise.reject"));
|
|
2571
|
+
return throws.sort((left, right) => left.line - right.line);
|
|
2572
|
+
}
|
|
2573
|
+
};
|
|
2574
|
+
//#endregion
|
|
2575
|
+
//#region src/importer/ts-morph/readers/type-reader.ts
|
|
2576
|
+
var TypeReader = class {
|
|
2577
|
+
packageNames;
|
|
2578
|
+
constructor(packageNames) {
|
|
2579
|
+
this.packageNames = packageNames;
|
|
2580
|
+
}
|
|
2581
|
+
classTypesIn(type) {
|
|
2582
|
+
const found = [];
|
|
2583
|
+
this.collectClassTypes(type, found, /* @__PURE__ */ new Set());
|
|
2584
|
+
return found;
|
|
2585
|
+
}
|
|
2586
|
+
classTypeOf(type) {
|
|
2587
|
+
const symbol = type.getSymbol();
|
|
2588
|
+
const declaredIn = symbol?.getDeclarations()[0]?.getSourceFile().getFilePath();
|
|
2589
|
+
return new ClassType(symbol?.getName() ?? type.getText(), declaredIn === void 0 ? void 0 : normalize(declaredIn), this.lineageOf(type));
|
|
2590
|
+
}
|
|
2591
|
+
namedTypeOf(type) {
|
|
2592
|
+
const symbol = type.getSymbol() ?? type.getAliasSymbol();
|
|
2593
|
+
return symbol === void 0 ? {
|
|
2594
|
+
name: type.getText(),
|
|
2595
|
+
packageName: void 0
|
|
2596
|
+
} : this.namedSymbol(symbol);
|
|
2597
|
+
}
|
|
2598
|
+
returnShapeOf(type) {
|
|
2599
|
+
if (type.getSymbol()?.getName() === "Promise") {
|
|
2600
|
+
const [inner] = type.getTypeArguments();
|
|
2601
|
+
if (inner !== void 0) return this.returnShapeOf(inner);
|
|
2602
|
+
}
|
|
2603
|
+
const alias = type.getAliasSymbol();
|
|
2604
|
+
const members = type.isUnion() ? type.getUnionTypes() : [type];
|
|
2605
|
+
return {
|
|
2606
|
+
alias: alias === void 0 ? void 0 : this.namedSymbol(alias),
|
|
2607
|
+
union: members.map((member) => this.namedTypeOf(member))
|
|
2608
|
+
};
|
|
2609
|
+
}
|
|
2610
|
+
lineageOf(type) {
|
|
2611
|
+
const classType = type.getTargetType() ?? type;
|
|
2612
|
+
const symbol = classType.getSymbol();
|
|
2613
|
+
if (symbol === void 0 || !classType.isClass()) return [];
|
|
2614
|
+
const lineage = [this.namedSymbol(symbol)];
|
|
2615
|
+
for (const base of classType.getBaseTypes()) lineage.push(...this.lineageOf(base));
|
|
2616
|
+
return lineage;
|
|
2617
|
+
}
|
|
2618
|
+
namedSymbol(symbol) {
|
|
2619
|
+
const declaration = symbol.getDeclarations()[0];
|
|
2620
|
+
const packageName = declaration === void 0 ? void 0 : this.packageNames.ofFile(declaration.getSourceFile().getFilePath());
|
|
2621
|
+
return {
|
|
2622
|
+
name: symbol.getName(),
|
|
2623
|
+
packageName
|
|
2624
|
+
};
|
|
2625
|
+
}
|
|
2626
|
+
collectClassTypes(type, found, seen) {
|
|
2627
|
+
if (seen.has(type.compilerType)) return;
|
|
2628
|
+
seen.add(type.compilerType);
|
|
2629
|
+
if (type.isUnion()) {
|
|
2630
|
+
for (const member of type.getUnionTypes()) this.collectClassTypes(member, found, seen);
|
|
2631
|
+
return;
|
|
2632
|
+
}
|
|
2633
|
+
if (type.isIntersection()) {
|
|
2634
|
+
for (const member of type.getIntersectionTypes()) this.collectClassTypes(member, found, seen);
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
if (type.isClass()) found.push(this.classTypeOf(type));
|
|
2638
|
+
for (const inner of this.innerTypesOf(type)) this.collectClassTypes(inner, found, seen);
|
|
2639
|
+
}
|
|
2640
|
+
innerTypesOf(type) {
|
|
2641
|
+
const inner = [
|
|
2642
|
+
...type.getTypeArguments(),
|
|
2643
|
+
...type.getAliasTypeArguments(),
|
|
2644
|
+
...type.getTupleElements()
|
|
2645
|
+
];
|
|
2646
|
+
for (const signature of type.getCallSignatures()) inner.push(signature.getReturnType());
|
|
2647
|
+
if (this.isDeclaredByProject(type)) for (const property of type.getProperties()) {
|
|
2648
|
+
const declaration = property.getDeclarations()[0];
|
|
2649
|
+
if (declaration !== void 0) inner.push(declaration.getType());
|
|
2650
|
+
}
|
|
2651
|
+
return inner;
|
|
2652
|
+
}
|
|
2653
|
+
isDeclaredByProject(type) {
|
|
2654
|
+
if (!type.isObject() || type.isClass()) return false;
|
|
2655
|
+
const declaration = (type.getSymbol() ?? type.getAliasSymbol())?.getDeclarations()[0];
|
|
2656
|
+
if (declaration === void 0) return false;
|
|
2657
|
+
const file = declaration.getSourceFile();
|
|
2658
|
+
return !file.isInNodeModules() && !file.isDeclarationFile();
|
|
2659
|
+
}
|
|
2660
|
+
};
|
|
2661
|
+
//#endregion
|
|
2662
|
+
//#region src/importer/ts-morph/ts-morph-importer.ts
|
|
2663
|
+
var TsMorphImporter = class TsMorphImporter extends Importer {
|
|
2664
|
+
sources;
|
|
2665
|
+
classes;
|
|
2666
|
+
statements = new StatementReader();
|
|
2667
|
+
disables = new DisableReader();
|
|
2668
|
+
throws = new ThrowReader();
|
|
2669
|
+
constructor(sources) {
|
|
2670
|
+
super();
|
|
2671
|
+
this.sources = sources;
|
|
2672
|
+
this.classes = new ClassReader(new TypeReader(new PackageNames(sources.getFileSystem())));
|
|
2673
|
+
}
|
|
2674
|
+
static fromTsConfig(tsConfigFilePath) {
|
|
2675
|
+
if (!existsSync(tsConfigFilePath)) throw new Error(`No TypeScript configuration found at ${tsConfigFilePath}: pass --tsconfig, or set tsconfig in alveolus.config.ts.`);
|
|
2676
|
+
return new TsMorphImporter(new Project({ tsConfigFilePath }));
|
|
2677
|
+
}
|
|
2678
|
+
resolves(packageName, scope) {
|
|
2679
|
+
const from = join(scope.rootDir, "index.ts");
|
|
2680
|
+
return ts.resolveModuleName(packageName, from, this.sources.getCompilerOptions(), this.sources.getModuleResolutionHost()).resolvedModule !== void 0;
|
|
2681
|
+
}
|
|
2682
|
+
read(scope) {
|
|
2683
|
+
const dependencies = new DependencyReader(this.sources, scope);
|
|
2684
|
+
const globals = new GlobalReader(scope.projectDir);
|
|
2685
|
+
const files = [];
|
|
2686
|
+
for (const file of this.sources.getSourceFiles()) if (this.isInside(scope.rootDir, file.getFilePath()) && !scope.isIgnored(file.getFilePath())) files.push(this.readFile(file, dependencies, globals));
|
|
2687
|
+
return new Project$1(scope.projectDir, files);
|
|
2688
|
+
}
|
|
2689
|
+
readFile(file, dependencies, globals) {
|
|
2690
|
+
const globalReferences = globals.read(file);
|
|
2691
|
+
return new SourceFile({
|
|
2692
|
+
classes: file.getClasses().map((declaration) => this.classes.read(declaration)),
|
|
2693
|
+
dependencies: dependencies.read(file, globalReferences),
|
|
2694
|
+
disables: this.disables.read(file),
|
|
2695
|
+
globals: this.globalUsesOf(globalReferences),
|
|
2696
|
+
path: normalize(file.getFilePath()),
|
|
2697
|
+
statements: this.statements.read(file),
|
|
2698
|
+
text: file.getFullText(),
|
|
2699
|
+
throws: this.throws.read(file)
|
|
2700
|
+
});
|
|
2701
|
+
}
|
|
2702
|
+
globalUsesOf(references) {
|
|
2703
|
+
const uses = [];
|
|
2704
|
+
for (const reference of references) if (reference.origin !== "project") uses.push(new GlobalUse(reference.line, reference.name, reference.origin, reference.effect));
|
|
2705
|
+
return uses;
|
|
2706
|
+
}
|
|
2707
|
+
isInside(directory, path) {
|
|
2708
|
+
const offset = relative(directory, path);
|
|
2709
|
+
return offset !== "" && !offset.startsWith("..") && !isAbsolute(offset) && !offset.split(/[\\/]/).includes("node_modules");
|
|
2710
|
+
}
|
|
2711
|
+
};
|
|
2712
|
+
//#endregion
|
|
2713
|
+
//#region src/cli/cli.ts
|
|
2714
|
+
var Cli = class {
|
|
2715
|
+
stdout;
|
|
2716
|
+
stderr;
|
|
2717
|
+
cwd;
|
|
2718
|
+
colored;
|
|
2719
|
+
exitCode = 0;
|
|
2720
|
+
constructor(stdout, stderr, cwd, colored = false) {
|
|
2721
|
+
this.stdout = stdout;
|
|
2722
|
+
this.stderr = stderr;
|
|
2723
|
+
this.cwd = cwd;
|
|
2724
|
+
this.colored = colored;
|
|
2725
|
+
}
|
|
2726
|
+
async run(args) {
|
|
2727
|
+
this.exitCode = 0;
|
|
2728
|
+
try {
|
|
2729
|
+
await this.program().parseAsync([...args], { from: "user" });
|
|
2730
|
+
} catch (error) {
|
|
2731
|
+
return this.fail(error);
|
|
2732
|
+
}
|
|
2733
|
+
return this.exitCode;
|
|
2734
|
+
}
|
|
2735
|
+
program() {
|
|
2736
|
+
const program = new Command("alveolus").exitOverride().configureOutput({
|
|
2737
|
+
writeErr: (text) => this.stderr.write(text),
|
|
2738
|
+
writeOut: (text) => this.stdout.write(text)
|
|
2739
|
+
});
|
|
2740
|
+
const arch = program.command("arch").description("Check the architecture of a Domain-Driven Design project");
|
|
2741
|
+
this.withOptions(arch.command("check").description("Report the violations that are not in the baseline")).action((options) => this.check(options));
|
|
2742
|
+
this.withOptions(arch.command("baseline").description(`Write the current violations to ${Baseline.fileName}`)).option("--allow-growth", "write the baseline even when it holds more entries than before").action((options) => this.baseline(options));
|
|
2743
|
+
return program;
|
|
2744
|
+
}
|
|
2745
|
+
withOptions(command) {
|
|
2746
|
+
return command.option("--project <dir>", "project directory", ".").option("--config <file>", "configuration file", ConfigLoader.fileName).option("--tsconfig <file>", "TypeScript configuration, tsconfig.json or the one set in the configuration file").addOption(new Option("--format <format>", "how violations are printed").choices([
|
|
2747
|
+
"text",
|
|
2748
|
+
"json",
|
|
2749
|
+
"sarif"
|
|
2750
|
+
]).default("text"));
|
|
2751
|
+
}
|
|
2752
|
+
async check(options) {
|
|
2753
|
+
const { config, registry, outcome } = await this.analyze(options);
|
|
2754
|
+
const baseline = await Baseline.load(join(config.projectDir, Baseline.fileName));
|
|
2755
|
+
const fresh = baseline.newViolations(outcome.violations);
|
|
2756
|
+
const stale = baseline.staleEntries(outcome.violations);
|
|
2757
|
+
if (baseline.outdatedEntries > 0) this.warnOutdated(baseline.outdatedEntries);
|
|
2758
|
+
if (stale > 0) this.stderr.write(`${stale} ${stale === 1 ? "entry" : "entries"} of the baseline match nothing any more: run alveolus arch baseline to drop ${stale === 1 ? "it" : "them"}.\n`);
|
|
2759
|
+
const report = new Report({
|
|
2760
|
+
baselined: outcome.violations.length - fresh.length,
|
|
2761
|
+
files: outcome.files,
|
|
2762
|
+
stale,
|
|
2763
|
+
suppressed: outcome.suppressed,
|
|
2764
|
+
violations: fresh
|
|
2765
|
+
}, this.colored);
|
|
2766
|
+
this.stdout.write(this.render(report, options.format, registry));
|
|
2767
|
+
this.exitCode = fresh.some((violation) => violation.severity === "error") ? 1 : 0;
|
|
2768
|
+
}
|
|
2769
|
+
render(report, format, registry) {
|
|
2770
|
+
if (format === "json") return report.json();
|
|
2771
|
+
if (format === "sarif") return report.sarif(registry.rules.map((rule) => rule.meta));
|
|
2772
|
+
return report.text();
|
|
2773
|
+
}
|
|
2774
|
+
warnOutdated(count) {
|
|
2775
|
+
const entries = count === 1 ? "1 entry of the baseline has no fingerprint and matches" : `${count} entries of the baseline have no fingerprint and match`;
|
|
2776
|
+
this.stderr.write(`${entries} nothing: run alveolus arch baseline to write ${Baseline.fileName} again.\n`);
|
|
2777
|
+
}
|
|
2778
|
+
async baseline(options) {
|
|
2779
|
+
const { config, outcome } = await this.analyze(options);
|
|
2780
|
+
const path = join(config.projectDir, Baseline.fileName);
|
|
2781
|
+
const previous = (await Baseline.load(path)).size;
|
|
2782
|
+
if (existsSync(path) && outcome.violations.length > previous && options.allowGrowth !== true) {
|
|
2783
|
+
this.stderr.write(`The baseline would grow from ${previous} to ${outcome.violations.length} entries: fix the new violations, or pass --allow-growth.\n`);
|
|
2784
|
+
this.exitCode = 1;
|
|
2785
|
+
return;
|
|
2786
|
+
}
|
|
2787
|
+
await Baseline.of(outcome.violations).save(path);
|
|
2788
|
+
this.stdout.write(`${outcome.violations.length} violations written to ${Baseline.fileName}\n`);
|
|
2789
|
+
}
|
|
2790
|
+
async analyze(options) {
|
|
2791
|
+
const projectDir = resolve(this.cwd, options.project);
|
|
2792
|
+
const config = await new ConfigLoader().load(projectDir, options.config);
|
|
2793
|
+
const tsConfigPath = options.tsconfig === void 0 ? config.tsConfigPath : resolve(config.projectDir, options.tsconfig);
|
|
2794
|
+
const registry = new RuleRegistry();
|
|
2795
|
+
return {
|
|
2796
|
+
config,
|
|
2797
|
+
outcome: new Checker(TsMorphImporter.fromTsConfig(tsConfigPath), registry.rules).check(config),
|
|
2798
|
+
registry
|
|
2799
|
+
};
|
|
2800
|
+
}
|
|
2801
|
+
fail(error) {
|
|
2802
|
+
if (error instanceof CommanderError) return error.exitCode === 0 ? 0 : 2;
|
|
2803
|
+
this.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
2804
|
+
return 2;
|
|
2805
|
+
}
|
|
2806
|
+
};
|
|
2807
|
+
//#endregion
|
|
2808
|
+
export { Config as a, RuleRegistry as c, AllowedPackages as d, Baseline as f, ConfigLoader as i, Rule as l, TsMorphImporter as n, Report as o, Importer as r, Checker as s, Cli as t, Architecture as u };
|
|
2809
|
+
|
|
2810
|
+
//# sourceMappingURL=cli-CwPCGjDg.mjs.map
|