@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.
@@ -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: Input<unknown>[]; published: EventOf<F>[] };
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: Input<unknown>[] = [];
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: <Output>(input: Input<Output>) => { sent.push(input); return Promise.resolve(undefined as Output); } } });
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
 
@@ -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
- /** The port of an adapter, from the path of the adapter: its name and its path. @type {(target: string) => { name: string; path: string } | null} */
59
- const portOfAdapter = target => {
60
- const adapter = adapterOf(target);
61
- return adapter && { name: adapter.port, path: `${adapter.portModule}.ts` };
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 name of an entity is its class, in PascalCase: entity/{Entity}.ts",
175
- "value-object": "the name of a value object is its class, in PascalCase: value-object/{ValueObject}.ts",
176
- error: "the name of an error ends with Error: error/{Name}Error.ts",
177
- event: "the name of a domain event is its class, in PascalCase: event/{DomainEvent}.ts",
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*Error|event\/[A-Z]\w*|port\/[a-z0-9-]+\/[A-Z]\w*?(\.contract)?)\.ts$/.test(path)) return;
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 stem = kind === "error" && !base.endsWith("Error") ? `${base}Error` : base;
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}${kind === "error" ? `, and its class ${stem}` : ""}.`;
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, ${expected}. Export it as ${expected}.`);
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.filter(name => name !== expected);
227
- const first = exports.find(({ name }) => extras.includes(name ?? "")) ?? exports[0];
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, ${expected}${port}. Move ${listOf(extras)} into ${expected} as a static member, or to a file of its own.`);
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, ${expected}. Export ${expected}.`;
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 [, kind, name] = /^src\/(?:backend|frontend)\/[^/]+\/domain\/(entity|value-object)\/([A-Z]\w*)\.ts$/.exec(path) ?? [];
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. Write \`export class ${name}\`, and put the rules of its values in it.`;
246
- const exported = exports.find(entry => entry.name === name);
247
- return exported ? at(exported.line, `${name}.ts exports ${name} as ${kindOf(exported.kind)}, but ${rule}`) : `${name}.ts exports no class, but ${rule}`;
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
- const name = /^src\/(?:backend|frontend)\/[^/]+\/domain\/error\/([A-Z]\w*Error)\.ts$/.exec(path)?.[1];
256
- if (!name || classes.some(({ exported, name: declared, extends: parent }) => exported && declared !== null && /^\w+Error$/.test(declared) && parent === "Error")) return;
257
- const fix = `Write \`export class ${name} extends Error\`.`;
258
- const declared = classes.find(({ name: other }) => other === name);
259
- if (!declared) {
260
- const others = classes.flatMap(({ name: other }) => other === null ? [] : [other]);
261
- return `${name}.ts holds no class ${name}${others.length > 0 ? `, only ${listOf(others)}` : ""}, but a domain error is an exported class named after its file, which extends Error. ${fix}`;
262
- }
263
- return at(declared.line, declared.exported
264
- ? `${name} extends ${declared.extends ?? "nothing"}, but a domain error is an exported class that extends Error. ${fix}`
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 ?? `describe${port.name}Contract`;
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, @ingenioz-it/caits/messaging/Input and /EventPublisher. The rest is technology: the domain declares a port, the infra implements it, and di.ts binds it.",
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 [, kind, name] = /\/domain\/(entity|value-object)\/(?:.+\/)?(\w+)\.ts$/.exec(file.path) ?? [];
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 port = portOf(target.replace(/\.js$/, ".ts"))?.name ?? fileName(target).replace(/\.(js|ts)$/, "");
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 gives its result as an Output DTO: it makes a `new …Output(…)`.",
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, constructed, classes }, { files }) => {
427
- const [, folder, useCase] = /^(src\/backend\/[^/]+\/(?:command|query)\/([^/]+))\/[^/]+\.handler\.ts$/.exec(path) ?? [];
428
- if (!folder || constructed.some(name => name !== null && /^\w+Output$/.test(name))) return;
429
- const handler = classes.find(({ exported }) => exported)?.name ?? `${pascalOf(useCase)}Handler`;
430
- const output = files.find(file => file.path === `${folder}/${useCase}.dto.ts`)?.exports.find(({ name }) => name !== null && /^\w+Output$/.test(name))?.name;
431
- const head = `${handler} builds no Output DTO, but a backend handler answers with one.`;
432
- return output
433
- ? `${head} Return \`new ${output}(…)\` from execute, as ${useCase}.dto.ts declares it.`
434
- : `${head} Declare \`export class ${pascalOf(useCase)}Output\` in ${useCase}.dto.ts, and return \`new ${pascalOf(useCase)}Output(…)\` from execute.`;
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
- const stem = /^src\/backend\/[^/]+\/(?:command|query)\/[^/]+\/([^/]+)\.controller\.ts$/.exec(path)?.[1];
443
- if (!stem || classes.some(({ exported, name, extends: parent }) => exported && name !== null && /^\w+Controller$/.test(name) && parent === "Controller")) return;
444
- const expected = `${pascalOf(stem)}Controller`;
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: event/{Event}.ts.",
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, named after it. Move it to ${context}/event/${pascalOf(fileName(rest).replace(/\.[^.]+$/, ""))}.ts.`;
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: the event, with the name of the file.",
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 [, context, event] = /^src\/frontend\/([^/]+)\/event\/(\w+)\.ts$/.exec(path) ?? [];
473
- if (!event) return;
474
- const file = `${event}.ts`;
475
- if (exports.length === 0) return `${file} exports nothing, but an event file exports its event. Write \`export class ${event} { readonly type = "${typeOfEvent(context, event)}" as const; }\`.`;
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
- const names = exports.map(({ name }) => name ?? "default");
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.name !== event) return at(exported.line, `${file} exports ${exported.name ?? "a default"}, but an event file exports the event of its name, ${event}. Rename the export ${event}, or the file ${exported.name ?? "default"}.ts.`);
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 [, context, event] = /^src\/frontend\/([^/]+)\/event\/(\w+)\.ts$/.exec(path) ?? [];
492
- const declared = event && classes.find(({ exported, name }) => exported && name === event);
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} in src/frontend/${context}/event/${guess}.ts, with \`readonly type = "${type}" as const\`, and build the event with \`new ${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: ({ path, classes }) => {
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 file = fileName(path);
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 = file.endsWith(".test.ts") ? ".test" : "";
645
- const implementation = kebab(file.replace(/(\.test)?\.[^.]+$/, "")).replace(/[^a-z0-9-]+/g, "-").replace(new RegExp(`-?${folder}$`), "") || "{implementation}";
646
- return `${file} is in infra/${folder}/, but an adapter is named after its implementation and its port: {implementation}-${folder}.ts. Rename it ${implementation}-${folder}${test}.ts.`;
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 = classes.flatMap(({ implements: ports }) => ports)[0];
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/${kebab(port)}/` : ""}.`;
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: ({ path, classes }, { paths }) => {
657
- const adapter = adapterOf(path);
658
- if (!adapter || paths.has(`${adapter.portModule}.ts`)) return;
659
- const context = contextFolderOf(path);
660
- const declared = (/** @type {string} */ port) => paths.has(`${context}/domain/port/${kebab(port)}/${port}.ts`);
661
- const implementer = classes.find(({ implements: ports }) => ports.some(declared));
662
- const port = implementer?.implements.find(declared);
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 (`implements Port`).",
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: ({ path, classes }, { paths }) => {
672
- const adapter = adapterOf(path);
673
- if (!adapter || adapter.test || !paths.has(`${adapter.portModule}.ts`) || classes.some(declared => declared.implements.includes(adapter.port))) return;
674
- const declared = classes.find(({ exported }) => exported) ?? classes[0];
675
- if (!declared) {
676
- return `${fileName(path)} holds no class, but an adapter of infra/${adapter.folder}/ is a class that implements ${adapter.port}. Write \`export class ${pascalOf(fileName(path).replace(/\.[^.]+$/, ""))} implements ${adapter.port}\`.`;
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, `${declared.name} does not implement ${adapter.port}, the port of infra/${adapter.folder}/${others}. Write \`export class ${declared.name} implements ${adapter.port}\`, with \`import type { ${adapter.port} } from "#context/domain/port/${adapter.folder}/${adapter.port}.js"\`.`);
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 || !paths.has(`${adapter.portModule}.ts`) || importedModules(file, root).includes(`${adapter.portModule}.contract`)) return;
689
- const tested = files.find(other => other.path === file.path.replace(/\.test\.ts$/, ".ts"));
690
- const name = tested?.classes.find(({ exported }) => exported)?.name ?? pascalOf(fileName(file.path).replace(/\.test\.ts$/, ""));
691
- const describe = files.find(other => other.path === `${adapter.portModule}.contract.ts`)?.exports.find(({ kind }) => kind === "function")?.name ?? `describe${adapter.port}Contract`;
692
- return `This test does not run the contract of ${adapter.port}, so nothing proves that ${name} does what every adapter of ${adapter.port} does. Import ${describe} from "#context/domain/port/${adapter.folder}/${adapter.port}.contract.js", and call it with a factory of ${name}.`;
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 adapter = adapters.filter(file => file.path.startsWith(`${context}/infra/${port.folder}/`)).flatMap(file => file.classes).find(declared => declared.implements.includes(port.name));
932
- return adapter ? [`bind(${port.name}).to(${adapter.name})`] : [];
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
- * input DTOs extend Input, and its handler publishes events. The presentation, the infra, di.ts and the context map have no list.
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/Input", "@ingenioz-it/caits/messaging/EventPublisher"]
13
+ application: [...domain, "@ingenioz-it/caits/messaging/EventPublisher"]
14
14
  };
@@ -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: { outDir: process.env.CAITS_OUT_DIR || "dist" },
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.
@@ -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.
@@ -1,4 +0,0 @@
1
- /** An input DTO of a context, a command or a query. `Output` is the DTO its handler answers with. */
2
- export abstract class Input<Output> {
3
- declare protected readonly output?: Output;
4
- }