@ingenioz-it/caits 0.6.0 → 0.7.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/package.json +1 -1
- package/quality/architecture/facts.mjs +25 -5
- package/quality/architecture/project.mjs +8 -6
- package/src/di/createBackend.ts +10 -8
- package/src/di/declareDependencies.js +25 -5
- package/src/di/discover.ts +19 -9
- package/src/di/discoverFrontends.ts +4 -8
- package/src/di/discovered.ts +2 -2
- package/src/di/discoveredFrontends.ts +2 -2
- package/src/messaging/BackendEventBus.ts +2 -3
- package/src/messaging/ContextMap.ts +3 -4
- package/src/messaging/Controller.ts +2 -3
- package/src/messaging/InProcessMessageBus.ts +5 -7
- package/src/messaging/MessageBus.ts +1 -2
- package/src/state/createStore.ts +1 -2
- package/src/testing/backend/application.ts +1 -1
- package/src/testing/backend/applicationScenario.ts +6 -7
- package/src/testing/backend/context-map.ts +1 -1
- package/src/testing/backend/integration.ts +1 -1
- package/src/testing/frontend/applicationScenario.ts +2 -3
- package/src/testing/frontend/applications.ts +3 -3
- package/src/testing/frontend/frontends.ts +3 -4
- package/src/virtual-modules.d.ts +0 -2
- package/src/vite.js +0 -2
- package/template/caits/architecture.mjs +138 -108
- package/template/caits/imports.mjs +2 -2
- package/template/caits/vite.ts +3 -1
- package/template/project/AGENTS +1 -1
- package/src/messaging/Input.ts +0 -4
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { FrontendEventBus } from "../../messaging/FrontendEventBus.js";
|
|
2
|
-
import type { Input } from "../../messaging/Input.js";
|
|
3
2
|
import { createStore, type StoreDefinition } from "../../state/createStore.js";
|
|
4
3
|
import type { EventPublisher } from "../../messaging/EventPublisher.js";
|
|
5
4
|
import { discoveredFrontends } from "../../di/discoveredFrontends.js";
|
|
@@ -9,7 +8,7 @@ type EventOf<F> = F extends { events: EventPublisher<infer E> } ? E : never;
|
|
|
9
8
|
type StoreOf<F> = F extends { store: infer S } ? S : undefined;
|
|
10
9
|
|
|
11
10
|
/** The presentation of a context in a test: its frontend, the DTOs that its store sent, and the events that it published. */
|
|
12
|
-
export type TestPresentation<F> = { events: EventPublisher<EventOf<F>>; store: StoreOf<F>; sent:
|
|
11
|
+
export type TestPresentation<F> = { events: EventPublisher<EventOf<F>>; store: StoreOf<F>; sent: object[]; published: EventOf<F>[] };
|
|
13
12
|
|
|
14
13
|
/**
|
|
15
14
|
* The presentation of a context, as its views and its components get it: its EventPublisher and its store (none, for a context
|
|
@@ -19,11 +18,11 @@ export type TestPresentation<F> = { events: EventPublisher<EventOf<F>>; store: S
|
|
|
19
18
|
*/
|
|
20
19
|
export function testPresentation<Name extends keyof Frontends>(name: Name): TestPresentation<Frontends[Name]> {
|
|
21
20
|
const events = new FrontendEventBus<EventOf<Frontends[Name]>>();
|
|
22
|
-
const sent:
|
|
21
|
+
const sent: object[] = [];
|
|
23
22
|
const published: EventOf<Frontends[Name]>[] = [];
|
|
24
23
|
events.subscribe(event => { published.push(event); });
|
|
25
24
|
const definition = discoveredFrontends[name].store as StoreDefinition<unknown, EventOf<Frontends[Name]>> | undefined;
|
|
26
|
-
const store = definition && createStore(definition, { events, bus: { send:
|
|
25
|
+
const store = definition && createStore(definition, { events, bus: { send: (input: object) => { sent.push(input); return Promise.resolve(undefined); } } });
|
|
27
26
|
return { events, store, sent, published } as unknown as TestPresentation<Frontends[Name]>;
|
|
28
27
|
}
|
|
29
28
|
|
package/src/virtual-modules.d.ts
CHANGED
|
@@ -4,7 +4,6 @@ declare module "virtual:kernel/backend" {
|
|
|
4
4
|
import type { Translation } from "@ingenioz-it/caits/messaging/ContextMap";
|
|
5
5
|
type Modules = Record<string, Record<string, unknown>>;
|
|
6
6
|
export const bindings: Record<string, readonly Binding[]>;
|
|
7
|
-
export const dtos: Modules;
|
|
8
7
|
export const handlers: Modules;
|
|
9
8
|
export const controllers: Modules;
|
|
10
9
|
export const maps: Record<string, Translation[]>;
|
|
@@ -21,7 +20,6 @@ declare module "virtual:kernel/frontend" {
|
|
|
21
20
|
import type { StoreDefinition } from "@ingenioz-it/caits/state/createStore";
|
|
22
21
|
type Modules = Record<string, Record<string, unknown>>;
|
|
23
22
|
export const bindings: Record<string, readonly Binding[]>;
|
|
24
|
-
export const dtos: Modules;
|
|
25
23
|
export const handlers: Modules;
|
|
26
24
|
export const stores: Record<string, StoreDefinition<unknown, unknown>>;
|
|
27
25
|
export const maps: Record<string, readonly Line[]>;
|
package/src/vite.js
CHANGED
|
@@ -58,7 +58,6 @@ function dependencies(source) {
|
|
|
58
58
|
const CONTEXT_MODULES = {
|
|
59
59
|
"virtual:kernel/backend": [
|
|
60
60
|
["bindings", "backend/*/di.ts", true],
|
|
61
|
-
["dtos", "backend/*/{command,query}/*/*.dto.ts"],
|
|
62
61
|
["handlers", "backend/*/{command,query}/*/*.handler.ts"],
|
|
63
62
|
["controllers", "backend/*/{command,query}/*/*.controller.ts"],
|
|
64
63
|
["maps", "backend/*/context-map.ts", true]
|
|
@@ -66,7 +65,6 @@ const CONTEXT_MODULES = {
|
|
|
66
65
|
"virtual:kernel/backend-test": [["bindings", "backend/*/test.di.ts", true]],
|
|
67
66
|
"virtual:kernel/frontend": [
|
|
68
67
|
["bindings", "frontend/*/di.ts", true],
|
|
69
|
-
["dtos", "frontend/*/{command,query}/*/*.dto.ts"],
|
|
70
68
|
["handlers", "frontend/*/{command,query}/*/*.handler.ts"],
|
|
71
69
|
["stores", "frontend/*/presentation/store/*Store.ts", true],
|
|
72
70
|
["maps", "frontend/*/context-map.ts", true]
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
LISTS, linesOf, locate, portOf, resolveImport, typeOfEvent
|
|
6
6
|
} from "@ingenioz-it/caits/quality/architecture";
|
|
7
7
|
|
|
8
|
-
/** @import { Located, Location, Rule } from "@ingenioz-it/caits/quality/architecture" */
|
|
8
|
+
/** @import { File, Located, Location, Rule } from "@ingenioz-it/caits/quality/architecture" */
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* The rules of the architecture, by layer. Each rule has a name and a description, reads one kind of thing (`on`), and checks it:
|
|
@@ -55,11 +55,35 @@ const fileKindOf = target =>
|
|
|
55
55
|
*/
|
|
56
56
|
const withinContext = (from, to) => from.kind === "layer" && to.kind === "layer" && to.side === from.side && to.layer !== "di"
|
|
57
57
|
&& (to.context === from.context || to.context === "shared");
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
/**
|
|
59
|
+
* The name of a port: the interface that its module exports, or the name of its file when the module exports none yet.
|
|
60
|
+
* @type {(module: string, files: File[]) => string}
|
|
61
|
+
*/
|
|
62
|
+
const portNameOf = (module, files) => files.find(file => file.path === `${module}.ts`)?.exports.find(({ kind }) => kind === "interface")?.name ?? fileName(module);
|
|
63
|
+
/**
|
|
64
|
+
* The port of an adapter, from the path of the adapter: its name and its path, when the domain declares it.
|
|
65
|
+
* @type {(target: string, project: { paths: Set<string>; files: File[] }) => { name: string; path: string } | null}
|
|
66
|
+
*/
|
|
67
|
+
const portOfAdapter = (target, { paths, files }) => {
|
|
68
|
+
const module = adapterOf(target, paths)?.portModule;
|
|
69
|
+
return module ? { name: portNameOf(module, files), path: `${module}.ts` } : null;
|
|
62
70
|
};
|
|
71
|
+
/**
|
|
72
|
+
* The names that a file imports from a module of the project, without its extension: `implements NoteRepository` names the port when
|
|
73
|
+
* NoteRepository comes from the module of the port.
|
|
74
|
+
* @type {(file: File, module: string, root: string) => Set<string>}
|
|
75
|
+
*/
|
|
76
|
+
const namesFrom = (file, module, root) => new Set(file.imported
|
|
77
|
+
.filter(({ from }) => resolveImport(file.path, from, root).target.replace(/\.(js|ts)$/, "") === module).map(({ name }) => name));
|
|
78
|
+
/**
|
|
79
|
+
* The port folder of the domain that a class implements, through the module that the file imports its interface from.
|
|
80
|
+
* @type {(file: File, root: string) => { implementer: string; name: string; folder: string } | undefined}
|
|
81
|
+
*/
|
|
82
|
+
const implementedPortOf = (file, root) => file.classes.flatMap(declared => declared.implements.flatMap(name => {
|
|
83
|
+
const from = file.imported.find(imported => imported.name === name)?.from;
|
|
84
|
+
const port = from === undefined ? null : portOf(`${resolveImport(file.path, from, root).target.replace(/\.(js|ts)$/, "")}.ts`);
|
|
85
|
+
return port && !port.contract ? [{ implementer: declared.name ?? "Its class", name, folder: port.folder }] : [];
|
|
86
|
+
}))[0];
|
|
63
87
|
/**
|
|
64
88
|
* The file that an import of a stylesheet loads: Sass adds the extension, and the _ of a partial.
|
|
65
89
|
* @type {(target: string, paths: Set<string>) => string}
|
|
@@ -171,16 +195,16 @@ export const rules = [
|
|
|
171
195
|
on: "file",
|
|
172
196
|
/** The name of the files of each folder of the domain: what it says, and the name to give. */
|
|
173
197
|
kinds: {
|
|
174
|
-
entity: "the
|
|
175
|
-
"value-object": "the
|
|
176
|
-
error: "the
|
|
177
|
-
event: "the
|
|
198
|
+
entity: "the file of an entity is in PascalCase: entity/{Entity}.ts",
|
|
199
|
+
"value-object": "the file of a value object is in PascalCase: value-object/{ValueObject}.ts",
|
|
200
|
+
error: "the file of an error is in PascalCase: error/{Error}.ts",
|
|
201
|
+
event: "the file of a domain event is in PascalCase: event/{DomainEvent}.ts",
|
|
178
202
|
port: "a port is port/{port}/{Port}.ts, beside its contract {Port}.contract.ts"
|
|
179
203
|
},
|
|
180
204
|
/** @this {{ kinds: Record<string, string> }} */
|
|
181
205
|
check({ path }) {
|
|
182
206
|
if (!/^src\/(backend|frontend)\/[^/]+\/domain\//.test(path)
|
|
183
|
-
|| /^src\/(backend|frontend)\/[^/]+\/domain\/((entity|value-object)\/[A-Z]\w*(\.test)?|error\/[A-Z]\w
|
|
207
|
+
|| /^src\/(backend|frontend)\/[^/]+\/domain\/((entity|value-object)\/[A-Z]\w*(\.test)?|error\/[A-Z]\w*|event\/[A-Z]\w*|port\/[a-z0-9-]+\/[A-Z]\w*?(\.contract)?)\.ts$/.test(path)) return;
|
|
184
208
|
const [, context, rest] = /^(src\/(?:backend|frontend)\/[^/]+)\/domain\/(.+)$/.exec(path) ?? [];
|
|
185
209
|
const name = fileName(path);
|
|
186
210
|
const parts = rest.split("/");
|
|
@@ -198,11 +222,10 @@ export const rules = [
|
|
|
198
222
|
if (test && kind !== "entity" && kind !== "value-object") {
|
|
199
223
|
return `${name} is a test in ${kind}/, but in the domain only the entities and the value objects have a test. Test it through the application test of a use case${kind === "port" ? ", or through the contract of the port" : ""}.`;
|
|
200
224
|
}
|
|
201
|
-
const
|
|
202
|
-
const suggested = `${stem}${test ? ".test" : contract ? ".contract" : ""}.ts`;
|
|
225
|
+
const suggested = `${base}${test ? ".test" : contract ? ".contract" : ""}.ts`;
|
|
203
226
|
const folder = kind === "port" ? `port/${kebab(base)}` : kind;
|
|
204
227
|
if (parts.slice(0, -1).join("/") !== folder) return `${name} is in ${parts.slice(0, -1).join("/")}/, but its place is ${folder}/. Move it to ${context}/domain/${folder}/${suggested}.`;
|
|
205
|
-
return `${name} is in ${folder}/, but ${this.kinds[kind]}. Rename it ${suggested}
|
|
228
|
+
return `${name} is in ${folder}/, but ${this.kinds[kind]}. Rename it ${suggested}.`;
|
|
206
229
|
}
|
|
207
230
|
},
|
|
208
231
|
{
|
|
@@ -216,23 +239,22 @@ export const rules = [
|
|
|
216
239
|
const names = exports.filter(({ kind }) => this.declarations.has(kind)).map(({ name }) => name);
|
|
217
240
|
if (names.length === exports.length && new Set(names).size === 1) return;
|
|
218
241
|
const file = fileName(path);
|
|
219
|
-
const expected = file.slice(0, -".ts".length);
|
|
220
242
|
const unnamed = exports.find(({ kind }) => kind === "default" || kind === "all");
|
|
221
243
|
if (unnamed) {
|
|
222
|
-
return at(unnamed.line, `${file} has an export without a name (${unnamed.kind === "all" ? "export *" : "export default"}), but a domain file exports one name
|
|
244
|
+
return at(unnamed.line, `${file} has an export without a name (${unnamed.kind === "all" ? "export *" : "export default"}), but a domain file exports one name. Export its declaration with its name: \`export class …\`.`);
|
|
223
245
|
}
|
|
224
246
|
const distinct = [...new Set(exports.map(({ name }) => name ?? ""))];
|
|
225
247
|
if (distinct.length > 1) {
|
|
226
|
-
const extras = distinct
|
|
227
|
-
const first = exports.find(({ name }) =>
|
|
248
|
+
const [kept, ...extras] = distinct;
|
|
249
|
+
const first = exports.find(({ name }) => name === extras[0]) ?? exports[0];
|
|
228
250
|
const port = /\/domain\/port\//.test(path) ? ", which the port and its token share" : "";
|
|
229
|
-
return at(first.line, `${file} exports ${distinct.length} names (${distinct.join(", ")}), but a domain file exports one name
|
|
251
|
+
return at(first.line, `${file} exports ${distinct.length} names (${distinct.join(", ")}), but a domain file exports one name${port}. Move ${listOf(extras)} into ${kept} as a static member, or to a file of its own.`);
|
|
230
252
|
}
|
|
231
253
|
const listed = exports.find(({ kind }) => kind === "specifier");
|
|
232
254
|
if (listed) {
|
|
233
255
|
return at(listed.line, `${file} exports ${listed.name} from a list, \`export { … }\`, but a domain file exports its one name where it declares it. Write \`export\` before the declaration of ${listed.name}.`);
|
|
234
256
|
}
|
|
235
|
-
return `${file} exports nothing, but a domain file exports one name
|
|
257
|
+
return `${file} exports nothing, but a domain file exports one name. Write \`export\` before its declaration.`;
|
|
236
258
|
}
|
|
237
259
|
},
|
|
238
260
|
{
|
|
@@ -240,11 +262,13 @@ export const rules = [
|
|
|
240
262
|
description: "An entity or a value object is an exported class. The class holds the business rules of its values.",
|
|
241
263
|
on: "file",
|
|
242
264
|
check: ({ path, classes, exports }) => {
|
|
243
|
-
const
|
|
265
|
+
const kind = /^src\/(?:backend|frontend)\/[^/]+\/domain\/(entity|value-object)\/[A-Z]\w*\.ts$/.exec(path)?.[1];
|
|
244
266
|
if (!kind || classes.some(({ exported }) => exported)) return;
|
|
245
|
-
const rule = `${kind === "entity" ? "an entity" : "a value object"} is an exported class, which holds the business rules of its values
|
|
246
|
-
const exported = exports
|
|
247
|
-
return exported
|
|
267
|
+
const rule = `${kind === "entity" ? "an entity" : "a value object"} is an exported class, which holds the business rules of its values.`;
|
|
268
|
+
const [exported] = exports;
|
|
269
|
+
return exported?.name
|
|
270
|
+
? at(exported.line, `${fileName(path)} exports ${exported.name} as ${kindOf(exported.kind)}, but ${rule} Make it a class, \`export class …\`, and put the rules of its values in it.`)
|
|
271
|
+
: `${fileName(path)} exports no class, but ${rule} Write \`export class …\`, and put the rules of its values in it.`;
|
|
248
272
|
}
|
|
249
273
|
},
|
|
250
274
|
{
|
|
@@ -252,17 +276,16 @@ export const rules = [
|
|
|
252
276
|
description: "A domain error is an exported class …Error that extends Error.",
|
|
253
277
|
on: "file",
|
|
254
278
|
check: ({ path, classes }) => {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
const declared = classes.find(({
|
|
259
|
-
if (!declared) {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
return at(declared.line, declared.
|
|
264
|
-
|
|
265
|
-
: `${name} is not exported, but a domain error is an exported class that extends Error. ${fix}`);
|
|
279
|
+
if (!/^src\/(?:backend|frontend)\/[^/]+\/domain\/error\/[^/]+\.ts$/.test(path)
|
|
280
|
+
|| classes.some(({ exported, name, extends: parent }) => exported && name !== null && /^\w+Error$/.test(name) && parent === "Error")) return;
|
|
281
|
+
const rule = "a domain error is an exported class …Error that extends Error.";
|
|
282
|
+
const declared = classes.find(({ extends: parent }) => parent === "Error") ?? classes.find(({ name }) => name !== null && /Error$/.test(name)) ?? classes[0];
|
|
283
|
+
if (!declared) return `${fileName(path)} holds no class, but ${rule} Write \`export class …Error extends Error\`.`;
|
|
284
|
+
const name = declared.name ?? "Its class";
|
|
285
|
+
const fix = `Write \`export class ${declared.name !== null && /Error$/.test(declared.name) ? declared.name : `${declared.name ?? "…"}Error`} extends Error\`.`;
|
|
286
|
+
if (!declared.exported) return at(declared.line, `${name} is not exported, but ${rule} ${fix}`);
|
|
287
|
+
if (declared.extends !== "Error") return at(declared.line, `${name} extends ${declared.extends ?? "nothing"}, but ${rule} ${fix}`);
|
|
288
|
+
return at(declared.line, `${name} does not end with Error, but ${rule} ${fix}`);
|
|
266
289
|
}
|
|
267
290
|
},
|
|
268
291
|
{
|
|
@@ -310,8 +333,8 @@ export const rules = [
|
|
|
310
333
|
on: "file",
|
|
311
334
|
check: (file, { files, paths, root }) => {
|
|
312
335
|
const port = portOf(file.path);
|
|
313
|
-
if (!port?.contract || files.some(other => adapterOf(other.path)?.test && importedModules(other, root).includes(`${port.module}.contract`))) return;
|
|
314
|
-
const describe = file.exports.find(({ kind }) => kind === "function")?.name ??
|
|
336
|
+
if (!port?.contract || files.some(other => adapterOf(other.path, paths)?.test && importedModules(other, root).includes(`${port.module}.contract`))) return;
|
|
337
|
+
const describe = file.exports.find(({ kind }) => kind === "function")?.name ?? "the function that the contract exports";
|
|
315
338
|
const infra = `${contextFolderOf(file.path)}/infra/${port.folder}/`;
|
|
316
339
|
const adapters = [...paths].filter(other => other.startsWith(infra)).map(fileName).sort();
|
|
317
340
|
if (adapters.length === 0) return `No test runs the contract of ${port.name}, so it checks nothing. Write an adapter of ${port.name} in ${infra}, with a test that calls ${describe}.`;
|
|
@@ -341,7 +364,7 @@ export const rules = [
|
|
|
341
364
|
},
|
|
342
365
|
{
|
|
343
366
|
name: "application-imports-only-listed-packages",
|
|
344
|
-
description: "The application (the handlers and the DTOs of the use cases, and the events of the frontend) imports only the packages, the modules of the kernel and the Node APIs that quality/imports.mjs lists for it. CAITS lists those of the domain
|
|
367
|
+
description: "The application (the handlers and the DTOs of the use cases, and the events of the frontend) imports only the packages, the modules of the kernel and the Node APIs that quality/imports.mjs lists for it. CAITS lists those of the domain and @ingenioz-it/caits/messaging/EventPublisher. The rest is technology: the domain declares a port, the infra implements it, and di.ts binds it.",
|
|
345
368
|
on: "import",
|
|
346
369
|
/** The fix for a module of the kernel that the list does not hold, by the folder of the module. */
|
|
347
370
|
kernel: {
|
|
@@ -367,10 +390,11 @@ export const rules = [
|
|
|
367
390
|
name: "entity-without-port",
|
|
368
391
|
description: "An entity or a value object does not import a port. It holds the business rules; the handlers call the ports.",
|
|
369
392
|
on: "import",
|
|
370
|
-
check: ({ file, from, target }) => {
|
|
371
|
-
const
|
|
393
|
+
check: ({ file, from, specifier, target }) => {
|
|
394
|
+
const kind = /\/domain\/(entity|value-object)\/(?:.+\/)?\w+\.ts$/.exec(file.path)?.[1];
|
|
372
395
|
if (from.layer !== "domain" || !kind || !/\/domain\/port\//.test(target)) return;
|
|
373
|
-
const
|
|
396
|
+
const name = file.classes.find(({ exported }) => exported)?.name ?? fileName(file.path).replace(/\.ts$/, "");
|
|
397
|
+
const port = file.imported.find(({ from: source }) => source === specifier)?.name ?? fileName(target).replace(/\.(js|ts)$/, "");
|
|
374
398
|
return `The ${kind === "entity" ? "entity" : "value object"} ${name} imports the port ${port} (${target}), but an entity or a value object holds business rules, and never talks to a port. Let the handler call ${port}, and give ${name} the values it needs.`;
|
|
375
399
|
}
|
|
376
400
|
},
|
|
@@ -421,17 +445,18 @@ export const rules = [
|
|
|
421
445
|
},
|
|
422
446
|
{
|
|
423
447
|
name: "handler-answers-output",
|
|
424
|
-
description: "A backend handler
|
|
448
|
+
description: "A backend handler answers with output DTOs: the return type of its execute names only classes of the DTO file of its use case, one or several (`Promise<AddMessageOutput>`, `Promise<MessageAdded | MessageRefused>`).",
|
|
425
449
|
on: "file",
|
|
426
|
-
check: ({ path,
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
return
|
|
433
|
-
|
|
434
|
-
|
|
450
|
+
check: ({ path, classes, imported }) => {
|
|
451
|
+
if (!/^src\/backend\/[^/]+\/(?:command|query)\/[^/]+\/[^/]+\.handler\.ts$/.test(path)) return;
|
|
452
|
+
const rule = "a backend handler answers with output DTOs, classes of the DTO file of its use case. Give execute the type of its answers, "
|
|
453
|
+
+ "`Promise<AddMessageOutput>`, or `Promise<MessageAdded | MessageRefused>` for two, and declare each class in the DTO file.";
|
|
454
|
+
const execute = classes.flatMap(({ name, methods }) => methods.filter(method => method.name === "execute").map(method => ({ ...method, label: name === null ? "Its execute" : `${name}.execute` })))[0];
|
|
455
|
+
if (!execute) return `${fileName(path)} has no execute, but ${rule}`;
|
|
456
|
+
if (!execute.returns) return at(execute.line, `${execute.label} has no return type, but ${rule}`);
|
|
457
|
+
const dtos = new Set(imported.filter(({ from }) => /\.dto\.(js|ts)$/.test(from)).map(({ name }) => name));
|
|
458
|
+
const others = execute.returns.filter(({ name }) => name === null || !dtos.has(name)).map(({ text }) => `\`${text}\``);
|
|
459
|
+
if (others.length > 0) return at(execute.line, `${execute.label} answers with ${listOf(others)}, ${others.length > 1 ? "which are not DTOs" : "which is not a DTO"} of its use case, but ${rule}`);
|
|
435
460
|
}
|
|
436
461
|
},
|
|
437
462
|
{
|
|
@@ -439,11 +464,11 @@ export const rules = [
|
|
|
439
464
|
description: "A controller is an exported class …Controller that extends the Controller of the kernel.",
|
|
440
465
|
on: "file",
|
|
441
466
|
check: ({ path, classes }) => {
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
const
|
|
467
|
+
if (!/^src\/backend\/[^/]+\/(?:command|query)\/[^/]+\/[^/]+\.controller\.ts$/.test(path)
|
|
468
|
+
|| classes.some(({ exported, name, extends: parent }) => exported && name !== null && /^\w+Controller$/.test(name) && parent === "Controller")) return;
|
|
469
|
+
const declared = classes.find(({ name }) => name !== null && /Controller$/.test(name)) ?? classes.find(({ extends: parent }) => parent === "Controller") ?? classes[0];
|
|
470
|
+
const expected = declared?.name ? (/Controller$/.test(declared.name) ? declared.name : `${declared.name}Controller`) : "…Controller";
|
|
445
471
|
const rule = `a controller is an exported class …Controller that extends the Controller of the kernel. Write \`export class ${expected} extends Controller\`, with Controller from @ingenioz-it/caits/messaging/Controller.`;
|
|
446
|
-
const declared = classes.find(({ name }) => name !== null && /Controller$/.test(name)) ?? classes[0];
|
|
447
472
|
if (!declared) return `${fileName(path)} holds no exported class …Controller, but ${rule}`;
|
|
448
473
|
const name = declared.name ?? "Its class";
|
|
449
474
|
if (!declared.exported) return at(declared.line, `${name} is not exported, but ${rule}`);
|
|
@@ -456,31 +481,29 @@ export const rules = [
|
|
|
456
481
|
|
|
457
482
|
{
|
|
458
483
|
name: "frontend-event-shape",
|
|
459
|
-
description: "The events of a frontend context are in event/, one file per event
|
|
484
|
+
description: "The events of a frontend context are in event/, one file per event, at its root.",
|
|
460
485
|
on: "file",
|
|
461
486
|
check: ({ path }) => {
|
|
462
487
|
const [, context, rest] = /^(src\/frontend\/[^/]+)\/event\/(.+)$/.exec(path) ?? [];
|
|
463
488
|
if (!rest || /^[A-Z]\w*\.ts$/.test(rest)) return;
|
|
464
|
-
return `${rest} is not an event file: the events of a frontend context are in event/, one file per event,
|
|
489
|
+
return `${rest} is not an event file: the events of a frontend context are in event/, one file per event, at its root. Move it to ${context}/event/${pascalOf(fileName(rest).replace(/\.[^.]+$/, ""))}.ts.`;
|
|
465
490
|
}
|
|
466
491
|
},
|
|
467
492
|
{
|
|
468
493
|
name: "event-file-exports-its-event",
|
|
469
|
-
description: "A frontend event file exports one name only:
|
|
494
|
+
description: "A frontend event file exports one name only: its event, a class, or the type that joins the events of the context.",
|
|
470
495
|
on: "file",
|
|
471
496
|
check: ({ path, exports }) => {
|
|
472
|
-
const
|
|
473
|
-
if (!
|
|
474
|
-
const file =
|
|
475
|
-
if (exports.length === 0) return `${file} exports nothing, but an event file exports its event. Write \`export class
|
|
497
|
+
const context = /^src\/frontend\/([^/]+)\/event\/\w+\.ts$/.exec(path)?.[1];
|
|
498
|
+
if (!context) return;
|
|
499
|
+
const file = fileName(path);
|
|
500
|
+
if (exports.length === 0) return `${file} exports nothing, but an event file exports its event. Write \`export class … { readonly type = "${context}.…" as const; }\`.`;
|
|
501
|
+
const names = exports.map(({ name }) => name ?? "default");
|
|
476
502
|
if (exports.length > 1) {
|
|
477
|
-
|
|
478
|
-
const first = exports.find(({ name }) => name !== event) ?? exports[1];
|
|
479
|
-
return at(first.line, `${file} exports ${exports.length} names (${names.join(", ")}), but an event file exports only its event, ${event}. Move ${listOf(names.filter(name => name !== event))} into ${event} as a static member, or to a file of its own.`);
|
|
503
|
+
return at(exports[1].line, `${file} exports ${exports.length} names (${names.join(", ")}), but an event file exports only its event. Move ${listOf(names.slice(1))} into ${names[0]} as a static member, or to a file of its own.`);
|
|
480
504
|
}
|
|
481
505
|
const [exported] = exports;
|
|
482
|
-
if (exported.
|
|
483
|
-
if (!["class", "type"].includes(exported.kind)) return at(exported.line, `${file} exports ${event} as ${kindOf(exported.kind)}, but an event is a class, or a type that joins events. Write \`export class ${event}\`.`);
|
|
506
|
+
if (!["class", "type"].includes(exported.kind)) return at(exported.line, `${file} exports ${names[0]} as ${kindOf(exported.kind)}, but an event is a class, or a type that joins events. Write \`export class ${names[0]}\`.`);
|
|
484
507
|
}
|
|
485
508
|
},
|
|
486
509
|
{
|
|
@@ -488,9 +511,10 @@ export const rules = [
|
|
|
488
511
|
description: "A frontend event class has `readonly type = \"{context}.{event}\" as const`. The store reads this type: the context, then the name of the event without the context, in kebab-case.",
|
|
489
512
|
on: "file",
|
|
490
513
|
check: ({ path, classes }) => {
|
|
491
|
-
const
|
|
492
|
-
const declared =
|
|
493
|
-
if (!context || !declared) return;
|
|
514
|
+
const context = /^src\/frontend\/([^/]+)\/event\/\w+\.ts$/.exec(path)?.[1];
|
|
515
|
+
const declared = classes.find(({ exported, name }) => exported && name !== null);
|
|
516
|
+
if (!context || !declared?.name) return;
|
|
517
|
+
const event = declared.name;
|
|
494
518
|
const type = typeOfEvent(context, event);
|
|
495
519
|
const write = `\`readonly type = "${type}" as const\``;
|
|
496
520
|
const property = declared.properties.find(({ name }) => name === "type");
|
|
@@ -586,7 +610,7 @@ export const rules = [
|
|
|
586
610
|
if (event) return [at(lines.objectTypes[index], `${head} Build it with \`new ${event.name}(…)\`, from ${event.path}.`)];
|
|
587
611
|
const [context, name] = type.split(".");
|
|
588
612
|
const guess = `${pascalOf(context)}${pascalOf(name)}`;
|
|
589
|
-
return [at(lines.objectTypes[index], `${head} Write the class ${guess}
|
|
613
|
+
return [at(lines.objectTypes[index], `${head} Write the class ${guess}, with \`readonly type = "${type}" as const\`, in a file of its own in src/frontend/${context}/event/, and build the event with \`new ${guess}(…)\`.`)];
|
|
590
614
|
});
|
|
591
615
|
}
|
|
592
616
|
},
|
|
@@ -634,49 +658,51 @@ export const rules = [
|
|
|
634
658
|
name: "backend-infra-shape",
|
|
635
659
|
description: "The infra of a backend context holds only adapters: infra/{port}/{implementation}-{port}.ts and their tests.",
|
|
636
660
|
on: "file",
|
|
637
|
-
check: ({
|
|
638
|
-
const [, context, rest] = /^(src\/backend\/[^/]+)\/infra\/(.+)$/.exec(path) ?? [];
|
|
661
|
+
check: (file, { root }) => {
|
|
662
|
+
const [, context, rest] = /^(src\/backend\/[^/]+)\/infra\/(.+)$/.exec(file.path) ?? [];
|
|
639
663
|
if (!rest || /^([a-z0-9-]+)\/[a-z0-9-]+-\1(\.test)?\.ts$/.test(rest)) return;
|
|
640
664
|
const parts = rest.split("/");
|
|
641
|
-
const
|
|
665
|
+
const name = fileName(file.path);
|
|
642
666
|
if (parts.length === 2 && /^[a-z0-9-]+$/.test(parts[0])) {
|
|
643
667
|
const [folder] = parts;
|
|
644
|
-
const test =
|
|
645
|
-
const implementation = kebab(
|
|
646
|
-
return `${
|
|
668
|
+
const test = name.endsWith(".test.ts") ? ".test" : "";
|
|
669
|
+
const implementation = kebab(name.replace(/(\.test)?\.[^.]+$/, "")).replace(/[^a-z0-9-]+/g, "-").replace(new RegExp(`-?${folder}$`), "") || "{implementation}";
|
|
670
|
+
return `${name} is in infra/${folder}/, but an adapter is named after its implementation and its port: {implementation}-${folder}.ts. Rename it ${implementation}-${folder}${test}.ts.`;
|
|
647
671
|
}
|
|
648
|
-
const port =
|
|
649
|
-
return `infra/${rest} is in no folder of a port: the infra holds one folder per port, infra/{port}/. Move it into the folder of the port that it implements${port ? `, ${context}/infra/${
|
|
672
|
+
const port = implementedPortOf(file, root);
|
|
673
|
+
return `infra/${rest} is in no folder of a port: the infra holds one folder per port, infra/{port}/. Move it into the folder of the port that it implements${port ? `, ${context}/infra/${port.folder}/` : ""}.`;
|
|
650
674
|
}
|
|
651
675
|
},
|
|
652
676
|
{
|
|
653
677
|
name: "infra-folder-implements-port",
|
|
654
678
|
description: "Each folder of infra/ is for a port that the domain declares: infra/{port}/ goes with domain/port/{port}/.",
|
|
655
679
|
on: "file",
|
|
656
|
-
check: (
|
|
657
|
-
const adapter = adapterOf(path);
|
|
658
|
-
if (!adapter ||
|
|
659
|
-
const context = contextFolderOf(path);
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
if (implementer && port) return `${implementer.name} implements ${port}, but it is in infra/${adapter.folder}/, and the domain declares no port ${adapter.port}. Move it to ${context}/infra/${kebab(port)}/.`;
|
|
664
|
-
return `infra/${adapter.folder}/ is the folder of the port ${adapter.port}, but the domain declares no such port. Write the port in ${adapter.portModule}.ts, or move the adapter into the folder of the port that it implements.`;
|
|
680
|
+
check: (file, { paths, root }) => {
|
|
681
|
+
const adapter = adapterOf(file.path, paths);
|
|
682
|
+
if (!adapter || adapter.portModule) return;
|
|
683
|
+
const context = contextFolderOf(file.path);
|
|
684
|
+
const port = implementedPortOf(file, root);
|
|
685
|
+
if (port) return `${port.implementer} implements ${port.name}, but it is in infra/${adapter.folder}/, and the domain has no port in domain/port/${adapter.folder}/. Move it to ${context}/infra/${port.folder}/.`;
|
|
686
|
+
return `infra/${adapter.folder}/ is the folder of a port, but the domain has no port in domain/port/${adapter.folder}/. Write the port in ${context}/domain/port/${adapter.folder}/, or move the adapter into the folder of the port that it implements.`;
|
|
665
687
|
}
|
|
666
688
|
},
|
|
667
689
|
{
|
|
668
690
|
name: "adapter-implements-port",
|
|
669
|
-
description: "An adapter in infra/{port}/ implements the port of its folder
|
|
691
|
+
description: "An adapter in infra/{port}/ implements the port of its folder: the interface that it imports from domain/port/{port}/.",
|
|
670
692
|
on: "file",
|
|
671
|
-
check: ({
|
|
672
|
-
const adapter = adapterOf(path);
|
|
673
|
-
if (!adapter || adapter.test
|
|
674
|
-
const
|
|
675
|
-
if (
|
|
676
|
-
|
|
677
|
-
}
|
|
693
|
+
check: (file, { paths, files, root }) => {
|
|
694
|
+
const adapter = adapterOf(file.path, paths);
|
|
695
|
+
if (!adapter?.portModule || adapter.test) return;
|
|
696
|
+
const imported = namesFrom(file, adapter.portModule, root);
|
|
697
|
+
if (file.classes.some(declared => declared.implements.some(name => imported.has(name)))) return;
|
|
698
|
+
const port = portNameOf(adapter.portModule, files);
|
|
699
|
+
const write = `with \`import type { ${port} } from "#context/${adapter.portModule.split("/").slice(3).join("/")}.js"\``;
|
|
700
|
+
const declared = file.classes.find(({ exported }) => exported) ?? file.classes[0];
|
|
701
|
+
if (!declared) return `${fileName(file.path)} holds no class, but an adapter of infra/${adapter.folder}/ is a class that implements ${port}. Write \`export class … implements ${port}\`, ${write}.`;
|
|
702
|
+
const name = declared.name ?? "Its class";
|
|
703
|
+
if (declared.implements.includes(port)) return at(declared.line, `${name} implements a ${port} that is not the port of infra/${adapter.folder}/, from ${adapter.portModule}.ts. Implement the port, ${write}.`);
|
|
678
704
|
const others = declared.implements.length > 0 ? `; it implements ${listOf(declared.implements)}` : "";
|
|
679
|
-
return at(declared.line, `${
|
|
705
|
+
return at(declared.line, `${name} does not implement ${port}, the port of infra/${adapter.folder}/${others}. Write \`export class ${declared.name ?? "…"} implements ${port}\`, ${write}.`);
|
|
680
706
|
}
|
|
681
707
|
},
|
|
682
708
|
{
|
|
@@ -684,12 +710,15 @@ export const rules = [
|
|
|
684
710
|
description: "The test of an adapter runs the contract of its port. Thus all the adapters of a port do the same things.",
|
|
685
711
|
on: "file",
|
|
686
712
|
check: (file, { files, paths, root }) => {
|
|
687
|
-
const adapter = adapterOf(file.path);
|
|
688
|
-
if (!adapter?.test || !
|
|
689
|
-
const
|
|
690
|
-
const
|
|
691
|
-
const
|
|
692
|
-
|
|
713
|
+
const adapter = adapterOf(file.path, paths);
|
|
714
|
+
if (!adapter?.test || !adapter.portModule || importedModules(file, root).includes(`${adapter.portModule}.contract`)) return;
|
|
715
|
+
const port = portNameOf(adapter.portModule, files);
|
|
716
|
+
const testedPath = file.path.replace(/\.test\.ts$/, ".ts");
|
|
717
|
+
const tested = files.find(other => other.path === testedPath)?.classes.find(({ exported }) => exported)?.name;
|
|
718
|
+
const name = tested ?? fileName(testedPath);
|
|
719
|
+
const describe = files.find(other => other.path === `${adapter.portModule}.contract.ts`)?.exports.find(({ kind }) => kind === "function")?.name;
|
|
720
|
+
const contract = `#context/${adapter.portModule.split("/").slice(3).join("/")}.contract.js`;
|
|
721
|
+
return `This test does not run the contract of ${port}, so nothing proves that ${name} does what every adapter of ${port} does. Import ${describe ?? "the function of the contract"} from "${contract}", and call it with a factory of ${tested ?? "the adapter"}.`;
|
|
693
722
|
}
|
|
694
723
|
},
|
|
695
724
|
|
|
@@ -928,8 +957,9 @@ export const rules = [
|
|
|
928
957
|
const bindings = [...paths].sort().flatMap(path => {
|
|
929
958
|
const port = path.startsWith(`${context}/`) ? portOf(path) : null;
|
|
930
959
|
if (!port || port.contract) return [];
|
|
931
|
-
const
|
|
932
|
-
|
|
960
|
+
const name = portNameOf(port.module, files);
|
|
961
|
+
const adapter = adapters.filter(file => file.path.startsWith(`${context}/infra/${port.folder}/`)).flatMap(file => file.classes).find(declared => declared.implements.includes(name));
|
|
962
|
+
return adapter ? [`bind(${name}).to(${adapter.name})`] : [];
|
|
933
963
|
});
|
|
934
964
|
return { file: context, message: `${context} is a context, but it has no di.ts, so the kernel does not find it. Write ${context}/di.ts with its bindings: \`export default [${bindings.join(", ")}]\`.` };
|
|
935
965
|
});
|
|
@@ -1076,9 +1106,9 @@ export const rules = [
|
|
|
1076
1106
|
name: "application-imports-no-infra",
|
|
1077
1107
|
description: "The application (the handlers and the DTOs of the use cases, and the events of the frontend) does not import the infra: a handler depends on a port of the domain, and di.ts binds the adapter.",
|
|
1078
1108
|
on: "import",
|
|
1079
|
-
check: ({ from, to, target }) => {
|
|
1109
|
+
check: ({ from, to, target }, project) => {
|
|
1080
1110
|
if (!withinContext(from, to) || from.layer !== "application" || to.layer !== "infra") return;
|
|
1081
|
-
const port = portOfAdapter(target);
|
|
1111
|
+
const port = portOfAdapter(target, project);
|
|
1082
1112
|
return `The application layer must not import the infra layer (${target}). Depend on its port${port ? `, ${port.name} (${port.path}),` : " in domain/port/"} instead, and bind the adapter in ${contextFolderOf(target)}/di.ts.`;
|
|
1083
1113
|
}
|
|
1084
1114
|
},
|
|
@@ -1086,9 +1116,9 @@ export const rules = [
|
|
|
1086
1116
|
name: "presentation-imports-no-infra",
|
|
1087
1117
|
description: "The presentation (a controller, or the presentation of the frontend) does not import the infra: it goes through a use case, whose handler depends on a port of the domain.",
|
|
1088
1118
|
on: "import",
|
|
1089
|
-
check: ({ file, from, to, target }) => {
|
|
1119
|
+
check: ({ file, from, to, target }, project) => {
|
|
1090
1120
|
if (!withinContext(from, to) || from.layer !== "presentation" || to.layer !== "infra") return;
|
|
1091
|
-
const port = portOfAdapter(target);
|
|
1121
|
+
const port = portOfAdapter(target, project);
|
|
1092
1122
|
const named = port ? `${port.name} (${port.path})` : "its port";
|
|
1093
1123
|
if (from.side === "frontend") {
|
|
1094
1124
|
return `The presentation layer must not import the infra layer (${target}): a view publishes events, and the handlers of the use cases call the ports. Publish an event of ${from.context}, let the store send the DTO of a use case, and let its handler depend on ${named}.`;
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
* Node API (`node:crypto`), or a folder of modules, ending with `/*` (`@ingenioz-it/caits/messaging/*`). A layer without a list imports
|
|
4
4
|
* any package. The imports of the files of the project (`#context/…`, `./…`) are not packages: the other rules check them.
|
|
5
5
|
* The domain holds the business rules: it imports only the primitives of the kernel. The application adds what a use case needs: its
|
|
6
|
-
*
|
|
6
|
+
* handler publishes events. The presentation, the infra, di.ts and the context map have no list.
|
|
7
7
|
* The project starts from these lists in its quality/imports.mjs.
|
|
8
8
|
*/
|
|
9
9
|
const domain = ["@ingenioz-it/caits/Result", "@ingenioz-it/caits/Token", "@ingenioz-it/caits/DomainEvent"];
|
|
10
10
|
|
|
11
11
|
export const allowed = {
|
|
12
12
|
domain,
|
|
13
|
-
application: [...domain, "@ingenioz-it/caits/messaging/
|
|
13
|
+
application: [...domain, "@ingenioz-it/caits/messaging/EventPublisher"]
|
|
14
14
|
};
|
package/template/caits/vite.ts
CHANGED
|
@@ -14,7 +14,9 @@ export default defineConfig({
|
|
|
14
14
|
plugins: [wholeRouteTypes(), marko(), kernel()],
|
|
15
15
|
// `caits check` runs the checks at once: it gives each one a build folder of its own (CAITS_OUT_DIR), else they empty the folder of
|
|
16
16
|
// each other. Without it, the build goes in dist/, which `npm start` runs.
|
|
17
|
-
build
|
|
17
|
+
// The build keeps the names of the classes: the errors of CAITS name them, and the build would else number a class that has the name
|
|
18
|
+
// of another, as a DTO of the backend and one of the frontend (`No handler takes AddMessageCommand$1`).
|
|
19
|
+
build: { outDir: process.env.CAITS_OUT_DIR || "dist", rolldownOptions: { output: { keepNames: true } } },
|
|
18
20
|
// The checks write their reports while the development server runs: a report is not a file of the app, and reloads no page. The
|
|
19
21
|
// sandbox of the mutation tests (.stryker-tmp/) copies the project, its tsconfig.json too: the server would empty its cache at each
|
|
20
22
|
// copy, and lose the dependencies that it optimized.
|
package/template/project/AGENTS
CHANGED
|
@@ -13,7 +13,7 @@ A CAITS project (Clean Architecture In TypeScript): a Marko app with a backend a
|
|
|
13
13
|
|
|
14
14
|
- The business lives in bounded contexts: `src/backend/<context>/` and `src/frontend/<context>/`, each with `domain/`, `infra/`, its use cases in `command/` and `query/`, and a `di.ts`. A frontend context has `event/` and `presentation/` too. To add one, copy the shape of an existing one.
|
|
15
15
|
- Domain: entities, value objects, errors, domain events, and ports (`domain/port/<port>/`), each with its contract (`<Port>.contract.ts`).
|
|
16
|
-
- Use cases: one folder per use case, `command/<use-case>/` or `query/<use-case>/`, which holds all of it: its DTOs, its handler and its test, `<use-case>.application.test.ts`, and in the backend its controllers, `<use-case>.<name>.controller.ts` (`<use-case>.http.controller.ts` for HTTP), each with its test, `<use-case>.<name>.presentation.test.ts`. An endpoint lives in one folder, and the name of a test says the layer that it tests. A use case imports nothing from another use case: what they share lives in the domain.
|
|
16
|
+
- Use cases: one folder per use case, `command/<use-case>/` or `query/<use-case>/`, which holds all of it: its DTOs (plain classes), its handler and its test, `<use-case>.application.test.ts`, and in the backend its controllers, `<use-case>.<name>.controller.ts` (`<use-case>.http.controller.ts` for HTTP), each with its test, `<use-case>.<name>.presentation.test.ts`. An endpoint lives in one folder, and the name of a test says the layer that it tests. A use case imports nothing from another use case: what they share lives in the domain. The `execute` of a handler takes the input DTO, typed with its class: CAITS finds the use case by this type.
|
|
17
17
|
- Infra: the adapters of the ports, in `infra/<port>/`. The test of each adapter runs the contract of its port.
|
|
18
18
|
- Frontend: its events, one file each in `event/`, and its presentation, which serves several use cases: the views (`presentation/view/<view>/`), which the routes place; the components, `presentation/component/<component>/`, the parts that the views place; and the store (`presentation/store/`), if the context has use cases. A view and a component take the frontend of their context only, publish its events and read its store. A view imports no other view, and a component no view.
|
|
19
19
|
- The contexts talk through the context shared, `src/backend/shared/` and `src/frontend/shared/`, and their context map, `context-map.ts` beside their di.ts. A handler (in the frontend, also a view or a component) publishes an event of its context; the context map, `share(Event, event => [...])`, turns it into events of shared on the shared bus (the event of the context holds what they need). The context map of each context that hears it, `when(Event, event => [...])`, turns it into input DTOs of the context (backend) or into events of the context (frontend). Each line goes one way, reads only its event, and the lines make no cycle. A context names only itself and shared, never another context: only its context map imports the events of shared.
|
package/src/messaging/Input.ts
DELETED