@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
package/dist/cli-CW1zAnrP.mjs
DELETED
|
@@ -1,1229 +0,0 @@
|
|
|
1
|
-
import { Command, CommanderError } from "commander";
|
|
2
|
-
import { basename, dirname, isAbsolute, join, matchesGlob, relative, resolve, sep } from "node:path";
|
|
3
|
-
import { Project, Scope, SyntaxKind } from "ts-morph";
|
|
4
|
-
import { existsSync } from "node:fs";
|
|
5
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
6
|
-
import { createJiti } from "jiti";
|
|
7
|
-
import { z } from "zod";
|
|
8
|
-
import picocolors from "picocolors";
|
|
9
|
-
//#region src/analysis/code-analyzer.ts
|
|
10
|
-
var CodeAnalyzer = class {};
|
|
11
|
-
//#endregion
|
|
12
|
-
//#region src/codebase/code-class.ts
|
|
13
|
-
var CodeClass = class {
|
|
14
|
-
name;
|
|
15
|
-
line;
|
|
16
|
-
isAbstract;
|
|
17
|
-
constructorParameters;
|
|
18
|
-
fields;
|
|
19
|
-
publicMethods;
|
|
20
|
-
kinds;
|
|
21
|
-
markers;
|
|
22
|
-
constructor(props) {
|
|
23
|
-
this.name = props.name;
|
|
24
|
-
this.line = props.line;
|
|
25
|
-
this.isAbstract = props.isAbstract;
|
|
26
|
-
this.kinds = props.kinds;
|
|
27
|
-
this.markers = props.markers;
|
|
28
|
-
this.constructorParameters = props.constructorParameters;
|
|
29
|
-
this.fields = props.fields;
|
|
30
|
-
this.publicMethods = props.publicMethods;
|
|
31
|
-
}
|
|
32
|
-
is(kind) {
|
|
33
|
-
return this.kinds.includes(kind);
|
|
34
|
-
}
|
|
35
|
-
implements(marker) {
|
|
36
|
-
return this.markers.includes(marker);
|
|
37
|
-
}
|
|
38
|
-
get extendsBuildingBlock() {
|
|
39
|
-
return this.kinds.length > 0;
|
|
40
|
-
}
|
|
41
|
-
get hasMarker() {
|
|
42
|
-
return this.markers.length > 0;
|
|
43
|
-
}
|
|
44
|
-
};
|
|
45
|
-
//#endregion
|
|
46
|
-
//#region src/codebase/code-file.ts
|
|
47
|
-
var CodeFile = class {
|
|
48
|
-
path;
|
|
49
|
-
location;
|
|
50
|
-
imports;
|
|
51
|
-
classes;
|
|
52
|
-
declarations;
|
|
53
|
-
domainErrorThrows;
|
|
54
|
-
constructor(props) {
|
|
55
|
-
this.path = props.path;
|
|
56
|
-
this.location = props.location;
|
|
57
|
-
this.imports = props.imports;
|
|
58
|
-
this.classes = props.classes;
|
|
59
|
-
this.declarations = props.declarations;
|
|
60
|
-
this.domainErrorThrows = props.domainErrorThrows;
|
|
61
|
-
}
|
|
62
|
-
declaresOpenHostService(name) {
|
|
63
|
-
return this.classes.some((codeClass) => codeClass.name === name && codeClass.implements("OpenHostService"));
|
|
64
|
-
}
|
|
65
|
-
get declaresAntiCorruptionLayer() {
|
|
66
|
-
return this.classes.some((codeClass) => codeClass.implements("AntiCorruptionLayer"));
|
|
67
|
-
}
|
|
68
|
-
};
|
|
69
|
-
//#endregion
|
|
70
|
-
//#region src/codebase/codebase.ts
|
|
71
|
-
var Codebase = class {
|
|
72
|
-
files;
|
|
73
|
-
config;
|
|
74
|
-
filesByPath;
|
|
75
|
-
constructor(files, config) {
|
|
76
|
-
this.files = files;
|
|
77
|
-
this.config = config;
|
|
78
|
-
this.filesByPath = new Map(files.map((file) => [file.path, file]));
|
|
79
|
-
}
|
|
80
|
-
file(path) {
|
|
81
|
-
return this.filesByPath.get(path);
|
|
82
|
-
}
|
|
83
|
-
relativePath(path) {
|
|
84
|
-
return relative(this.config.projectDir, path).split(sep).join("/");
|
|
85
|
-
}
|
|
86
|
-
};
|
|
87
|
-
//#endregion
|
|
88
|
-
//#region src/codebase/core-api.ts
|
|
89
|
-
var CoreApi = class CoreApi {
|
|
90
|
-
static packageName = "@alveolus/core";
|
|
91
|
-
static domainSymbols = /* @__PURE__ */ new Set([
|
|
92
|
-
"AggregateRoot",
|
|
93
|
-
"AnyAggregateRoot",
|
|
94
|
-
"AnyDomainError",
|
|
95
|
-
"AnyDomainEvent",
|
|
96
|
-
"AnyEntity",
|
|
97
|
-
"AnyIdentifier",
|
|
98
|
-
"AnySnapshot",
|
|
99
|
-
"AnyValueObject",
|
|
100
|
-
"Clock",
|
|
101
|
-
"CommandRepository",
|
|
102
|
-
"DomainError",
|
|
103
|
-
"DomainEvent",
|
|
104
|
-
"DomainEventProps",
|
|
105
|
-
"DomainService",
|
|
106
|
-
"Entity",
|
|
107
|
-
"Err",
|
|
108
|
-
"IdGenerator",
|
|
109
|
-
"Identifier",
|
|
110
|
-
"IdentifierValue",
|
|
111
|
-
"Ok",
|
|
112
|
-
"Port",
|
|
113
|
-
"QueryRepository",
|
|
114
|
-
"Result",
|
|
115
|
-
"SnapshotValue",
|
|
116
|
-
"ValueObject",
|
|
117
|
-
"View",
|
|
118
|
-
"andThen",
|
|
119
|
-
"combine",
|
|
120
|
-
"err",
|
|
121
|
-
"map",
|
|
122
|
-
"mapErr",
|
|
123
|
-
"ok"
|
|
124
|
-
]);
|
|
125
|
-
static publishedLanguageSymbols = /* @__PURE__ */ new Set([
|
|
126
|
-
"AnyIntegrationEvent",
|
|
127
|
-
"IntegrationEvent",
|
|
128
|
-
"JsonValue",
|
|
129
|
-
"PublishedLanguage"
|
|
130
|
-
]);
|
|
131
|
-
static markers = /* @__PURE__ */ new Set(["OpenHostService", "AntiCorruptionLayer"]);
|
|
132
|
-
static kinds = /* @__PURE__ */ new Set([
|
|
133
|
-
"AggregateRoot",
|
|
134
|
-
"Entity",
|
|
135
|
-
"ValueObject",
|
|
136
|
-
"Identifier",
|
|
137
|
-
"DomainEvent",
|
|
138
|
-
"DomainError",
|
|
139
|
-
"DomainService",
|
|
140
|
-
"Port",
|
|
141
|
-
"CommandRepository",
|
|
142
|
-
"QueryRepository",
|
|
143
|
-
"CommandHandler",
|
|
144
|
-
"QueryHandler",
|
|
145
|
-
"EventTranslator",
|
|
146
|
-
"EventPublisher",
|
|
147
|
-
"Outbox",
|
|
148
|
-
"UnitOfWork"
|
|
149
|
-
]);
|
|
150
|
-
static isDomainSymbol(name) {
|
|
151
|
-
return CoreApi.domainSymbols.has(name);
|
|
152
|
-
}
|
|
153
|
-
static isPublishedLanguageSymbol(name) {
|
|
154
|
-
return CoreApi.publishedLanguageSymbols.has(name);
|
|
155
|
-
}
|
|
156
|
-
static isKind(name) {
|
|
157
|
-
return CoreApi.kinds.has(name);
|
|
158
|
-
}
|
|
159
|
-
static isMarker(name) {
|
|
160
|
-
return CoreApi.markers.has(name);
|
|
161
|
-
}
|
|
162
|
-
};
|
|
163
|
-
//#endregion
|
|
164
|
-
//#region src/codebase/declaration.ts
|
|
165
|
-
var Declaration = class {
|
|
166
|
-
kind;
|
|
167
|
-
name;
|
|
168
|
-
line;
|
|
169
|
-
constructor(kind, name, line) {
|
|
170
|
-
this.kind = kind;
|
|
171
|
-
this.name = name;
|
|
172
|
-
this.line = line;
|
|
173
|
-
}
|
|
174
|
-
};
|
|
175
|
-
//#endregion
|
|
176
|
-
//#region src/codebase/import.ts
|
|
177
|
-
var Import = class {
|
|
178
|
-
line;
|
|
179
|
-
specifier;
|
|
180
|
-
names;
|
|
181
|
-
target;
|
|
182
|
-
constructor(line, specifier, names, target) {
|
|
183
|
-
this.line = line;
|
|
184
|
-
this.specifier = specifier;
|
|
185
|
-
this.names = names;
|
|
186
|
-
this.target = target;
|
|
187
|
-
}
|
|
188
|
-
get label() {
|
|
189
|
-
return this.names.length === 0 ? this.specifier : this.names.join(", ");
|
|
190
|
-
}
|
|
191
|
-
isFromPackage(name) {
|
|
192
|
-
return this.target.kind === "package" && this.target.name === name;
|
|
193
|
-
}
|
|
194
|
-
};
|
|
195
|
-
//#endregion
|
|
196
|
-
//#region src/codebase/location.ts
|
|
197
|
-
var Location = class {
|
|
198
|
-
area;
|
|
199
|
-
context;
|
|
200
|
-
layer;
|
|
201
|
-
folder;
|
|
202
|
-
fileName;
|
|
203
|
-
isCompositionRoot;
|
|
204
|
-
constructor(props) {
|
|
205
|
-
this.area = props.area;
|
|
206
|
-
this.context = props.context;
|
|
207
|
-
this.layer = props.layer;
|
|
208
|
-
this.folder = props.folder;
|
|
209
|
-
this.fileName = props.fileName;
|
|
210
|
-
this.isCompositionRoot = props.isCompositionRoot ?? false;
|
|
211
|
-
}
|
|
212
|
-
get isInBoundedContext() {
|
|
213
|
-
return this.area === "context";
|
|
214
|
-
}
|
|
215
|
-
get isInSharedKernel() {
|
|
216
|
-
return this.area === "shared-kernel";
|
|
217
|
-
}
|
|
218
|
-
get isAtRoot() {
|
|
219
|
-
return this.area === "root";
|
|
220
|
-
}
|
|
221
|
-
get isOutside() {
|
|
222
|
-
return this.area === "outside";
|
|
223
|
-
}
|
|
224
|
-
get isInLayer() {
|
|
225
|
-
return this.layer !== void 0;
|
|
226
|
-
}
|
|
227
|
-
isSameContextAs(other) {
|
|
228
|
-
return this.context !== void 0 && this.context === other.context;
|
|
229
|
-
}
|
|
230
|
-
isOtherBoundedContextThan(other) {
|
|
231
|
-
return this.isInBoundedContext && !this.isSameContextAs(other);
|
|
232
|
-
}
|
|
233
|
-
describe() {
|
|
234
|
-
if (this.isOutside) return "outside the declared bounded contexts and shared kernel";
|
|
235
|
-
if (this.isAtRoot) return "at the root";
|
|
236
|
-
const role = this.isCompositionRoot ? "composition root" : this.layer ?? "outside the layers";
|
|
237
|
-
return `${this.context} ${role}`;
|
|
238
|
-
}
|
|
239
|
-
};
|
|
240
|
-
//#endregion
|
|
241
|
-
//#region src/codebase/layout.ts
|
|
242
|
-
const layers = /* @__PURE__ */ new Set([
|
|
243
|
-
"domain",
|
|
244
|
-
"application",
|
|
245
|
-
"published-language",
|
|
246
|
-
"driven",
|
|
247
|
-
"driving"
|
|
248
|
-
]);
|
|
249
|
-
var Layout = class {
|
|
250
|
-
config;
|
|
251
|
-
constructor(config) {
|
|
252
|
-
this.config = config;
|
|
253
|
-
}
|
|
254
|
-
locate(path) {
|
|
255
|
-
const fileName = basename(path);
|
|
256
|
-
const folder = this.contextFolderOf(path);
|
|
257
|
-
if (folder === void 0) return new Location({
|
|
258
|
-
area: dirname(path) === this.config.rootDir ? "root" : "outside",
|
|
259
|
-
fileName
|
|
260
|
-
});
|
|
261
|
-
const area = folder.isSharedKernel ? "shared-kernel" : "context";
|
|
262
|
-
const context = folder.name;
|
|
263
|
-
const directories = this.directoriesBetween(folder.dir, path);
|
|
264
|
-
if (directories.length === 0) return new Location({
|
|
265
|
-
area,
|
|
266
|
-
context,
|
|
267
|
-
fileName,
|
|
268
|
-
isCompositionRoot: this.isCompositionRoot(fileName)
|
|
269
|
-
});
|
|
270
|
-
const layerIndex = directories.findIndex((directory) => layers.has(directory));
|
|
271
|
-
if (layerIndex === -1) return new Location({
|
|
272
|
-
area,
|
|
273
|
-
context,
|
|
274
|
-
fileName,
|
|
275
|
-
isCompositionRoot: folder.isSharedKernel && directories.length === 1 && this.isCompositionRoot(fileName)
|
|
276
|
-
});
|
|
277
|
-
const layer = directories[layerIndex];
|
|
278
|
-
const subfolder = directories.length - 1 > layerIndex ? directories.at(-1) : void 0;
|
|
279
|
-
return new Location({
|
|
280
|
-
area,
|
|
281
|
-
context,
|
|
282
|
-
fileName,
|
|
283
|
-
layer,
|
|
284
|
-
...subfolder === void 0 ? {} : { folder: subfolder }
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
isCompositionRoot(fileName) {
|
|
288
|
-
return matchesGlob(fileName, this.config.compositionRoot);
|
|
289
|
-
}
|
|
290
|
-
contextFolderOf(path) {
|
|
291
|
-
return this.config.contextFolders.filter((folder) => this.isInside(folder.dir, path)).sort((left, right) => right.dir.length - left.dir.length)[0];
|
|
292
|
-
}
|
|
293
|
-
directoriesBetween(dir, path) {
|
|
294
|
-
const between = relative(dir, dirname(path));
|
|
295
|
-
return between === "" ? [] : between.split(sep);
|
|
296
|
-
}
|
|
297
|
-
isInside(dir, path) {
|
|
298
|
-
const offset = relative(dir, path);
|
|
299
|
-
return offset !== "" && !offset.startsWith("..") && !isAbsolute(offset);
|
|
300
|
-
}
|
|
301
|
-
};
|
|
302
|
-
//#endregion
|
|
303
|
-
//#region src/codebase/method.ts
|
|
304
|
-
var Method = class {
|
|
305
|
-
name;
|
|
306
|
-
line;
|
|
307
|
-
returnsResult;
|
|
308
|
-
constructor(name, line, returnsResult) {
|
|
309
|
-
this.name = name;
|
|
310
|
-
this.line = line;
|
|
311
|
-
this.returnsResult = returnsResult;
|
|
312
|
-
}
|
|
313
|
-
};
|
|
314
|
-
//#endregion
|
|
315
|
-
//#region src/codebase/type-usage.ts
|
|
316
|
-
var TypeUsage = class {
|
|
317
|
-
member;
|
|
318
|
-
line;
|
|
319
|
-
typeName;
|
|
320
|
-
kinds;
|
|
321
|
-
constructor(member, line, typeName, kinds) {
|
|
322
|
-
this.member = member;
|
|
323
|
-
this.line = line;
|
|
324
|
-
this.typeName = typeName;
|
|
325
|
-
this.kinds = kinds;
|
|
326
|
-
}
|
|
327
|
-
is(kind) {
|
|
328
|
-
return this.kinds.includes(kind);
|
|
329
|
-
}
|
|
330
|
-
};
|
|
331
|
-
//#endregion
|
|
332
|
-
//#region src/analysis/class-reader.ts
|
|
333
|
-
var ClassReader = class {
|
|
334
|
-
types;
|
|
335
|
-
constructor(types) {
|
|
336
|
-
this.types = types;
|
|
337
|
-
}
|
|
338
|
-
read(declaration) {
|
|
339
|
-
const parameters = declaration.getConstructors()[0]?.getParameters() ?? [];
|
|
340
|
-
const parameterProperties = parameters.filter((parameter) => parameter.isParameterProperty());
|
|
341
|
-
return new CodeClass({
|
|
342
|
-
constructorParameters: parameters.flatMap((parameter) => this.usagesOf(parameter)),
|
|
343
|
-
fields: [...declaration.getProperties(), ...parameterProperties].flatMap((member) => this.usagesOf(member)),
|
|
344
|
-
isAbstract: declaration.isAbstract(),
|
|
345
|
-
kinds: this.types.kindsOf(declaration.getType()),
|
|
346
|
-
line: declaration.getStartLineNumber(),
|
|
347
|
-
markers: this.markersOf(declaration),
|
|
348
|
-
name: declaration.getName() ?? "default",
|
|
349
|
-
publicMethods: this.publicMethodsOf(declaration)
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
|
-
markersOf(declaration) {
|
|
353
|
-
const markers = [];
|
|
354
|
-
for (const clause of declaration.getImplements()) {
|
|
355
|
-
const marker = this.types.markerOf(clause.getType());
|
|
356
|
-
if (marker !== void 0) markers.push(marker);
|
|
357
|
-
}
|
|
358
|
-
return markers;
|
|
359
|
-
}
|
|
360
|
-
publicMethodsOf(declaration) {
|
|
361
|
-
return declaration.getMethods().filter((method) => !method.isStatic() && method.getScope() === Scope.Public).map((method) => new Method(method.getName(), method.getStartLineNumber(), this.types.isResult(method.getReturnType())));
|
|
362
|
-
}
|
|
363
|
-
usagesOf(member) {
|
|
364
|
-
return this.types.classTypesIn(member.getType()).map((type) => new TypeUsage(member.getName(), member.getStartLineNumber(), type.getSymbol()?.getName() ?? type.getText(), this.types.kindsOf(type)));
|
|
365
|
-
}
|
|
366
|
-
};
|
|
367
|
-
//#endregion
|
|
368
|
-
//#region src/analysis/package-names.ts
|
|
369
|
-
var PackageNames = class {
|
|
370
|
-
fileSystem;
|
|
371
|
-
cache = /* @__PURE__ */ new Map();
|
|
372
|
-
constructor(fileSystem) {
|
|
373
|
-
this.fileSystem = fileSystem;
|
|
374
|
-
}
|
|
375
|
-
ofFile(path) {
|
|
376
|
-
return this.ofDirectory(dirname(path));
|
|
377
|
-
}
|
|
378
|
-
ofDirectory(directory) {
|
|
379
|
-
if (this.cache.has(directory)) return this.cache.get(directory);
|
|
380
|
-
const name = this.readManifest(directory) ?? this.ofParent(directory);
|
|
381
|
-
this.cache.set(directory, name);
|
|
382
|
-
return name;
|
|
383
|
-
}
|
|
384
|
-
ofParent(directory) {
|
|
385
|
-
const parent = dirname(directory);
|
|
386
|
-
return parent === directory ? void 0 : this.ofDirectory(parent);
|
|
387
|
-
}
|
|
388
|
-
readManifest(directory) {
|
|
389
|
-
const manifest = join(directory, "package.json");
|
|
390
|
-
if (!this.fileSystem.fileExistsSync(manifest)) return;
|
|
391
|
-
const content = JSON.parse(this.fileSystem.readFileSync(manifest));
|
|
392
|
-
return typeof content.name === "string" ? content.name : void 0;
|
|
393
|
-
}
|
|
394
|
-
};
|
|
395
|
-
//#endregion
|
|
396
|
-
//#region src/analysis/type-inspector.ts
|
|
397
|
-
const containers = /* @__PURE__ */ new Set([
|
|
398
|
-
"Array",
|
|
399
|
-
"ReadonlyArray",
|
|
400
|
-
"Map",
|
|
401
|
-
"ReadonlyMap",
|
|
402
|
-
"Set",
|
|
403
|
-
"ReadonlySet",
|
|
404
|
-
"Readonly",
|
|
405
|
-
"Promise"
|
|
406
|
-
]);
|
|
407
|
-
var TypeInspector = class {
|
|
408
|
-
packageNames;
|
|
409
|
-
constructor(packageNames) {
|
|
410
|
-
this.packageNames = packageNames;
|
|
411
|
-
}
|
|
412
|
-
kindsOf(type) {
|
|
413
|
-
const classType = type.getTargetType() ?? type;
|
|
414
|
-
const symbol = classType.getSymbol();
|
|
415
|
-
if (symbol === void 0 || !classType.isClass()) return [];
|
|
416
|
-
const kinds = [];
|
|
417
|
-
const name = symbol.getName();
|
|
418
|
-
if (CoreApi.isKind(name) && this.isCore(symbol)) kinds.push(name);
|
|
419
|
-
for (const base of classType.getBaseTypes()) kinds.push(...this.kindsOf(base));
|
|
420
|
-
return kinds;
|
|
421
|
-
}
|
|
422
|
-
markerOf(type) {
|
|
423
|
-
const symbol = type.getSymbol();
|
|
424
|
-
if (symbol === void 0) return;
|
|
425
|
-
const name = symbol.getName();
|
|
426
|
-
return CoreApi.isMarker(name) && this.isCore(symbol) ? name : void 0;
|
|
427
|
-
}
|
|
428
|
-
isResult(type) {
|
|
429
|
-
const alias = type.getAliasSymbol();
|
|
430
|
-
if (alias !== void 0 && alias.getName() === "Result" && this.isCore(alias)) return true;
|
|
431
|
-
return (type.isUnion() ? type.getUnionTypes() : [type]).every((member) => this.isOkOrErr(member));
|
|
432
|
-
}
|
|
433
|
-
classTypesIn(type) {
|
|
434
|
-
if (type.isUnion()) return type.getUnionTypes().flatMap((member) => this.classTypesIn(member));
|
|
435
|
-
if (type.isIntersection()) return type.getIntersectionTypes().flatMap((member) => this.classTypesIn(member));
|
|
436
|
-
const element = type.getArrayElementType();
|
|
437
|
-
if (element !== void 0) return this.classTypesIn(element);
|
|
438
|
-
if (this.isContainer(type)) return [...type.getTypeArguments(), ...type.getAliasTypeArguments()].flatMap((argument) => this.classTypesIn(argument));
|
|
439
|
-
return type.isClass() ? [type] : [];
|
|
440
|
-
}
|
|
441
|
-
isContainer(type) {
|
|
442
|
-
const name = type.getSymbol()?.getName() ?? type.getAliasSymbol()?.getName();
|
|
443
|
-
return name !== void 0 && containers.has(name);
|
|
444
|
-
}
|
|
445
|
-
isOkOrErr(type) {
|
|
446
|
-
const symbol = type.getSymbol();
|
|
447
|
-
if (symbol === void 0) return false;
|
|
448
|
-
const name = symbol.getName();
|
|
449
|
-
return (name === "Ok" || name === "Err") && this.isCore(symbol);
|
|
450
|
-
}
|
|
451
|
-
isCore(symbol) {
|
|
452
|
-
const declaration = symbol.getDeclarations()[0];
|
|
453
|
-
if (declaration === void 0) return false;
|
|
454
|
-
return this.packageNames.ofFile(declaration.getSourceFile().getFilePath()) === CoreApi.packageName;
|
|
455
|
-
}
|
|
456
|
-
};
|
|
457
|
-
//#endregion
|
|
458
|
-
//#region src/analysis/ts-morph-analyzer.ts
|
|
459
|
-
var TsMorphAnalyzer = class TsMorphAnalyzer extends CodeAnalyzer {
|
|
460
|
-
project;
|
|
461
|
-
types;
|
|
462
|
-
classes;
|
|
463
|
-
constructor(project) {
|
|
464
|
-
super();
|
|
465
|
-
this.project = project;
|
|
466
|
-
this.types = new TypeInspector(new PackageNames(project.getFileSystem()));
|
|
467
|
-
this.classes = new ClassReader(this.types);
|
|
468
|
-
}
|
|
469
|
-
static fromTsConfig(projectDir) {
|
|
470
|
-
return new TsMorphAnalyzer(new Project({ tsConfigFilePath: join(projectDir, "tsconfig.json") }));
|
|
471
|
-
}
|
|
472
|
-
analyze(config) {
|
|
473
|
-
const layout = new Layout(config);
|
|
474
|
-
return new Codebase(this.project.getSourceFiles().filter((file) => this.isInside(config.rootDir, file.getFilePath()) && !config.isIgnored(file.getFilePath())).map((file) => this.read(file, config, layout)), config);
|
|
475
|
-
}
|
|
476
|
-
read(file, config, layout) {
|
|
477
|
-
const path = file.getFilePath();
|
|
478
|
-
const imports = this.moduleDeclarationsOf(file).map((declaration) => this.readImport(declaration, config, layout));
|
|
479
|
-
return new CodeFile({
|
|
480
|
-
classes: file.getClasses().map((declaration) => this.classes.read(declaration)),
|
|
481
|
-
declarations: this.declarationsOf(file),
|
|
482
|
-
domainErrorThrows: this.domainErrorThrowsOf(file),
|
|
483
|
-
imports,
|
|
484
|
-
location: layout.locate(path),
|
|
485
|
-
path
|
|
486
|
-
});
|
|
487
|
-
}
|
|
488
|
-
declarationsOf(file) {
|
|
489
|
-
const functions = file.getFunctions().map((declaration) => new Declaration("function", declaration.getName() ?? "default", declaration.getStartLineNumber()));
|
|
490
|
-
const functionConstants = file.getVariableDeclarations().filter((declaration) => declaration.getInitializerIfKind(SyntaxKind.ArrowFunction) !== void 0 || declaration.getInitializerIfKind(SyntaxKind.FunctionExpression) !== void 0).map((declaration) => new Declaration("function", declaration.getName(), declaration.getStartLineNumber()));
|
|
491
|
-
const enums = file.getEnums().map((declaration) => new Declaration("enum", declaration.getName(), declaration.getStartLineNumber()));
|
|
492
|
-
return [
|
|
493
|
-
...functions,
|
|
494
|
-
...functionConstants,
|
|
495
|
-
...enums
|
|
496
|
-
];
|
|
497
|
-
}
|
|
498
|
-
moduleDeclarationsOf(file) {
|
|
499
|
-
const reexports = file.getExportDeclarations().filter((declaration) => declaration.hasModuleSpecifier());
|
|
500
|
-
return [...file.getImportDeclarations(), ...reexports];
|
|
501
|
-
}
|
|
502
|
-
readImport(declaration, config, layout) {
|
|
503
|
-
const specifier = declaration.getModuleSpecifierValue() ?? "";
|
|
504
|
-
return new Import(declaration.getStartLineNumber(), specifier, this.importedNames(declaration), this.targetOf(declaration, specifier, config, layout));
|
|
505
|
-
}
|
|
506
|
-
importedNames(declaration) {
|
|
507
|
-
if (declaration.isKind(SyntaxKind.ExportDeclaration)) return declaration.isNamespaceExport() ? ["*"] : declaration.getNamedExports().map((named) => named.getName());
|
|
508
|
-
const names = declaration.getNamedImports().map((named) => named.getName());
|
|
509
|
-
if (declaration.getDefaultImport() !== void 0) names.unshift("default");
|
|
510
|
-
if (declaration.getNamespaceImport() !== void 0) names.unshift("*");
|
|
511
|
-
return names;
|
|
512
|
-
}
|
|
513
|
-
targetOf(declaration, specifier, config, layout) {
|
|
514
|
-
const resolved = declaration.getModuleSpecifierSourceFile()?.getFilePath();
|
|
515
|
-
if (resolved !== void 0 && this.isInside(config.projectDir, resolved)) return {
|
|
516
|
-
kind: "file",
|
|
517
|
-
location: layout.locate(resolved),
|
|
518
|
-
path: resolved
|
|
519
|
-
};
|
|
520
|
-
if (specifier.startsWith(".")) {
|
|
521
|
-
const path = join(declaration.getSourceFile().getDirectoryPath(), specifier);
|
|
522
|
-
return {
|
|
523
|
-
kind: "file",
|
|
524
|
-
location: layout.locate(path),
|
|
525
|
-
path
|
|
526
|
-
};
|
|
527
|
-
}
|
|
528
|
-
return {
|
|
529
|
-
kind: "package",
|
|
530
|
-
name: this.packageNameOf(specifier)
|
|
531
|
-
};
|
|
532
|
-
}
|
|
533
|
-
packageNameOf(specifier) {
|
|
534
|
-
const segments = specifier.split("/");
|
|
535
|
-
const length = specifier.startsWith("@") ? 2 : 1;
|
|
536
|
-
return segments.slice(0, length).join("/");
|
|
537
|
-
}
|
|
538
|
-
domainErrorThrowsOf(file) {
|
|
539
|
-
return file.getDescendantsOfKind(SyntaxKind.ThrowStatement).filter((statement) => this.types.kindsOf(statement.getExpression().getType()).includes("DomainError")).map((statement) => statement.getStartLineNumber());
|
|
540
|
-
}
|
|
541
|
-
isInside(directory, path) {
|
|
542
|
-
const offset = relative(directory, path);
|
|
543
|
-
return offset !== "" && !offset.startsWith("..") && !isAbsolute(offset) && !offset.split(/[\\/]/).includes("node_modules");
|
|
544
|
-
}
|
|
545
|
-
};
|
|
546
|
-
//#endregion
|
|
547
|
-
//#region src/baseline/baseline.ts
|
|
548
|
-
var Baseline = class Baseline {
|
|
549
|
-
entries;
|
|
550
|
-
static fileName = "alveolus.baseline.json";
|
|
551
|
-
constructor(entries) {
|
|
552
|
-
this.entries = entries;
|
|
553
|
-
}
|
|
554
|
-
static of(violations) {
|
|
555
|
-
return new Baseline(violations.map(({ file, rule, symbol }) => ({
|
|
556
|
-
file,
|
|
557
|
-
rule,
|
|
558
|
-
symbol
|
|
559
|
-
})));
|
|
560
|
-
}
|
|
561
|
-
static async load(path) {
|
|
562
|
-
if (!existsSync(path)) return new Baseline([]);
|
|
563
|
-
const content = JSON.parse(await readFile(path, "utf8"));
|
|
564
|
-
return new Baseline(content.violations ?? []);
|
|
565
|
-
}
|
|
566
|
-
newViolations(violations) {
|
|
567
|
-
const remaining = /* @__PURE__ */ new Map();
|
|
568
|
-
for (const entry of this.entries) remaining.set(this.keyOf(entry), (remaining.get(this.keyOf(entry)) ?? 0) + 1);
|
|
569
|
-
const fresh = [];
|
|
570
|
-
for (const violation of violations) {
|
|
571
|
-
const key = this.keyOf(violation);
|
|
572
|
-
const count = remaining.get(key) ?? 0;
|
|
573
|
-
if (count > 0) remaining.set(key, count - 1);
|
|
574
|
-
else fresh.push(violation);
|
|
575
|
-
}
|
|
576
|
-
return fresh;
|
|
577
|
-
}
|
|
578
|
-
async save(path) {
|
|
579
|
-
const violations = [...this.entries].sort((left, right) => this.keyOf(left).localeCompare(this.keyOf(right)));
|
|
580
|
-
await writeFile(path, `${JSON.stringify({ violations }, null, " ")}\n`);
|
|
581
|
-
}
|
|
582
|
-
keyOf(entry) {
|
|
583
|
-
return [
|
|
584
|
-
entry.rule,
|
|
585
|
-
entry.file,
|
|
586
|
-
entry.symbol
|
|
587
|
-
].join("\n");
|
|
588
|
-
}
|
|
589
|
-
};
|
|
590
|
-
//#endregion
|
|
591
|
-
//#region src/checker/arch-checker.ts
|
|
592
|
-
var ArchChecker = class {
|
|
593
|
-
analyzer;
|
|
594
|
-
rules;
|
|
595
|
-
constructor(analyzer, rules) {
|
|
596
|
-
this.analyzer = analyzer;
|
|
597
|
-
this.rules = rules;
|
|
598
|
-
}
|
|
599
|
-
check(config) {
|
|
600
|
-
const codebase = this.analyzer.analyze(config);
|
|
601
|
-
return this.rules.filter((rule) => config.isEnabled(rule.id)).flatMap((rule) => rule.check(codebase)).sort((left, right) => this.compare(left, right));
|
|
602
|
-
}
|
|
603
|
-
compare(left, right) {
|
|
604
|
-
return left.file.localeCompare(right.file) || left.line - right.line || left.rule.localeCompare(right.rule);
|
|
605
|
-
}
|
|
606
|
-
};
|
|
607
|
-
//#endregion
|
|
608
|
-
//#region src/config/config.ts
|
|
609
|
-
const testFiles = [
|
|
610
|
-
"**/*.spec.ts",
|
|
611
|
-
"**/*.test.ts",
|
|
612
|
-
"**/__tests__/**"
|
|
613
|
-
];
|
|
614
|
-
var Config = class {
|
|
615
|
-
projectDir;
|
|
616
|
-
rootDir;
|
|
617
|
-
compositionRoot;
|
|
618
|
-
domainDependencies;
|
|
619
|
-
contextFolders;
|
|
620
|
-
ignored;
|
|
621
|
-
rules;
|
|
622
|
-
constructor(config, projectDir) {
|
|
623
|
-
this.projectDir = resolve(projectDir);
|
|
624
|
-
this.rootDir = resolve(projectDir, config.root);
|
|
625
|
-
this.compositionRoot = config.compositionRoot ?? "*.module.ts";
|
|
626
|
-
this.domainDependencies = config.domainDependencies ?? [];
|
|
627
|
-
this.ignored = [...testFiles, ...config.ignore ?? []];
|
|
628
|
-
this.rules = config.rules;
|
|
629
|
-
const sharedKernel = config.sharedKernel ?? "shared-kernel";
|
|
630
|
-
this.contextFolders = [...Object.entries(config.boundedContexts).map(([name, folder]) => ({
|
|
631
|
-
dir: resolve(this.rootDir, folder),
|
|
632
|
-
isSharedKernel: false,
|
|
633
|
-
name
|
|
634
|
-
})), {
|
|
635
|
-
dir: resolve(this.rootDir, sharedKernel),
|
|
636
|
-
isSharedKernel: true,
|
|
637
|
-
name: "shared kernel"
|
|
638
|
-
}];
|
|
639
|
-
}
|
|
640
|
-
isIgnored(path) {
|
|
641
|
-
const projectPath = relative(this.projectDir, path).split(sep).join("/");
|
|
642
|
-
return this.ignored.some((glob) => matchesGlob(projectPath, glob));
|
|
643
|
-
}
|
|
644
|
-
isEnabled(rule) {
|
|
645
|
-
return this.rules?.[rule] !== "off";
|
|
646
|
-
}
|
|
647
|
-
};
|
|
648
|
-
//#endregion
|
|
649
|
-
//#region src/config/config-loader.ts
|
|
650
|
-
const ruleSetting = z.enum(["error", "off"]);
|
|
651
|
-
const schema = z.strictObject({
|
|
652
|
-
boundedContexts: z.record(z.string(), z.string()),
|
|
653
|
-
compositionRoot: z.string().exactOptional(),
|
|
654
|
-
domainDependencies: z.array(z.string()).exactOptional(),
|
|
655
|
-
ignore: z.array(z.string()).exactOptional(),
|
|
656
|
-
root: z.string(),
|
|
657
|
-
rules: z.strictObject({
|
|
658
|
-
"bc-isolation": ruleSetting.exactOptional(),
|
|
659
|
-
"building-blocks-only": ruleSetting.exactOptional(),
|
|
660
|
-
"command-query-separation": ruleSetting.exactOptional(),
|
|
661
|
-
"domain-purity": ruleSetting.exactOptional(),
|
|
662
|
-
"driven-adapters-extend-port": ruleSetting.exactOptional(),
|
|
663
|
-
"errors-as-values": ruleSetting.exactOptional(),
|
|
664
|
-
"layer-direction": ruleSetting.exactOptional(),
|
|
665
|
-
placement: ruleSetting.exactOptional(),
|
|
666
|
-
"reference-by-identity": ruleSetting.exactOptional()
|
|
667
|
-
}).exactOptional(),
|
|
668
|
-
sharedKernel: z.string().exactOptional()
|
|
669
|
-
});
|
|
670
|
-
var ConfigLoader = class ConfigLoader {
|
|
671
|
-
static fileName = "alveolus.config.ts";
|
|
672
|
-
async load(projectDir, configFile = ConfigLoader.fileName) {
|
|
673
|
-
const path = resolve(projectDir, configFile);
|
|
674
|
-
if (!existsSync(path)) throw new Error(`No configuration found at ${path}.`);
|
|
675
|
-
const exported = await createJiti(import.meta.url).import(path, { default: true });
|
|
676
|
-
return new Config(this.validate(exported, path), projectDir);
|
|
677
|
-
}
|
|
678
|
-
validate(exported, path) {
|
|
679
|
-
const parsed = schema.safeParse(exported);
|
|
680
|
-
if (!parsed.success) throw new Error(`Invalid configuration in ${path}:\n${z.prettifyError(parsed.error)}`);
|
|
681
|
-
return parsed.data;
|
|
682
|
-
}
|
|
683
|
-
};
|
|
684
|
-
//#endregion
|
|
685
|
-
//#region src/report/report.ts
|
|
686
|
-
var Report = class {
|
|
687
|
-
violations;
|
|
688
|
-
baselined;
|
|
689
|
-
colors;
|
|
690
|
-
constructor(violations, baselined, colored = false) {
|
|
691
|
-
this.violations = violations;
|
|
692
|
-
this.baselined = baselined;
|
|
693
|
-
this.colors = picocolors.createColors(colored);
|
|
694
|
-
}
|
|
695
|
-
text() {
|
|
696
|
-
return `${[...this.violations.map((violation) => this.block(violation)), this.summary()].join("\n\n")}\n`;
|
|
697
|
-
}
|
|
698
|
-
json() {
|
|
699
|
-
return `${JSON.stringify({
|
|
700
|
-
baselined: this.baselined,
|
|
701
|
-
violations: this.violations
|
|
702
|
-
}, null, " ")}\n`;
|
|
703
|
-
}
|
|
704
|
-
block(violation) {
|
|
705
|
-
const { bold, red } = this.colors;
|
|
706
|
-
return `${bold(`${violation.file}:${violation.line}`)}\n ${red(violation.rule)}: ${violation.message}`;
|
|
707
|
-
}
|
|
708
|
-
summary() {
|
|
709
|
-
const { dim, green, red } = this.colors;
|
|
710
|
-
const count = this.violations.length;
|
|
711
|
-
const baselined = this.baselined > 0 ? dim(` (${this.baselined} in the baseline)`) : "";
|
|
712
|
-
if (count === 0) return `${green("No violation")}${baselined}`;
|
|
713
|
-
return `${red(`${count} violation${count === 1 ? "" : "s"}`)}${baselined}`;
|
|
714
|
-
}
|
|
715
|
-
};
|
|
716
|
-
//#endregion
|
|
717
|
-
//#region src/rules/rule.ts
|
|
718
|
-
var Rule = class {
|
|
719
|
-
violation(codebase, file, problem) {
|
|
720
|
-
return {
|
|
721
|
-
file: codebase.relativePath(file.path),
|
|
722
|
-
line: problem.line,
|
|
723
|
-
message: problem.message,
|
|
724
|
-
rule: this.id,
|
|
725
|
-
symbol: problem.symbol
|
|
726
|
-
};
|
|
727
|
-
}
|
|
728
|
-
};
|
|
729
|
-
//#endregion
|
|
730
|
-
//#region src/rules/import-rule.ts
|
|
731
|
-
var ImportRule = class extends Rule {
|
|
732
|
-
check(codebase) {
|
|
733
|
-
const violations = [];
|
|
734
|
-
for (const file of codebase.files) {
|
|
735
|
-
if (!this.appliesTo(file)) continue;
|
|
736
|
-
for (const imported of file.imports) {
|
|
737
|
-
const message = this.problemWith(imported, file, codebase);
|
|
738
|
-
if (message !== void 0) violations.push(this.violation(codebase, file, {
|
|
739
|
-
line: imported.line,
|
|
740
|
-
message,
|
|
741
|
-
symbol: imported.label
|
|
742
|
-
}));
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
return violations;
|
|
746
|
-
}
|
|
747
|
-
describeTarget(imported, codebase) {
|
|
748
|
-
if (imported.target.kind === "package") return imported.target.name;
|
|
749
|
-
return `${codebase.relativePath(imported.target.path)} (${imported.target.location.describe()})`;
|
|
750
|
-
}
|
|
751
|
-
};
|
|
752
|
-
//#endregion
|
|
753
|
-
//#region src/rules/bc-isolation.rule.ts
|
|
754
|
-
var BcIsolationRule = class extends ImportRule {
|
|
755
|
-
id = "bc-isolation";
|
|
756
|
-
appliesTo(file) {
|
|
757
|
-
return file.location.isInBoundedContext || file.location.isInSharedKernel;
|
|
758
|
-
}
|
|
759
|
-
problemWith(imported, file, codebase) {
|
|
760
|
-
if (imported.target.kind !== "file") return;
|
|
761
|
-
const from = file.location;
|
|
762
|
-
const to = imported.target.location;
|
|
763
|
-
if (!to.isOtherBoundedContextThan(from)) return;
|
|
764
|
-
if (from.isInSharedKernel) return `The shared kernel imports no bounded context, but imports ${this.describeTarget(imported, codebase)}.`;
|
|
765
|
-
if (from.isCompositionRoot && to.isCompositionRoot) return;
|
|
766
|
-
if (to.layer === "published-language") return `Imports the published language of ${to.context}: redeclare the fields you read in your own published-language/.`;
|
|
767
|
-
if (!this.importsOnlyOpenHostServices(imported, codebase)) return `Imports ${this.describeTarget(imported, codebase)}: only an OpenHostService of another bounded context may be imported.`;
|
|
768
|
-
if (!from.isCompositionRoot && !file.declaresAntiCorruptionLayer) return `Uses the open host service of ${to.context} outside an AntiCorruptionLayer: translate it in an anti-corruption layer.`;
|
|
769
|
-
}
|
|
770
|
-
importsOnlyOpenHostServices(imported, codebase) {
|
|
771
|
-
if (imported.target.kind !== "file" || imported.names.length === 0) return false;
|
|
772
|
-
const target = codebase.file(imported.target.path);
|
|
773
|
-
return target !== void 0 && imported.names.every((name) => target.declaresOpenHostService(name));
|
|
774
|
-
}
|
|
775
|
-
};
|
|
776
|
-
//#endregion
|
|
777
|
-
//#region src/rules/building-blocks-only.rule.ts
|
|
778
|
-
const guarded = /* @__PURE__ */ new Set(["domain", "application"]);
|
|
779
|
-
const blocksOf = {
|
|
780
|
-
application: "CommandHandler, QueryHandler or EventTranslator",
|
|
781
|
-
domain: "AggregateRoot, Entity, ValueObject, Identifier, DomainEvent, DomainError, DomainService or a Port"
|
|
782
|
-
};
|
|
783
|
-
var BuildingBlocksOnlyRule = class extends Rule {
|
|
784
|
-
id = "building-blocks-only";
|
|
785
|
-
check(codebase) {
|
|
786
|
-
const violations = [];
|
|
787
|
-
for (const file of codebase.files) {
|
|
788
|
-
if (!guarded.has(file.location.layer)) continue;
|
|
789
|
-
for (const problem of [...this.plainClasses(file), ...this.freeDeclarations(file)]) violations.push(this.violation(codebase, file, problem));
|
|
790
|
-
}
|
|
791
|
-
return violations;
|
|
792
|
-
}
|
|
793
|
-
plainClasses(file) {
|
|
794
|
-
const blocks = blocksOf[file.location.layer ?? ""];
|
|
795
|
-
return file.classes.filter((codeClass) => !codeClass.extendsBuildingBlock).map((codeClass) => ({
|
|
796
|
-
line: codeClass.line,
|
|
797
|
-
message: `${codeClass.name} extends no building block: extend ${blocks}.`,
|
|
798
|
-
symbol: codeClass.name
|
|
799
|
-
}));
|
|
800
|
-
}
|
|
801
|
-
freeDeclarations(file) {
|
|
802
|
-
return file.declarations.map((declaration) => {
|
|
803
|
-
if (declaration.kind === "enum") return {
|
|
804
|
-
line: declaration.line,
|
|
805
|
-
message: `The enum ${declaration.name} has no place here: use a union of literal types, or a ValueObject when it has behaviour.`,
|
|
806
|
-
symbol: declaration.name
|
|
807
|
-
};
|
|
808
|
-
return {
|
|
809
|
-
line: declaration.line,
|
|
810
|
-
message: `The function ${declaration.name} floats outside any class: make it a method of a value object or of a DomainService.`,
|
|
811
|
-
symbol: declaration.name
|
|
812
|
-
};
|
|
813
|
-
});
|
|
814
|
-
}
|
|
815
|
-
};
|
|
816
|
-
//#endregion
|
|
817
|
-
//#region src/rules/class-rule.ts
|
|
818
|
-
var ClassRule = class extends Rule {
|
|
819
|
-
check(codebase) {
|
|
820
|
-
const violations = [];
|
|
821
|
-
for (const file of codebase.files) for (const codeClass of file.classes) for (const problem of this.problemsWith(codeClass, file)) violations.push(this.violation(codebase, file, problem));
|
|
822
|
-
return violations;
|
|
823
|
-
}
|
|
824
|
-
};
|
|
825
|
-
//#endregion
|
|
826
|
-
//#region src/rules/command-query-separation.rule.ts
|
|
827
|
-
const separations = [{
|
|
828
|
-
forbidden: ["QueryRepository"],
|
|
829
|
-
handler: "CommandHandler"
|
|
830
|
-
}, {
|
|
831
|
-
forbidden: [
|
|
832
|
-
"CommandRepository",
|
|
833
|
-
"Outbox",
|
|
834
|
-
"UnitOfWork",
|
|
835
|
-
"EventPublisher"
|
|
836
|
-
],
|
|
837
|
-
handler: "QueryHandler"
|
|
838
|
-
}];
|
|
839
|
-
var CommandQuerySeparationRule = class extends ClassRule {
|
|
840
|
-
id = "command-query-separation";
|
|
841
|
-
problemsWith(codeClass) {
|
|
842
|
-
const problems = [];
|
|
843
|
-
for (const { handler, forbidden } of separations) {
|
|
844
|
-
if (!codeClass.is(handler)) continue;
|
|
845
|
-
for (const parameter of codeClass.constructorParameters) {
|
|
846
|
-
const kind = forbidden.find((candidate) => parameter.is(candidate));
|
|
847
|
-
if (kind !== void 0) problems.push({
|
|
848
|
-
line: parameter.line,
|
|
849
|
-
message: `The ${handler} ${codeClass.name} receives ${parameter.typeName}, a ${kind}: keep commands and queries apart.`,
|
|
850
|
-
symbol: `${codeClass.name}.${parameter.member}`
|
|
851
|
-
});
|
|
852
|
-
}
|
|
853
|
-
}
|
|
854
|
-
return problems;
|
|
855
|
-
}
|
|
856
|
-
};
|
|
857
|
-
//#endregion
|
|
858
|
-
//#region src/rules/domain-purity.rule.ts
|
|
859
|
-
var DomainPurityRule = class extends ImportRule {
|
|
860
|
-
id = "domain-purity";
|
|
861
|
-
appliesTo(file) {
|
|
862
|
-
return file.location.layer === "domain";
|
|
863
|
-
}
|
|
864
|
-
problemWith(imported, file, codebase) {
|
|
865
|
-
const target = imported.target;
|
|
866
|
-
if (target.kind === "file") {
|
|
867
|
-
if (this.isDomainReachableFrom(target.location, file.location)) return;
|
|
868
|
-
return `The domain imports ${this.describeTarget(imported, codebase)}: it may only import the domain.`;
|
|
869
|
-
}
|
|
870
|
-
if (target.name === CoreApi.packageName) {
|
|
871
|
-
const forbidden = imported.names.filter((name) => !CoreApi.isDomainSymbol(name));
|
|
872
|
-
if (forbidden.length === 0) return;
|
|
873
|
-
return `The domain imports ${forbidden.join(", ")} from ${CoreApi.packageName}: only domain building blocks and Result are allowed.`;
|
|
874
|
-
}
|
|
875
|
-
if (codebase.config.domainDependencies.includes(target.name)) return;
|
|
876
|
-
return `The domain imports ${target.name}: add it to domainDependencies if the domain really needs it.`;
|
|
877
|
-
}
|
|
878
|
-
isDomainReachableFrom(to, from) {
|
|
879
|
-
return to.layer === "domain" && (to.isSameContextAs(from) || to.isInSharedKernel);
|
|
880
|
-
}
|
|
881
|
-
};
|
|
882
|
-
//#endregion
|
|
883
|
-
//#region src/rules/driven-adapters-extend-port.rule.ts
|
|
884
|
-
var DrivenAdaptersExtendPortRule = class extends ClassRule {
|
|
885
|
-
id = "driven-adapters-extend-port";
|
|
886
|
-
problemsWith(codeClass, file) {
|
|
887
|
-
const location = file.location;
|
|
888
|
-
if (this.isAdaptersFolder(location) && !codeClass.is("Port")) return [{
|
|
889
|
-
line: codeClass.line,
|
|
890
|
-
message: `${codeClass.name} is a driven adapter but extends no Port: extend the port it implements.`,
|
|
891
|
-
symbol: codeClass.name
|
|
892
|
-
}];
|
|
893
|
-
if (codeClass.is("Port") && codeClass.isAbstract && !this.isPortsFolder(location) && !this.isAdaptersFolder(location)) return [{
|
|
894
|
-
line: codeClass.line,
|
|
895
|
-
message: `The port ${codeClass.name} is declared outside the domain: move it to domain/ports/ or domain/repositories/.`,
|
|
896
|
-
symbol: codeClass.name
|
|
897
|
-
}];
|
|
898
|
-
return [];
|
|
899
|
-
}
|
|
900
|
-
isAdaptersFolder(location) {
|
|
901
|
-
return location.layer === "driven" && location.folder === "adapters";
|
|
902
|
-
}
|
|
903
|
-
isPortsFolder(location) {
|
|
904
|
-
return location.layer === "domain" && (location.folder === "ports" || location.folder === "repositories");
|
|
905
|
-
}
|
|
906
|
-
};
|
|
907
|
-
//#endregion
|
|
908
|
-
//#region src/rules/errors-as-values.rule.ts
|
|
909
|
-
const exempt = /* @__PURE__ */ new Set(["equals", "toSnapshot"]);
|
|
910
|
-
var ErrorsAsValuesRule = class extends Rule {
|
|
911
|
-
id = "errors-as-values";
|
|
912
|
-
check(codebase) {
|
|
913
|
-
const violations = [];
|
|
914
|
-
for (const file of codebase.files) for (const problem of [...this.methodsWithoutResult(file), ...this.thrownErrors(file)]) violations.push(this.violation(codebase, file, problem));
|
|
915
|
-
return violations;
|
|
916
|
-
}
|
|
917
|
-
methodsWithoutResult(file) {
|
|
918
|
-
const problems = [];
|
|
919
|
-
for (const codeClass of file.classes.filter((candidate) => candidate.is("Entity"))) for (const method of codeClass.publicMethods) if (!exempt.has(method.name) && !method.returnsResult) problems.push({
|
|
920
|
-
line: method.line,
|
|
921
|
-
message: `${codeClass.name}.${method.name} must return a Result: expose reads as getters and return business failures as values.`,
|
|
922
|
-
symbol: `${codeClass.name}.${method.name}`
|
|
923
|
-
});
|
|
924
|
-
return problems;
|
|
925
|
-
}
|
|
926
|
-
thrownErrors(file) {
|
|
927
|
-
return file.domainErrorThrows.map((line) => ({
|
|
928
|
-
line,
|
|
929
|
-
message: "A DomainError is thrown: return it in a Result instead.",
|
|
930
|
-
symbol: "throw"
|
|
931
|
-
}));
|
|
932
|
-
}
|
|
933
|
-
};
|
|
934
|
-
//#endregion
|
|
935
|
-
//#region src/rules/layer-direction.rule.ts
|
|
936
|
-
const allowedLayers = {
|
|
937
|
-
application: [
|
|
938
|
-
"domain",
|
|
939
|
-
"application",
|
|
940
|
-
"published-language"
|
|
941
|
-
],
|
|
942
|
-
driven: [
|
|
943
|
-
"domain",
|
|
944
|
-
"application",
|
|
945
|
-
"published-language",
|
|
946
|
-
"driven"
|
|
947
|
-
],
|
|
948
|
-
driving: [
|
|
949
|
-
"domain",
|
|
950
|
-
"application",
|
|
951
|
-
"published-language",
|
|
952
|
-
"driving"
|
|
953
|
-
],
|
|
954
|
-
"published-language": ["published-language"]
|
|
955
|
-
};
|
|
956
|
-
var LayerDirectionRule = class extends ImportRule {
|
|
957
|
-
id = "layer-direction";
|
|
958
|
-
check(codebase) {
|
|
959
|
-
return [...this.filesOutsideLayers(codebase), ...super.check(codebase)];
|
|
960
|
-
}
|
|
961
|
-
appliesTo(file) {
|
|
962
|
-
const location = file.location;
|
|
963
|
-
return location.isAtRoot || location.isCompositionRoot || location.isInLayer && location.layer !== "domain";
|
|
964
|
-
}
|
|
965
|
-
problemWith(imported, file, codebase) {
|
|
966
|
-
if (imported.target.kind === "file") return this.fileProblem(file.location, imported.target.location, this.describeTarget(imported, codebase));
|
|
967
|
-
return this.packageProblem(file.location, imported, codebase.config);
|
|
968
|
-
}
|
|
969
|
-
filesOutsideLayers(codebase) {
|
|
970
|
-
const violations = [];
|
|
971
|
-
for (const file of codebase.files) {
|
|
972
|
-
const location = file.location;
|
|
973
|
-
if (location.isOutside) violations.push(this.violation(codebase, file, {
|
|
974
|
-
line: 1,
|
|
975
|
-
message: "The file is outside the bounded contexts and the shared kernel declared in alveolus.config.ts: move it, or add it to ignore.",
|
|
976
|
-
symbol: location.fileName
|
|
977
|
-
}));
|
|
978
|
-
else if (!location.isAtRoot && !location.isInLayer && !location.isCompositionRoot) violations.push(this.violation(codebase, file, {
|
|
979
|
-
line: 1,
|
|
980
|
-
message: "The file is outside the layers: move it to domain/, application/, published-language/, driven/ or driving/.",
|
|
981
|
-
symbol: location.fileName
|
|
982
|
-
}));
|
|
983
|
-
}
|
|
984
|
-
return violations;
|
|
985
|
-
}
|
|
986
|
-
fileProblem(from, to, target) {
|
|
987
|
-
if (from.isAtRoot) return to.isInBoundedContext && !to.isCompositionRoot ? `Files at the root import composition roots only, not ${target}.` : void 0;
|
|
988
|
-
if (from.isCompositionRoot || to.isOtherBoundedContextThan(from)) return;
|
|
989
|
-
if (to.isCompositionRoot) return `Imports ${target}: only the composition root wires the layers.`;
|
|
990
|
-
const layer = from.layer;
|
|
991
|
-
if (layer === void 0 || layer === "domain") return;
|
|
992
|
-
const allowed = allowedLayers[layer];
|
|
993
|
-
if (to.layer !== void 0 && allowed.includes(to.layer)) return;
|
|
994
|
-
return `The ${layer} layer imports ${target}: it may only import ${allowed.join(", ")}.`;
|
|
995
|
-
}
|
|
996
|
-
packageProblem(from, imported, config) {
|
|
997
|
-
if (from.layer === "application") return this.applicationPackageProblem(imported, config);
|
|
998
|
-
if (from.layer === "published-language" && imported.isFromPackage(CoreApi.packageName)) {
|
|
999
|
-
const forbidden = imported.names.filter((name) => !CoreApi.isPublishedLanguageSymbol(name));
|
|
1000
|
-
return forbidden.length === 0 ? void 0 : `The published language imports ${forbidden.join(", ")} from ${CoreApi.packageName}: only published-language types are allowed.`;
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
applicationPackageProblem(imported, config) {
|
|
1004
|
-
if (imported.target.kind !== "package" || imported.isFromPackage(CoreApi.packageName)) return;
|
|
1005
|
-
if (imported.isFromPackage("@nestjs/common")) {
|
|
1006
|
-
const forbidden = imported.names.filter((name) => name !== "Injectable");
|
|
1007
|
-
return forbidden.length === 0 ? void 0 : `The application imports ${forbidden.join(", ")} from @nestjs/common: only Injectable is allowed.`;
|
|
1008
|
-
}
|
|
1009
|
-
if (config.domainDependencies.includes(imported.target.name)) return;
|
|
1010
|
-
return `The application imports ${imported.target.name}: only ${CoreApi.packageName}, Injectable from @nestjs/common and domainDependencies are allowed.`;
|
|
1011
|
-
}
|
|
1012
|
-
};
|
|
1013
|
-
//#endregion
|
|
1014
|
-
//#region src/rules/place.ts
|
|
1015
|
-
var Place = class {
|
|
1016
|
-
layer;
|
|
1017
|
-
folder;
|
|
1018
|
-
suffix;
|
|
1019
|
-
constructor(layer, folder, suffix) {
|
|
1020
|
-
this.layer = layer;
|
|
1021
|
-
this.folder = folder;
|
|
1022
|
-
this.suffix = suffix;
|
|
1023
|
-
}
|
|
1024
|
-
fits(location) {
|
|
1025
|
-
if (!location.isInBoundedContext && !location.isInSharedKernel) return false;
|
|
1026
|
-
if (location.layer !== this.layer) return false;
|
|
1027
|
-
if (this.folder !== void 0 && location.folder !== this.folder) return false;
|
|
1028
|
-
return this.suffix === void 0 || location.fileName.endsWith(this.suffix);
|
|
1029
|
-
}
|
|
1030
|
-
describe() {
|
|
1031
|
-
return this.folder === void 0 ? `${this.layer}/` : `${this.layer}/${this.folder}/*${this.suffix ?? ".ts"}`;
|
|
1032
|
-
}
|
|
1033
|
-
};
|
|
1034
|
-
//#endregion
|
|
1035
|
-
//#region src/rules/placement.rule.ts
|
|
1036
|
-
const repositories = new Place("domain", "repositories", ".repository.ts");
|
|
1037
|
-
const kindPlaces = [
|
|
1038
|
-
{
|
|
1039
|
-
kind: "AggregateRoot",
|
|
1040
|
-
place: new Place("domain", "aggregates", ".aggregate.ts")
|
|
1041
|
-
},
|
|
1042
|
-
{
|
|
1043
|
-
kind: "Entity",
|
|
1044
|
-
place: new Place("domain", "entities", ".entity.ts")
|
|
1045
|
-
},
|
|
1046
|
-
{
|
|
1047
|
-
kind: "Identifier",
|
|
1048
|
-
place: new Place("domain", "value-objects", ".identifier.ts")
|
|
1049
|
-
},
|
|
1050
|
-
{
|
|
1051
|
-
kind: "ValueObject",
|
|
1052
|
-
place: new Place("domain", "value-objects", ".value-object.ts")
|
|
1053
|
-
},
|
|
1054
|
-
{
|
|
1055
|
-
kind: "DomainEvent",
|
|
1056
|
-
place: new Place("domain", "events", ".event.ts")
|
|
1057
|
-
},
|
|
1058
|
-
{
|
|
1059
|
-
kind: "DomainError",
|
|
1060
|
-
place: new Place("domain", "errors", ".error.ts")
|
|
1061
|
-
},
|
|
1062
|
-
{
|
|
1063
|
-
kind: "DomainService",
|
|
1064
|
-
place: new Place("domain", "services", ".service.ts")
|
|
1065
|
-
},
|
|
1066
|
-
{
|
|
1067
|
-
kind: "CommandHandler",
|
|
1068
|
-
place: new Place("application", "commands", ".command.ts")
|
|
1069
|
-
},
|
|
1070
|
-
{
|
|
1071
|
-
kind: "QueryHandler",
|
|
1072
|
-
place: new Place("application", "queries", ".query.ts")
|
|
1073
|
-
},
|
|
1074
|
-
{
|
|
1075
|
-
kind: "EventTranslator",
|
|
1076
|
-
place: new Place("application", "translators", ".translator.ts")
|
|
1077
|
-
},
|
|
1078
|
-
{
|
|
1079
|
-
concreteOnly: true,
|
|
1080
|
-
kind: "Port",
|
|
1081
|
-
place: new Place("driven", "adapters", ".adapter.ts")
|
|
1082
|
-
},
|
|
1083
|
-
{
|
|
1084
|
-
kind: "CommandRepository",
|
|
1085
|
-
place: repositories
|
|
1086
|
-
},
|
|
1087
|
-
{
|
|
1088
|
-
kind: "QueryRepository",
|
|
1089
|
-
place: repositories
|
|
1090
|
-
},
|
|
1091
|
-
{
|
|
1092
|
-
kind: "Port",
|
|
1093
|
-
place: new Place("domain", "ports", ".port.ts")
|
|
1094
|
-
}
|
|
1095
|
-
];
|
|
1096
|
-
const markedPlace = new Place("driving");
|
|
1097
|
-
var PlacementRule = class extends Rule {
|
|
1098
|
-
id = "placement";
|
|
1099
|
-
check(codebase) {
|
|
1100
|
-
const violations = [];
|
|
1101
|
-
for (const file of codebase.files) for (const problem of [...this.extraClasses(file), ...this.misplacedClasses(file)]) violations.push(this.violation(codebase, file, problem));
|
|
1102
|
-
return violations;
|
|
1103
|
-
}
|
|
1104
|
-
extraClasses(file) {
|
|
1105
|
-
const [first, ...others] = file.classes;
|
|
1106
|
-
return others.map((codeClass) => ({
|
|
1107
|
-
line: codeClass.line,
|
|
1108
|
-
message: `${codeClass.name} shares its file with ${first?.name}: one class per file.`,
|
|
1109
|
-
symbol: codeClass.name
|
|
1110
|
-
}));
|
|
1111
|
-
}
|
|
1112
|
-
misplacedClasses(file) {
|
|
1113
|
-
const problems = [];
|
|
1114
|
-
for (const codeClass of file.classes) {
|
|
1115
|
-
const place = this.placeOf(codeClass);
|
|
1116
|
-
if (place !== void 0 && !place.fits(file.location)) problems.push({
|
|
1117
|
-
line: codeClass.line,
|
|
1118
|
-
message: `${codeClass.name} belongs in ${place.describe()}.`,
|
|
1119
|
-
symbol: codeClass.name
|
|
1120
|
-
});
|
|
1121
|
-
}
|
|
1122
|
-
return problems;
|
|
1123
|
-
}
|
|
1124
|
-
placeOf(codeClass) {
|
|
1125
|
-
const match = kindPlaces.find((entry) => codeClass.is(entry.kind) && !(entry.concreteOnly === true && codeClass.isAbstract));
|
|
1126
|
-
if (match !== void 0) return match.place;
|
|
1127
|
-
return codeClass.hasMarker ? markedPlace : void 0;
|
|
1128
|
-
}
|
|
1129
|
-
};
|
|
1130
|
-
//#endregion
|
|
1131
|
-
//#region src/rules/reference-by-identity.rule.ts
|
|
1132
|
-
var ReferenceByIdentityRule = class extends ClassRule {
|
|
1133
|
-
id = "reference-by-identity";
|
|
1134
|
-
problemsWith(codeClass) {
|
|
1135
|
-
if (!codeClass.is("Entity")) return [];
|
|
1136
|
-
return this.membersOf(codeClass).filter((member) => member.is("AggregateRoot")).map((member) => ({
|
|
1137
|
-
line: member.line,
|
|
1138
|
-
message: `${codeClass.name}.${member.member} holds the aggregate ${member.typeName}: reference it by its identifier instead.`,
|
|
1139
|
-
symbol: `${codeClass.name}.${member.member}`
|
|
1140
|
-
}));
|
|
1141
|
-
}
|
|
1142
|
-
membersOf(codeClass) {
|
|
1143
|
-
const byName = /* @__PURE__ */ new Map();
|
|
1144
|
-
for (const member of [...codeClass.fields, ...codeClass.constructorParameters]) byName.set(`${member.member}:${member.typeName}`, member);
|
|
1145
|
-
return [...byName.values()];
|
|
1146
|
-
}
|
|
1147
|
-
};
|
|
1148
|
-
//#endregion
|
|
1149
|
-
//#region src/rules/rules.ts
|
|
1150
|
-
var Rules = class {
|
|
1151
|
-
static all() {
|
|
1152
|
-
return [
|
|
1153
|
-
new BcIsolationRule(),
|
|
1154
|
-
new DomainPurityRule(),
|
|
1155
|
-
new LayerDirectionRule(),
|
|
1156
|
-
new DrivenAdaptersExtendPortRule(),
|
|
1157
|
-
new ReferenceByIdentityRule(),
|
|
1158
|
-
new CommandQuerySeparationRule(),
|
|
1159
|
-
new ErrorsAsValuesRule(),
|
|
1160
|
-
new PlacementRule(),
|
|
1161
|
-
new BuildingBlocksOnlyRule()
|
|
1162
|
-
];
|
|
1163
|
-
}
|
|
1164
|
-
};
|
|
1165
|
-
//#endregion
|
|
1166
|
-
//#region src/cli/cli.ts
|
|
1167
|
-
var Cli = class {
|
|
1168
|
-
stdout;
|
|
1169
|
-
stderr;
|
|
1170
|
-
cwd;
|
|
1171
|
-
colored;
|
|
1172
|
-
exitCode = 0;
|
|
1173
|
-
constructor(stdout, stderr, cwd, colored = false) {
|
|
1174
|
-
this.stdout = stdout;
|
|
1175
|
-
this.stderr = stderr;
|
|
1176
|
-
this.cwd = cwd;
|
|
1177
|
-
this.colored = colored;
|
|
1178
|
-
}
|
|
1179
|
-
async run(args) {
|
|
1180
|
-
try {
|
|
1181
|
-
await this.program().parseAsync([...args], { from: "user" });
|
|
1182
|
-
} catch (error) {
|
|
1183
|
-
return this.fail(error);
|
|
1184
|
-
}
|
|
1185
|
-
return this.exitCode;
|
|
1186
|
-
}
|
|
1187
|
-
program() {
|
|
1188
|
-
const program = new Command("alveolus").exitOverride().configureOutput({
|
|
1189
|
-
writeErr: (text) => this.stderr.write(text),
|
|
1190
|
-
writeOut: (text) => this.stdout.write(text)
|
|
1191
|
-
});
|
|
1192
|
-
const arch = program.command("arch").description("Check the architecture of a Domain-Driven Design project");
|
|
1193
|
-
this.withOptions(arch.command("check").description("Report the violations that are not in the baseline")).action((options) => this.check(options));
|
|
1194
|
-
this.withOptions(arch.command("baseline").description(`Write the current violations to ${Baseline.fileName}`)).action((options) => this.baseline(options));
|
|
1195
|
-
return program;
|
|
1196
|
-
}
|
|
1197
|
-
withOptions(command) {
|
|
1198
|
-
return command.option("--project <dir>", "project directory", ".").option("--config <file>", "configuration file", ConfigLoader.fileName).option("--format <format>", "text or json", "text");
|
|
1199
|
-
}
|
|
1200
|
-
async check(options) {
|
|
1201
|
-
const { config, violations } = await this.analyze(options);
|
|
1202
|
-
const fresh = (await Baseline.load(join(config.projectDir, Baseline.fileName))).newViolations(violations);
|
|
1203
|
-
const report = new Report(fresh, violations.length - fresh.length, this.colored);
|
|
1204
|
-
this.stdout.write(options.format === "json" ? report.json() : report.text());
|
|
1205
|
-
this.exitCode = fresh.length === 0 ? 0 : 1;
|
|
1206
|
-
}
|
|
1207
|
-
async baseline(options) {
|
|
1208
|
-
const { config, violations } = await this.analyze(options);
|
|
1209
|
-
await Baseline.of(violations).save(join(config.projectDir, Baseline.fileName));
|
|
1210
|
-
this.stdout.write(`${violations.length} violations written to ${Baseline.fileName}\n`);
|
|
1211
|
-
}
|
|
1212
|
-
async analyze(options) {
|
|
1213
|
-
const projectDir = resolve(this.cwd, options.project);
|
|
1214
|
-
const config = await new ConfigLoader().load(projectDir, options.config);
|
|
1215
|
-
return {
|
|
1216
|
-
config,
|
|
1217
|
-
violations: new ArchChecker(TsMorphAnalyzer.fromTsConfig(config.projectDir), Rules.all()).check(config)
|
|
1218
|
-
};
|
|
1219
|
-
}
|
|
1220
|
-
fail(error) {
|
|
1221
|
-
if (error instanceof CommanderError) return error.exitCode === 0 ? 0 : 2;
|
|
1222
|
-
this.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
1223
|
-
return 2;
|
|
1224
|
-
}
|
|
1225
|
-
};
|
|
1226
|
-
//#endregion
|
|
1227
|
-
export { ConfigLoader as a, Baseline as c, Report as i, TsMorphAnalyzer as l, Rules as n, Config as o, Rule as r, ArchChecker as s, Cli as t, CodeAnalyzer as u };
|
|
1228
|
-
|
|
1229
|
-
//# sourceMappingURL=cli-CW1zAnrP.mjs.map
|