@ingenioz-it/caits 0.5.1 → 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.
Files changed (53) hide show
  1. package/bin/caits.js +17 -12
  2. package/bin/init.js +15 -6
  3. package/package.json +1 -1
  4. package/quality/architecture/adapters/marko.mjs +7 -4
  5. package/quality/architecture/adapters/scss.mjs +29 -8
  6. package/quality/architecture/adapters/typescript.mjs +2 -2
  7. package/quality/architecture/facts.mjs +158 -31
  8. package/quality/architecture/project.mjs +121 -27
  9. package/quality/architecture.mjs +68 -22
  10. package/quality/check.mjs +20 -14
  11. package/quality/dev.mjs +3 -2
  12. package/quality/mutation-suite.mjs +56 -5
  13. package/quality/mutation-summary.mjs +28 -11
  14. package/quality/mutation.mjs +5 -6
  15. package/quality/terminal.mjs +30 -0
  16. package/quality/test-docs/test-docs.mjs +1 -2
  17. package/src/DomainEvent.ts +4 -1
  18. package/src/di/Container.ts +5 -1
  19. package/src/di/createBackend.ts +60 -15
  20. package/src/di/createFrontend.ts +7 -4
  21. package/src/di/declareDependencies.js +25 -5
  22. package/src/di/discover.ts +52 -13
  23. package/src/di/discoverFrontends.ts +30 -11
  24. package/src/di/discovered.ts +3 -3
  25. package/src/di/discoveredFrontends.ts +3 -3
  26. package/src/di/frontend.ts +46 -4
  27. package/src/messaging/BackendEventBus.ts +6 -6
  28. package/src/messaging/ContextMap.ts +30 -7
  29. package/src/messaging/Controller.ts +2 -3
  30. package/src/messaging/EventPublisher.ts +2 -2
  31. package/src/messaging/FrontendContextMap.ts +33 -0
  32. package/src/messaging/InProcessMessageBus.ts +5 -7
  33. package/src/messaging/MessageBus.ts +1 -2
  34. package/src/state/createStore.ts +1 -2
  35. package/src/testing/backend/application.ts +1 -1
  36. package/src/testing/backend/applicationScenario.ts +6 -7
  37. package/src/testing/backend/applications.ts +7 -6
  38. package/src/testing/backend/context-map.ts +5 -2
  39. package/src/testing/backend/integration.ts +1 -1
  40. package/src/testing/frontend/applicationScenario.ts +2 -3
  41. package/src/testing/frontend/applications.ts +3 -3
  42. package/src/testing/frontend/context-map.ts +6 -0
  43. package/src/testing/frontend/frontends.ts +17 -11
  44. package/src/virtual-modules.d.ts +3 -3
  45. package/src/vite.js +18 -13
  46. package/template/caits/architecture.mjs +1040 -187
  47. package/template/caits/eslint.js +18 -0
  48. package/template/caits/imports.mjs +14 -0
  49. package/template/caits/tsconfig.json +1 -1
  50. package/template/caits/vite.ts +7 -3
  51. package/template/project/AGENTS +9 -5
  52. package/template/project/quality/imports.mjs +10 -0
  53. package/src/messaging/Input.ts +0 -4
@@ -1,16 +1,168 @@
1
- import { adapterOf, frontendsOf, importedModules, isCore, isMarko, isNode, isPackage, kebab, kernelPathOf, locate, portOf, typeOfEvent } from "@ingenioz-it/caits/quality/architecture";
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import {
4
+ adapterOf, contextFolderOf, frontendCallsOf, givesSharedTags, importedModules, isListed, isMarko, isPackage, isTestCode, kebab, kernelPathOf,
5
+ LISTS, linesOf, locate, portOf, resolveImport, typeOfEvent
6
+ } from "@ingenioz-it/caits/quality/architecture";
2
7
 
3
- /** @import { Rule } from "@ingenioz-it/caits/quality/architecture" */
8
+ /** @import { File, Located, Location, Rule } from "@ingenioz-it/caits/quality/architecture" */
4
9
 
5
10
  /**
6
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:
7
- * the check gives nothing when the rule holds, else what is wrong, as a message or a list of messages.
8
- * - on "import": each import of the production code, as { file, from, to, specifier, internal, target } (from and to: see locate).
9
- * The rules on imports do not overlap: an import breaks two of them only when it has two problems.
10
- * - on "file": each file, with the project ({ root, files, paths }); `production: true` leaves the test code aside.
11
- * - on "project": the project once; the check gives a list of { file, message }.
12
- * @type {Rule[]}
12
+ * the check gives nothing when the rule holds, else what is wrong and how to fix it, as a message or a list of messages. A message is a
13
+ * text, or `{ line, message }` when the rule points at one construct of the file (an import, an export, a class, a call…).
14
+ * - on "import": each import of the production code, as { file, from, to, specifier, internal, target, line } (from and to: see locate),
15
+ * with the project. The message of an import is at its line. The rules on imports do not overlap: an import breaks two of them only
16
+ * when it has two problems.
17
+ * - on "file": each file, with the project ({ root, files, paths, packages }); `production: true` leaves the test code aside.
18
+ * - on "project": the project once; the check gives a list of { file, line?, message }.
19
+ * The packages of the project are the lists of quality/imports.mjs: the packages that each layer may import.
13
20
  */
21
+
22
+ /** The name of a file, without its folder. @type {(path: string) => string} */
23
+ const fileName = path => path.slice(path.lastIndexOf("/") + 1);
24
+ /** The folder of a file. @type {(path: string) => string} */
25
+ const folderOf = path => path.slice(0, path.lastIndexOf("/"));
26
+ /** A name in PascalCase, from kebab-case, camelCase, snake_case or dots: message-board and messageBoard give MessageBoard. @type {(name: string) => string} */
27
+ const pascalOf = name => name.split(/[^A-Za-z0-9]+/).filter(Boolean).map(word => word[0].toUpperCase() + word.slice(1)).join("");
28
+ /** A name in camelCase: messages gives messages, user-profiles gives userProfiles. @type {(name: string) => string} */
29
+ const camelOf = name => pascalOf(name).replace(/^./, first => first.toLowerCase());
30
+ /** Names in a sentence: a, b and c. @type {(items: string[], last?: string) => string} */
31
+ const listOf = (items, last = "and") => items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} ${last} ${items[items.length - 1]}`;
32
+ /** A message at a line of the file. @type {(line: number, message: string) => Located} */
33
+ const at = (line, message) => ({ line, message });
34
+ /** What a file exports, in a sentence: `exports Text as a type`. @type {(kind: string) => string} */
35
+ const kindOf = kind => ({ interface: "an interface", type: "a type", function: "a function", const: "a constant", let: "a variable", var: "a variable", enum: "an enum", specifier: "a name of a list", default: "a default export" })[kind] ?? `a ${kind}`;
36
+ /** A layer that does something, in a sentence: The domain, This di.ts, The context map. @type {(layer: string) => string} */
37
+ const subjectOf = layer => layer === "di" ? "This di.ts" : layer === "map" ? "The context map" : `The ${layer}`;
38
+ /** A layer that gets something, in a sentence: the domain, di.ts, the context map. @type {(layer: string) => string} */
39
+ const objectOf = layer => layer === "di" ? "di.ts" : layer === "map" ? "the context map" : `the ${layer}`;
40
+ /** What a file of the project is, in a sentence, from its path: a handler, a view, an adapter… @type {(target: string) => string} */
41
+ const fileKindOf = target =>
42
+ /\.handler\.(js|ts)$/.test(target) ? "a handler"
43
+ : /\.controller\.(js|ts)$/.test(target) ? "a controller"
44
+ : /\.dto\.(js|ts)$/.test(target) ? "an input DTO"
45
+ : /\/presentation\/view\//.test(target) ? "a view"
46
+ : /\/presentation\/component\//.test(target) ? "a component"
47
+ : /\/presentation\/store\//.test(target) ? "the store"
48
+ : /\/infra\//.test(target) ? "an adapter"
49
+ : /\/(test\.)?di\.(js|ts)$/.test(target) ? "a di.ts"
50
+ : `a file of ${objectOf(locate(target).layer ?? "project")}`;
51
+ /**
52
+ * An import from a layer of a context to a layer of its context, or of the context shared of its side: the rules on the layers read it.
53
+ * An import of a di.ts is for di-read-by-kernel, an import of another context for contexts-isolated.
54
+ * @type {(from: Location, to: Location) => boolean}
55
+ */
56
+ const withinContext = (from, to) => from.kind === "layer" && to.kind === "layer" && to.side === from.side && to.layer !== "di"
57
+ && (to.context === from.context || to.context === "shared");
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;
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];
87
+ /**
88
+ * The file that an import of a stylesheet loads: Sass adds the extension, and the _ of a partial.
89
+ * @type {(target: string, paths: Set<string>) => string}
90
+ */
91
+ const stylesheetOf = (target, paths) => {
92
+ const partial = `${folderOf(target)}/_${fileName(target)}`;
93
+ return [target, `${target}.scss`, `${partial}.scss`, `${target}.css`, `${target}/_index.scss`, `${target}/index.scss`].find(path => paths.has(path)) ?? target;
94
+ };
95
+ /**
96
+ * The shortcut that leads a file to the target of its import, if one does: #context/ in its context, #shared/ or #frontend-shared/ for
97
+ * the context shared of its side, #backend/ or #frontend/ from a route.
98
+ * @type {(path: string, target: string) => string | undefined}
99
+ */
100
+ const shortcutOf = (path, target) => {
101
+ const where = locate(path);
102
+ if (where.kind === "routes") {
103
+ const [, side, rest] = /^src\/(backend|frontend)\/(.+)$/.exec(target) ?? [];
104
+ return side ? `#${side}/${rest}` : undefined;
105
+ }
106
+ const context = contextFolderOf(path);
107
+ if (context && target.startsWith(`${context}/`)) return `#context/${target.slice(context.length + 1)}`;
108
+ if (where.kind === "layer" && target.startsWith(`src/${where.side}/shared/`)) {
109
+ return `${where.side === "backend" ? "#shared" : "#frontend-shared"}/${target.slice(`src/${where.side}/shared/`.length)}`;
110
+ }
111
+ return undefined;
112
+ };
113
+
114
+ /**
115
+ * What a file of command/ or query/ breaks when it is not a file of a use case, or nothing. A use case holds its files in its folder, each
116
+ * named after the use case: the DTOs, the handler and its test, and in the backend the controllers and their tests.
117
+ * @param {string} path
118
+ * @param {"backend" | "frontend"} side
119
+ * @param {Set<string>} paths
120
+ */
121
+ function useCaseShape(path, side, paths) {
122
+ const [, context, kind, rest] = /^(src\/(?:backend|frontend)\/[^/]+)\/(command|query)\/(.+)$/.exec(path) ?? [];
123
+ const name = fileName(path);
124
+ const parts = rest.split("/");
125
+ if (parts.length === 1) {
126
+ return `${name} is in ${kind}/ but in no folder of a use case: ${kind}/ holds one folder per use case. Move it to ${context}/${kind}/${name.split(".")[0]}/${name}.`;
127
+ }
128
+ const [useCase] = parts;
129
+ const suffix = name.slice(name.indexOf("."));
130
+ const shapes = side === "backend" ? /^\.(handler|dto|application\.test|[a-z0-9-]+\.(controller|presentation\.test))\.ts$/ : /^\.(handler|dto|application\.test)\.ts$/;
131
+ if (parts.length === 2 && shapes.test(suffix)) return `${name} does not start with the name of its use case, ${useCase}. Rename it ${useCase}${suffix}.`;
132
+ if (parts.length === 2 && name.endsWith(".test.ts")) {
133
+ const controllers = [...paths].sort().flatMap(other => new RegExp(`^${context}/${kind}/${useCase}/${useCase}\\.([a-z0-9-]+)\\.controller\\.ts$`).exec(other)?.[1] ?? []);
134
+ const presentation = side === "frontend" ? ""
135
+ : controllers.length === 0 ? `, or ${useCase}.{name}.presentation.test.ts if it tests the controller ${useCase}.{name}.controller.ts`
136
+ : controllers.map(controller => `, or ${useCase}.${controller}.presentation.test.ts if it tests the controller ${useCase}.${controller}.controller.ts`).join("");
137
+ return `${name} is a test of the use case ${useCase}, but its name does not say the layer that it tests. Rename it ${useCase}.application.test.ts${side === "backend" ? " if it tests the handler" : ""}${presentation}.`;
138
+ }
139
+ const controllers = side === "backend" ? `, and its controllers ${useCase}.{name}.controller.ts, each with its test ${useCase}.{name}.presentation.test.ts` : "";
140
+ return `${name} is not a file of a use case: ${kind}/${useCase}/ holds ${useCase}.dto.ts, ${useCase}.handler.ts and ${useCase}.application.test.ts${controllers}. Move its code into ${useCase}.handler.ts, or into ${context}/domain/ if another use case needs it.`;
141
+ }
142
+
143
+ /**
144
+ * The rule of the list of a layer in quality/imports.mjs, for the layers whose import of a package needs no fix of its own: the layer
145
+ * imports only the packages of its list. Without a list, the rule never fires.
146
+ * @param {"presentation" | "infra" | "di" | "map"} layer
147
+ * @returns {Rule}
148
+ */
149
+ function listedPackages(layer) {
150
+ const subject = { presentation: "The presentation layer", infra: "The infra layer", di: "This di.ts", map: "The context map" }[layer];
151
+ const key = LISTS[layer];
152
+ return {
153
+ name: `${layer === "map" ? "context-map" : layer}-imports-only-listed-packages`,
154
+ description: `${subject.replace(/^This/, "A")} imports only the packages, the modules of the kernel and the Node APIs that quality/imports.mjs lists for it, under ${key}. CAITS gives it no list: it imports any package, until the project gives it one.`,
155
+ on: "import",
156
+ check: ({ from, specifier, internal }, { packages }) => {
157
+ const list = packages[key];
158
+ if (internal || from.kind !== "layer" || from.layer !== layer || !list || isListed(specifier, list)) return;
159
+ const where = layer === "presentation" || layer === "infra" ? `the ${layer}` : key;
160
+ return `${subject} imports ${specifier}, ${specifier.startsWith("node:") ? "a Node API" : "a package"} that quality/imports.mjs does not list for ${objectOf(layer)}. Add it to ${where} in quality/imports.mjs, or move the code that needs it to a layer whose list holds it.`;
161
+ }
162
+ };
163
+ }
164
+
165
+ /** @type {Rule[]} */
14
166
  export const rules = [
15
167
  // Kernel: the package @ingenioz-it/caits builds the containers and the controllers
16
168
 
@@ -18,16 +170,20 @@ export const rules = [
18
170
  name: "container-built-by-kernel",
19
171
  description: "Only the kernel makes a Container. A context does not build its dependencies: its di.ts gives the bindings, and the kernel builds them.",
20
172
  on: "file",
21
- check: ({ constructed }) => constructed.includes("Container")
22
- && "only the kernel builds a container: a context says in its di.ts what each token stands for"
173
+ check: ({ path, constructed, lines }) => {
174
+ const context = contextFolderOf(path);
175
+ return constructed.flatMap((name, index) => name !== "Container" ? [] : [at(lines.constructed[index],
176
+ `This file builds a Container, but only the kernel builds the containers, from the di.ts of each context. Remove \`new Container(…)\`, and bind what it holds in ${context ? `${context}/di.ts` : "the di.ts of the context that uses it"}.`)]);
177
+ }
23
178
  },
24
179
  {
25
180
  name: "controllers-built-by-kernel",
26
181
  description: "Only the kernel makes a controller, with its dependencies. The other code asks the kernel for a controller, or sends a request through the router.",
27
182
  on: "file",
28
- check: ({ constructed }) => {
29
- const controller = constructed.find(name => name !== null && /^[A-Z]\w*Controller$/.test(name));
30
- return controller && `only the kernel builds a controller: ask the kernel for it, or send a request through the router (new ${controller}(…))`;
183
+ check: ({ path, constructed, lines }) => {
184
+ const route = locate(path).kind === "routes";
185
+ return constructed.flatMap((name, index) => name === null || !/^[A-Z]\w*Controller$/.test(name) ? [] : [at(lines.constructed[index],
186
+ `This file builds ${name} with \`new\`, but only the kernel builds a controller, with its dependencies. ${route ? `Ask the kernel for it with \`controller(${name})\`, from @ingenioz-it/caits/di/backend.` : "Send the request through the router instead."}`)]);
31
187
  }
32
188
  },
33
189
 
@@ -37,9 +193,40 @@ export const rules = [
37
193
  name: "domain-shape",
38
194
  description: "The domain of a context holds only entities, value objects, errors, domain events and ports. Each kind of file has its folder and its name pattern.",
39
195
  on: "file",
40
- check: ({ path }) => /^src\/(backend|frontend)\/[^/]+\/domain\//.test(path)
41
- && !/^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)
42
- && "domain holds only entity/{Entity}.ts and its test, value-object/{ValueObject}.ts and its test, error/{Name}Error.ts, event/{DomainEvent}.ts, port/{port}/{Port}.ts and port/{port}/{Port}.contract.ts"
196
+ /** The name of the files of each folder of the domain: what it says, and the name to give. */
197
+ kinds: {
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",
202
+ port: "a port is port/{port}/{Port}.ts, beside its contract {Port}.contract.ts"
203
+ },
204
+ /** @this {{ kinds: Record<string, string> }} */
205
+ check({ path }) {
206
+ if (!/^src\/(backend|frontend)\/[^/]+\/domain\//.test(path)
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;
208
+ const [, context, rest] = /^(src\/(?:backend|frontend)\/[^/]+)\/domain\/(.+)$/.exec(path) ?? [];
209
+ const name = fileName(path);
210
+ const parts = rest.split("/");
211
+ const holds = "the domain holds only entity/, value-object/, error/, event/ and port/. Move its code into an entity or a value object, or move the file to the folder of its kind.";
212
+ if (parts.length === 1) return `${name} is at the root of the domain, but ${holds}`;
213
+ const [kind] = parts;
214
+ if (!(kind in this.kinds)) return `${name} is in domain/${parts.slice(0, -1).join("/")}/, but ${holds}`;
215
+ const test = name.endsWith(".test.ts");
216
+ const contract = name.endsWith(".contract.ts");
217
+ // A port is named after its folder, when the folder has the name of a port.
218
+ const base = kind === "port" && parts.length === 3 && /^[a-z0-9-]+$/.test(parts[1]) ? pascalOf(parts[1]) : pascalOf(name.replace(/(\.test|\.contract)?\.[^.]+$/, ""));
219
+ if (kind === "port" && parts.length === 2) {
220
+ return `${name} is in port/, but a port lives in a folder of its own, port/{port}/{Port}.ts. Move it to ${context}/domain/port/${kebab(base)}/${name}.`;
221
+ }
222
+ if (test && kind !== "entity" && kind !== "value-object") {
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" : ""}.`;
224
+ }
225
+ const suggested = `${base}${test ? ".test" : contract ? ".contract" : ""}.ts`;
226
+ const folder = kind === "port" ? `port/${kebab(base)}` : kind;
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}.`;
228
+ return `${name} is in ${folder}/, but ${this.kinds[kind]}. Rename it ${suggested}.`;
229
+ }
43
230
  },
44
231
  {
45
232
  name: "domain-file-exports-one-name",
@@ -50,31 +237,68 @@ export const rules = [
50
237
  check({ path, exports }) {
51
238
  if (!/^src\/(backend|frontend)\/[^/]+\/domain\/.+\.ts$/.test(path) || path.endsWith(".test.ts")) return;
52
239
  const names = exports.filter(({ kind }) => this.declarations.has(kind)).map(({ name }) => name);
53
- return (names.length !== exports.length || new Set(names).size !== 1) && `a domain file exports one name: a port and its token share it (found ${exports.length} exports)`;
240
+ if (names.length === exports.length && new Set(names).size === 1) return;
241
+ const file = fileName(path);
242
+ const unnamed = exports.find(({ kind }) => kind === "default" || kind === "all");
243
+ if (unnamed) {
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 …\`.`);
245
+ }
246
+ const distinct = [...new Set(exports.map(({ name }) => name ?? ""))];
247
+ if (distinct.length > 1) {
248
+ const [kept, ...extras] = distinct;
249
+ const first = exports.find(({ name }) => name === extras[0]) ?? exports[0];
250
+ const port = /\/domain\/port\//.test(path) ? ", which the port and its token share" : "";
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.`);
252
+ }
253
+ const listed = exports.find(({ kind }) => kind === "specifier");
254
+ if (listed) {
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}.`);
256
+ }
257
+ return `${file} exports nothing, but a domain file exports one name. Write \`export\` before its declaration.`;
54
258
  }
55
259
  },
56
260
  {
57
261
  name: "entity-is-class",
58
262
  description: "An entity or a value object is an exported class. The class holds the business rules of its values.",
59
263
  on: "file",
60
- check: ({ path, classes }) => /^src\/(backend|frontend)\/[^/]+\/domain\/(entity|value-object)\/[A-Z]\w*\.ts$/.test(path) && !classes.some(({ exported }) => exported)
61
- && "an entity or a value object is a class"
264
+ check: ({ path, classes, exports }) => {
265
+ const kind = /^src\/(?:backend|frontend)\/[^/]+\/domain\/(entity|value-object)\/[A-Z]\w*\.ts$/.exec(path)?.[1];
266
+ if (!kind || classes.some(({ exported }) => exported)) return;
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.`;
272
+ }
62
273
  },
63
274
  {
64
275
  name: "error-extends-error",
65
276
  description: "A domain error is an exported class …Error that extends Error.",
66
277
  on: "file",
67
- check: ({ path, classes }) => /^src\/(backend|frontend)\/[^/]+\/domain\/error\/[A-Z]\w*Error\.ts$/.test(path)
68
- && !classes.some(({ exported, name, extends: parent }) => exported && name !== null && /^\w+Error$/.test(name) && parent === "Error")
69
- && "an error is a class that extends Error"
278
+ check: ({ path, classes }) => {
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}`);
289
+ }
70
290
  },
71
291
  {
72
292
  name: "port-folder-named-after-port",
73
293
  description: "The folder of a port has the name of the port in kebab-case: the port NoteRepository is in port/note-repository/. Its adapters use the same folder name in the infra.",
74
294
  on: "file",
75
- check: ({ path }) => {
295
+ check: ({ path }, { paths }) => {
76
296
  const port = portOf(path);
77
- return port && kebab(port.name) !== port.folder && `the folder of port ${port.name} is port/${kebab(port.name)}/`;
297
+ if (!port || kebab(port.name) === port.folder) return;
298
+ const context = contextFolderOf(path);
299
+ const folder = kebab(port.name);
300
+ const adapters = [...paths].some(other => other.startsWith(`${context}/infra/${port.folder}/`));
301
+ return `The ${port.contract ? "contract of the port" : "port"} ${port.name} is in port/${port.folder}/, but the folder of a port is its name in kebab-case, port/${folder}/. Move it to ${context}/domain/port/${folder}/${fileName(path)}${adapters ? `, and its adapters from ${context}/infra/${port.folder}/ to ${context}/infra/${folder}/` : ""}.`;
78
302
  }
79
303
  },
80
304
  {
@@ -83,79 +307,203 @@ export const rules = [
83
307
  on: "file",
84
308
  check: ({ path }, { paths }) => {
85
309
  const port = portOf(path);
86
- return port && !port.contract && !paths.has(`${port.module}.contract.ts`) && `a port has a contract: domain/port/${port.folder}/${port.name}.contract.ts`;
310
+ if (!port || port.contract || paths.has(`${port.module}.contract.ts`)) return;
311
+ const infra = `${contextFolderOf(path)}/infra/${port.folder}/`;
312
+ const tests = [...paths].filter(other => other.startsWith(infra) && other.endsWith(".test.ts")).map(fileName).sort();
313
+ return `The port ${port.name} has no contract, so nothing says what each of its adapters must do. Write ${port.module}.contract.ts, and run it in the test of each adapter in ${infra}${tests.length > 0 ? ` (${tests.join(", ")})` : ""}.`;
87
314
  }
88
315
  },
89
316
  {
90
317
  name: "contract-next-to-port",
91
318
  description: "A contract test is in domain/port/{port}/, next to the port that it describes.",
92
319
  on: "file",
93
- check: ({ path }) => path.endsWith(".contract.ts") && !portOf(path) && "a contract test describes a port: it lives in domain/port/, next to the port"
320
+ check: ({ path }, { paths }) => {
321
+ if (!path.endsWith(".contract.ts") || portOf(path)) return;
322
+ const file = fileName(path);
323
+ const name = file.slice(0, -".contract.ts".length);
324
+ const ports = [...paths].filter(other => portOf(other)?.name === name && !portOf(other)?.contract).sort();
325
+ if (ports.length === 1) return `${file} is the contract of ${name}, but it is not next to its port. Move it to ${folderOf(ports[0])}/${file}, beside ${ports[0]}.`;
326
+ if (ports.length > 1) return `${file} is the contract of ${name}, but it is not next to its port. Move it next to the port that it describes: ${ports.map(port => `${folderOf(port)}/${file}`).join(", or ")}.`;
327
+ return `${file} is a contract, but no port ${name} exists: a contract lives in domain/port/${kebab(name)}/, beside the port that it describes. Write the port ${name} in the domain/port/${kebab(name)}/ of its context, and move the contract beside it.`;
328
+ }
94
329
  },
95
330
  {
96
331
  name: "contract-runs-in-adapter-test",
97
332
  description: "The test of at least one adapter runs each contract. A contract that no test runs checks nothing.",
98
333
  on: "file",
99
- check: ({ path }, { files, root }) => {
100
- const port = portOf(path);
101
- return port?.contract && !files.some(file => adapterOf(file.path)?.test && importedModules(file, root).includes(`${port.module}.contract`))
102
- && "a contract is run by the test of at least one adapter (in the context's infra)";
334
+ check: (file, { files, paths, root }) => {
335
+ const port = portOf(file.path);
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";
338
+ const infra = `${contextFolderOf(file.path)}/infra/${port.folder}/`;
339
+ const adapters = [...paths].filter(other => other.startsWith(infra)).map(fileName).sort();
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
+ const tests = adapters.filter(name => name.endsWith(".test.ts"));
342
+ return `No test runs the contract of ${port.name}, so it checks nothing. Call ${describe} in the test of each adapter in ${infra} (${(tests.length > 0 ? tests : adapters).join(", ")}).`;
103
343
  }
104
344
  },
105
345
  {
106
- name: "core-without-node",
107
- description: "The domain and the application do not import Node APIs (node:*). Node APIs are technology: they stay in the infra.",
346
+ name: "domain-imports-only-listed-packages",
347
+ description: "The domain imports only the packages, the modules of the kernel and the Node APIs that quality/imports.mjs lists for it. CAITS lists the primitives of the kernel: @ingenioz-it/caits/Result, /Token and /DomainEvent. The rest is technology: the domain declares a port, and the infra implements it.",
108
348
  on: "import",
109
- check: ({ from, specifier }) => isCore(from) && isNode(specifier) && "domain and application stay free of Node APIs"
349
+ /** The fix for a module of the kernel that the list does not hold, by the folder of the module. */
350
+ kernel: {
351
+ messaging: "Let a handler publish the events, and keep the domain free of buses.",
352
+ state: "Keep the state in the store of the presentation, and let the domain give values.",
353
+ di: "Declare what the domain needs as a port, and let di.ts bind it.",
354
+ other: "Move the code that needs it out of the domain."
355
+ },
356
+ /** @this {{ kernel: Record<string, string> }} */
357
+ check({ file, from, specifier, internal }, { packages }) {
358
+ const list = packages.domain;
359
+ if (internal || from.layer !== "domain" || !list || isListed(specifier, list)) return;
360
+ const kernel = kernelPathOf(specifier);
361
+ if (kernel !== null) return `The domain layer imports ${specifier}, a module of the kernel that quality/imports.mjs does not list for the domain. ${this.kernel[kernel.split("/")[0]] ?? this.kernel.other}`;
362
+ return `The domain layer imports ${specifier}, ${specifier.startsWith("node:") ? "a Node API" : "a package"} that quality/imports.mjs does not list for the domain. Declare what it needs as a port in ${contextFolderOf(file.path)}/domain/port/, and implement it in the infra; or, to use ${specifier} in the domain anyway, add it to the domain in quality/imports.mjs.`;
363
+ }
110
364
  },
111
365
  {
112
- name: "domain-uses-kernel-primitives",
113
- description: "The domain imports only the files at the root of the kernel (@ingenioz-it/caits/Result, /Token, /DomainEvent). It does not import the buses, the store or the DI of the kernel.",
366
+ name: "application-imports-only-listed-packages",
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.",
114
368
  on: "import",
115
- check: ({ from, specifier }) => from.layer === "domain" && kernelPathOf(specifier)?.includes("/")
116
- && "the domain imports only the kernel's domain primitives, never buses or the store"
369
+ /** The fix for a module of the kernel that the list does not hold, by the folder of the module. */
370
+ kernel: {
371
+ state: "Keep the state in the store of the presentation, and publish an event that the store hears.",
372
+ di: "Depend on a port of the domain, and let di.ts bind its adapter.",
373
+ other: "Move the code that needs it into the presentation or the infra."
374
+ },
375
+ /** @this {{ kernel: Record<string, string> }} */
376
+ check({ file, from, specifier, internal }, { packages }) {
377
+ const list = packages.application;
378
+ if (internal || from.layer !== "application" || !list || isListed(specifier, list)) return;
379
+ const kernel = kernelPathOf(specifier);
380
+ if (kernel !== null) return `The application layer imports ${specifier}, a module of the kernel that quality/imports.mjs does not list for the application. ${this.kernel[kernel.split("/")[0]] ?? this.kernel.other}`;
381
+ const context = contextFolderOf(file.path);
382
+ return `The application layer imports ${specifier}, ${specifier.startsWith("node:") ? "a Node API" : "a package"} that quality/imports.mjs does not list for the application. Declare what it needs as a port in ${context}/domain/port/, implement it in the infra, and bind the adapter in ${context}/di.ts; or, to use ${specifier} in the application anyway, add it to the application in quality/imports.mjs.`;
383
+ }
117
384
  },
385
+ listedPackages("presentation"),
386
+ listedPackages("infra"),
387
+ listedPackages("di"),
388
+ listedPackages("map"),
118
389
  {
119
390
  name: "entity-without-port",
120
- description: "An entity or a value object does not import a port. It holds the business rules; the application calls the ports.",
391
+ description: "An entity or a value object does not import a port. It holds the business rules; the handlers call the ports.",
121
392
  on: "import",
122
- check: ({ file, from, target }) => from.layer === "domain" && /\/domain\/(entity|value-object)\//.test(file.path) && /\/domain\/port\//.test(target)
123
- && "an entity or a value object holds business rules: it never talks to a port, the application orchestrates"
393
+ check: ({ file, from, specifier, target }) => {
394
+ const kind = /\/domain\/(entity|value-object)\/(?:.+\/)?\w+\.ts$/.exec(file.path)?.[1];
395
+ if (from.layer !== "domain" || !kind || !/\/domain\/port\//.test(target)) return;
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)$/, "");
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.`;
399
+ }
124
400
  },
125
401
 
126
- // Application: the use cases, and the events of the frontend
402
+ // Use cases: one folder each, in command/ or query/, with its DTOs, its handler, its controllers and their tests
127
403
 
128
404
  {
129
- name: "backend-application-shape",
130
- description: "The application of a backend context holds only use cases. Each use case has its folder, command/{usecase}/ or query/{usecase}/, with its handler, its DTOs and its test.",
405
+ name: "backend-use-case-shape",
406
+ description: "A use case of a backend context has its folder, command/{usecase}/ or query/{usecase}/, which holds all of it: its DTOs, its handler and its test, {usecase}.application.test.ts, and its controllers, {usecase}.{name}.controller.ts, each with its test, {usecase}.{name}.presentation.test.ts. An endpoint lives in one folder, and the name of each test says the layer that it tests.",
407
+ on: "file",
408
+ check: ({ path }, { paths }) => /^src\/backend\/[^/]+\/(command|query)\//.test(path)
409
+ && !/^src\/backend\/[^/]+\/(command|query)\/([a-z0-9-]+)\/\2(\.(handler|dto|application\.test)|\.[a-z0-9-]+\.(controller|presentation\.test))\.ts$/.test(path)
410
+ && useCaseShape(path, "backend", paths)
411
+ },
412
+ {
413
+ name: "frontend-use-case-shape",
414
+ description: "A use case of a frontend context has its folder, command/{usecase}/ or query/{usecase}/, with its DTOs, its handler and its test, {usecase}.application.test.ts. The store sends its input DTOs: a use case of the frontend has no controller.",
131
415
  on: "file",
132
- check: ({ path }) => /^src\/backend\/[^/]+\/application\//.test(path) && !/^src\/backend\/[^/]+\/application\/(command|query)\/([a-z0-9-]+)\/\2\.(handler|dto|test)\.ts$/.test(path)
133
- && "application holds only {usecase}.handler.ts, {usecase}.dto.ts and {usecase}.test.ts in command/{usecase}/ or query/{usecase}/"
416
+ check: ({ path, classes }, { paths }) => {
417
+ if (!/^src\/frontend\/[^/]+\/(command|query)\//.test(path) || /^src\/frontend\/[^/]+\/(command|query)\/([a-z0-9-]+)\/\2\.(handler|dto|application\.test)\.ts$/.test(path)) return;
418
+ const context = contextFolderOf(path);
419
+ const name = fileName(path);
420
+ if (name.endsWith(".controller.ts")) {
421
+ const useCase = /\/(?:command|query)\/([^/]+)\/[^/]+$/.exec(path)?.[1] ?? name.split(".")[0];
422
+ const store = [...paths].sort().find(other => other.startsWith(`${context}/presentation/store/`) && other.endsWith("Store.ts")) ?? "the store of the context";
423
+ return `${name} is a controller, but a use case of the frontend has none: the store sends its DTOs to the message bus. Remove it, and let ${store} send the DTO of ${useCase}.`;
424
+ }
425
+ if (classes.some(({ properties }) => properties.some(({ name: property }) => property === "type"))) {
426
+ return `${name} is an event, but the events of a frontend context live in event/. Move it to ${context}/event/${name}.`;
427
+ }
428
+ return useCaseShape(path, "frontend", paths);
429
+ }
430
+ },
431
+ {
432
+ name: "use-cases-isolated",
433
+ description: "A use case imports nothing from another use case, of its context or of the context shared: each one holds all that it needs, and what two use cases share lives in the domain. Thus a use case changes, and goes, alone.",
434
+ on: "import",
435
+ check: ({ file, from, to, target }) => {
436
+ if (!from.useCase || !to.useCase || to.side !== from.side || (to.context !== from.context && to.context !== "shared")
437
+ || (to.context === from.context && to.useCase === from.useCase)) return;
438
+ const context = contextFolderOf(file.path);
439
+ const head = `The use case ${from.useCase} imports ${target}, a file of the use case ${to.useCase}${to.context === from.context ? "" : " of shared"}, but a use case imports nothing from another one: each changes, and goes, alone.`;
440
+ if (to.context === from.context && file.path.endsWith(".controller.ts") && /\.dto\.(js|ts)$/.test(target)) {
441
+ return `${head} Move this controller into ${context}/${to.useCase}/, since a controller sends the DTO of its own use case.`;
442
+ }
443
+ return `${head} Move what they share into ${context}/domain/.`;
444
+ }
134
445
  },
135
446
  {
136
447
  name: "handler-answers-output",
137
- 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>`).",
138
449
  on: "file",
139
- check: ({ path, constructed }) => /^src\/backend\/[^/]+\/application\/.+\.handler\.ts$/.test(path) && !constructed.some(name => name !== null && /^\w+Output$/.test(name))
140
- && "a handler answers with `new SomethingOutput(…)`"
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}`);
460
+ }
141
461
  },
142
462
  {
143
- name: "frontend-application-shape",
144
- description: "The application of a frontend context holds its use cases, as in the backend, and its events, one file per event in event/.",
463
+ name: "controller-extends-controller",
464
+ description: "A controller is an exported class …Controller that extends the Controller of the kernel.",
145
465
  on: "file",
146
- check: ({ path }) => /^src\/frontend\/[^/]+\/application\//.test(path)
147
- && !/^src\/frontend\/[^/]+\/application\/((command|query)\/([a-z0-9-]+)\/\3\.(handler|dto|test)|event\/[A-Z]\w*)\.ts$/.test(path)
148
- && "a frontend application holds only {usecase}.handler.ts, {usecase}.dto.ts and {usecase}.test.ts in command/{usecase}/ or query/{usecase}/ and event/{Event}.ts"
466
+ check: ({ path, classes }) => {
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";
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.`;
472
+ if (!declared) return `${fileName(path)} holds no exported class …Controller, but ${rule}`;
473
+ const name = declared.name ?? "Its class";
474
+ if (!declared.exported) return at(declared.line, `${name} is not exported, but ${rule}`);
475
+ if (declared.extends !== "Controller") return at(declared.line, `${name} extends ${declared.extends ?? "nothing"}, but ${rule}`);
476
+ return at(declared.line, `${name} does not end with Controller, but ${rule}`);
477
+ }
478
+ },
479
+
480
+ // Events of the frontend: what its views, its handlers and its context map publish
481
+
482
+ {
483
+ name: "frontend-event-shape",
484
+ description: "The events of a frontend context are in event/, one file per event, at its root.",
485
+ on: "file",
486
+ check: ({ path }) => {
487
+ const [, context, rest] = /^(src\/frontend\/[^/]+)\/event\/(.+)$/.exec(path) ?? [];
488
+ if (!rest || /^[A-Z]\w*\.ts$/.test(rest)) return;
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.`;
490
+ }
149
491
  },
150
492
  {
151
493
  name: "event-file-exports-its-event",
152
- 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.",
153
495
  on: "file",
154
496
  check: ({ path, exports }) => {
155
- const event = /^src\/frontend\/[^/]+\/application\/event\/(\w+)\.ts$/.exec(path)?.[1];
156
- if (!event) return;
157
- if (exports.length !== 1) return `an event file has exactly one export (found ${exports.length})`;
158
- return (exports[0].name !== event || !["class", "type"].includes(exports[0].kind)) && `event/${event}.ts exports ${event}`;
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");
502
+ if (exports.length > 1) {
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.`);
504
+ }
505
+ const [exported] = exports;
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]}\`.`);
159
507
  }
160
508
  },
161
509
  {
@@ -163,79 +511,144 @@ export const rules = [
163
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.",
164
512
  on: "file",
165
513
  check: ({ path, classes }) => {
166
- const [, context, event] = /^src\/frontend\/([^/]+)\/application\/event\/(\w+)\.ts$/.exec(path) ?? [];
167
- const declared = event && classes.find(({ exported, name }) => exported && name === event);
168
- 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;
169
518
  const type = typeOfEvent(context, event);
519
+ const write = `\`readonly type = "${type}" as const\``;
170
520
  const property = declared.properties.find(({ name }) => name === "type");
171
- return (!property?.readonly || !property.asConst || property.value !== type) && `the type of ${event} is "${type}": its context, then its name without the context`;
521
+ if (!property) return at(declared.line, `${event} has no type, so the store cannot tell it from the other events. Add ${write}.`);
522
+ if (property.value !== type) {
523
+ const found = property.value === null ? "not a string" : `"${property.value}"`;
524
+ return at(property.line, `The type of ${event} is ${found}, but it must be "${type}": the name of its context, then the name of the event without the context, in kebab-case. Write ${write}.`);
525
+ }
526
+ if (!property.readonly || !property.asConst) {
527
+ const missing = !property.readonly && !property.asConst ? "neither readonly nor as const" : !property.readonly ? "not readonly" : "not as const";
528
+ return at(property.line, `The type of ${event} is ${missing}, so the store cannot tell the events apart. Write ${write}.`);
529
+ }
172
530
  }
173
531
  },
174
532
 
175
- // Presentation: the controllers of the backend, the views and the stores of the frontend
533
+ // Presentation: the views, the components and the store of the frontend; the controllers live in their use cases
176
534
 
177
- {
178
- name: "backend-presentation-shape",
179
- description: "The presentation of a backend context holds only controllers, each in its folder {name}/ with its test.",
180
- on: "file",
181
- check: ({ path }) => /^src\/backend\/[^/]+\/presentation\//.test(path) && !/^src\/backend\/[^/]+\/presentation\/([a-z0-9-]+)\/\1\.(controller|test)\.ts$/.test(path)
182
- && "presentation holds only {name}/{name}.controller.ts and {name}/{name}.test.ts"
183
- },
184
- {
185
- name: "controller-extends-controller",
186
- description: "A controller is an exported class …Controller that extends the Controller of the kernel.",
187
- on: "file",
188
- check: ({ path, classes }) => /^src\/backend\/[^/]+\/presentation\/.+\.controller\.ts$/.test(path)
189
- && !classes.some(({ exported, name, extends: parent }) => exported && name !== null && /^\w+Controller$/.test(name) && parent === "Controller")
190
- && "a controller is an exported class …Controller that extends the kernel's Controller"
191
- },
192
535
  {
193
536
  name: "frontend-presentation-shape",
194
- description: "The presentation of a frontend context holds only its views (one folder per view), its store and the type of its frontend. The pages are in the routes.",
537
+ description: "The presentation of a frontend context holds only its views (one folder per view), its components (one folder per component: what its views share), its store and the type of its frontend. The pages are in the routes.",
195
538
  on: "file",
196
- check: ({ path }) => /^src\/frontend\/[^/]+\/presentation\//.test(path)
197
- && !/^src\/frontend\/[^/]+\/presentation\/(view\/[a-z0-9-]+\/[^/]+|store\/[a-z]\w*Store\.ts|[A-Z]\w*Frontend\.ts)$/.test(path)
198
- && "a frontend presentation holds only view/{view}/ with the files that only this View uses, store/{context}Store.ts, {Context}Frontend.ts: the pages live in the routes"
539
+ check: ({ path }, { files, root }) => {
540
+ const [, context, name, rest] = /^(src\/frontend\/([^/]+))\/presentation\/(.+)$/.exec(path) ?? [];
541
+ if (!rest || /^((view|component)\/[a-z0-9-]+\/[^/]+|store\/[a-z]\w*Store\.ts|[A-Z]\w*Frontend\.ts)$/.test(rest)) return;
542
+ const file = fileName(path);
543
+ const frontend = `${pascalOf(name)}Frontend.ts`;
544
+ const parts = rest.split("/");
545
+ if (parts.length === 1) {
546
+ const module = path.replace(/\.(js|ts)$/, "");
547
+ const views = [...new Set(files.filter(other => importedModules(other, root).includes(module))
548
+ .flatMap(other => /\/presentation\/view\/([a-z0-9-]+)\//.exec(other.path)?.[1] ?? []))];
549
+ return `${file} is at the root of the presentation, which holds only ${frontend} besides view/, component/ and store/. Move it into the folder of the view that uses it${views.length === 1 ? `, view/${views[0]}/` : ""}, or into a component of component/ if several views share it.`;
550
+ }
551
+ const [folder] = parts;
552
+ if (folder === "store") return `${file} is in store/, but the store of the context is store/${camelOf(name)}Store.ts. Rename it ${camelOf(name)}Store.ts, or move its code into the store.`;
553
+ if ((folder === "view" || folder === "component") && parts.length === 2) {
554
+ return `${file} is in ${folder}/ but in no folder of a ${folder}: ${folder}/ holds one folder each. Move it to ${folder}/${kebab(pascalOf(file.replace(/\..*$/, "")))}/${file}.`;
555
+ }
556
+ if (folder === "view" || folder === "component") {
557
+ return `${rest} is not in a folder of a ${folder}: ${folder}/ holds one folder per ${folder}, named in kebab-case. Move it to ${context}/presentation/${folder}/${kebab(pascalOf(parts[1]))}/${file}.`;
558
+ }
559
+ return `${rest} is in presentation/${parts.slice(0, -1).join("/")}/, but the presentation holds only view/, component/, store/ and ${frontend}, and the pages live in src/routes/_frontend/. Move a page to src/routes/_frontend/, and the rest into a view or a component.`;
560
+ }
199
561
  },
200
562
  {
201
563
  name: "zod-in-presentation",
202
- description: "Only the presentation imports zod. zod checks the data that comes from HTTP; the other layers get typed values.",
564
+ description: "In a layer without a list in quality/imports.mjs, only the presentation (the controllers of the use cases, and the presentation of the frontend) and the infra of the frontend import zod. zod checks the data that comes in; the other layers get typed values. In a layer with a list, the list says it.",
203
565
  on: "import",
204
- check: ({ from, specifier }) => isPackage(specifier, "zod") && (isCore(from) || (from.kind === "layer" && from.layer !== "presentation"))
205
- && "zod stays at the transport boundary (presentation)"
566
+ check: ({ from, specifier }, { packages }) => {
567
+ if (!isPackage(specifier, "zod") || from.kind !== "layer" || packages[LISTS[from.layer]] || from.layer === "presentation" || (from.side === "frontend" && from.layer === "infra")) return;
568
+ return from.side === "backend"
569
+ ? `${subjectOf(from.layer)} imports zod, but in the backend only the controllers check data with zod. Check the data in the controller that receives it, and give ${objectOf(from.layer)} typed values.`
570
+ : `${subjectOf(from.layer)} imports zod, but in the frontend only the presentation and the adapters of the infra check data with zod. Check the data where it comes in, and give ${objectOf(from.layer)} typed values.`;
571
+ }
206
572
  },
207
573
  {
208
574
  name: "marko-in-frontend-presentation",
209
- description: "Only the frontend presentation and the routes import Marko. The other layers stay free of the view technology.",
575
+ description: "In a layer without a list in quality/imports.mjs, only the frontend presentation imports Marko: the routes import it too. The other layers stay free of the view technology. In a layer with a list, the list says it.",
210
576
  on: "import",
211
- check: ({ from, specifier }) => isMarko(specifier) && from.kind === "layer" && !(from.side === "frontend" && from.layer === "presentation")
212
- && "Marko lives in frontend presentation and routes only"
577
+ check: ({ file, from, to, specifier }, { packages }) => {
578
+ if (!isMarko(specifier) || from.kind !== "layer" || packages[LISTS[from.layer]] || (from.side === "frontend" && from.layer === "presentation")) return;
579
+ // A template of the frontend presentation: the rules on the layers and the sides say what is wrong.
580
+ if (to.kind === "layer" && to.side === "frontend" && to.layer === "presentation") return;
581
+ return from.side === "frontend"
582
+ ? `${subjectOf(from.layer)} imports ${specifier}, but only the presentation of the frontend and the routes use Marko. Move the template into a view of ${contextFolderOf(file.path)}/presentation/view/, and give the view its data as input.`
583
+ : `The backend imports ${specifier}, but only the presentation of the frontend and the routes use Marko. Move the template into a view of a frontend context, which gets its data from the HTTP API of the backend.`;
584
+ }
213
585
  },
214
586
  {
215
587
  name: "dtos-sent-by-store",
216
- description: "In the frontend, only the store and the application import the input DTOs. A view publishes events; the store turns them into DTOs and sends them to the message bus.",
588
+ description: "In the frontend, only the store and the application (the use cases and the events) import the input DTOs. A view publishes events; the store turns them into DTOs and sends them to the message bus.",
217
589
  on: "import",
218
- check: ({ file, target }) => /^src\/frontend\/[^/]+\/application\/.+\.dto\.(js|ts)$/.test(target) && !/^src\/frontend\/[^/]+\/(presentation\/store|application)\//.test(file.path)
219
- && "in the frontend, only the store sends input DTOs to the message bus: a view publishes events, and the store turns them into DTOs"
590
+ check: ({ file, specifier, target }, { paths }) => {
591
+ const [, context, useCase] = /^(src\/frontend\/[^/]+)\/((?:command|query)\/[^/]+)\/.+\.dto\.(js|ts)$/.exec(target) ?? [];
592
+ if (!context || /^src\/frontend\/[^/]+\/(presentation\/store|command|query|event)\//.test(file.path)) return;
593
+ const names = file.imported.filter(({ from }) => from === specifier).map(({ name }) => name);
594
+ const store = [...paths].sort().find(path => path.startsWith(`${context}/presentation/store/`) && path.endsWith("Store.ts")) ?? "the store of the context";
595
+ return `This file imports ${names.length > 0 ? `${listOf(names)} (${target})` : target}, ${names.length > 1 ? "input DTOs" : "an input DTO"} of the use case ${useCase}, but in the frontend only the store sends input DTOs. Publish an event of ${context.split("/")[2]} instead, and let ${store} turn it into ${names.length > 0 ? listOf(names, "or") : "its DTO"}.`;
596
+ }
220
597
  },
221
598
  {
222
599
  name: "events-built-with-new",
223
600
  description: "In the frontend, the code makes an event with `new SomeEvent(…)`, not as an object literal `{ type: \"…\" }`. The class gives the correct type and the correct data.",
224
601
  on: "file",
225
- check: ({ path, objectTypes }) => {
226
- const type = /^src\/(frontend|routes\/_frontend)\//.test(path) && objectTypes.find(value => /^[a-z]+\.[a-z-]+$/.test(value));
227
- return type && `an event of the frontend is built with \`new SomeEvent(…)\`, not as an object literal ({ type: "${type}" })`;
602
+ check: ({ path, objectTypes, lines }, { files }) => {
603
+ if (!/^src\/(frontend|routes\/_frontend)\//.test(path)) return;
604
+ return objectTypes.flatMap((type, index) => {
605
+ if (!/^[a-z]+\.[a-z-]+$/.test(type)) return [];
606
+ const head = `This file builds the event "${type}" as an object literal, so nothing checks its type or its data.`;
607
+ const event = files.flatMap(file => /^src\/frontend\/[^/]+\/event\//.test(file.path)
608
+ ? file.classes.filter(({ properties }) => properties.some(({ name, value }) => name === "type" && value === type)).map(({ name }) => ({ name, path: file.path }))
609
+ : [])[0];
610
+ if (event) return [at(lines.objectTypes[index], `${head} Build it with \`new ${event.name}(…)\`, from ${event.path}.`)];
611
+ const [context, name] = type.split(".");
612
+ const guess = `${pascalOf(context)}${pascalOf(name)}`;
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}(…)\`.`)];
614
+ });
228
615
  }
229
616
  },
230
617
  {
231
618
  name: "views-take-own-frontend",
232
- description: "A view takes only the frontend of its own context, with frontendOf. The contexts of the frontend do not share their frontends.",
619
+ description: "A view or a component takes only the frontend of its own context, with frontendOf. The contexts of the frontend do not share their frontends, not even the one of shared: to tell the other contexts, a view publishes an event of its context, and the context map shares it as an event of shared.",
233
620
  on: "file",
234
621
  production: true,
235
622
  check: file => {
236
623
  const where = locate(file.path);
237
- return where.kind === "layer" && where.side === "frontend"
238
- && frontendsOf(file).filter(context => context !== where.context).map(context => `a context takes only its own frontend (${where.context}), not the one of ${context}`);
624
+ if (where.kind !== "layer" || where.side !== "frontend") return;
625
+ const kind = /\/presentation\/view\//.test(file.path) ? "view" : /\/presentation\/component\//.test(file.path) ? "component" : "file";
626
+ const head = (/** @type {string} */ other) => `This ${kind} of ${where.context} takes the frontend of ${other}, but a view or a component takes only the frontend of its own context.`;
627
+ return frontendCallsOf(file).filter(({ context }) => context !== where.context).map(({ context: other, line }) => at(line, where.context === "shared"
628
+ ? `${head(other)} Publish an event of shared instead: the context map of ${other} turns it into an event of its own.`
629
+ : `${head(other)} Publish an event of ${where.context} instead, and share it as an event of shared in ${contextFolderOf(file.path)}/context-map.ts${other === "shared" ? "" : `: the context map of ${other} turns it into an event of its own`}.`));
630
+ }
631
+ },
632
+ {
633
+ name: "components-import-no-view",
634
+ description: "A component of the frontend (presentation/component/) is a part of the views: it imports no view, of its context or of the context shared. It may take the frontend of its context, publish its events and read its store, as a view does.",
635
+ on: "import",
636
+ check: ({ file, target }) => {
637
+ const component = /^src\/frontend\/[^/]+\/presentation\/component\/([^/]+)\//.exec(file.path)?.[1];
638
+ const view = /^src\/frontend\/[^/]+\/presentation\/view\/([^/]+)\//.exec(target)?.[1];
639
+ return component && view
640
+ && `The component ${component} imports ${target}, a file of the view ${view}, but a component imports no view: the views place the components. Move what they both need into the component ${component}, and let a route place the view ${view}.`;
641
+ }
642
+ },
643
+ {
644
+ name: "views-import-no-view",
645
+ description: "A view of the frontend imports nothing from another view. What views share is a component (presentation/component/); the routes place the views side by side.",
646
+ on: "import",
647
+ check: ({ file, target }) => {
648
+ const view = /^(src\/frontend\/[^/]+)\/presentation\/view\/([^/]+)\//.exec(file.path);
649
+ const other = /^(src\/frontend\/[^/]+)\/presentation\/view\/([^/]+)\//.exec(target);
650
+ return view && other && view[1] === other[1] && view[2] !== other[2]
651
+ && `The view ${view[2]} imports ${target}, a file of the view ${other[2]}, but a view imports nothing from another view. Move what they share into a component of ${view[1]}/presentation/component/, and let the route place the two views side by side.`;
239
652
  }
240
653
  },
241
654
 
@@ -245,36 +658,67 @@ export const rules = [
245
658
  name: "backend-infra-shape",
246
659
  description: "The infra of a backend context holds only adapters: infra/{port}/{implementation}-{port}.ts and their tests.",
247
660
  on: "file",
248
- check: ({ path }) => /^src\/backend\/[^/]+\/infra\//.test(path) && !/^src\/backend\/[^/]+\/infra\/([a-z0-9-]+)\/[a-z0-9-]+-\1(\.test)?\.ts$/.test(path)
249
- && "infra holds only {port}/{implementation}-{port}.ts and its test"
661
+ check: (file, { root }) => {
662
+ const [, context, rest] = /^(src\/backend\/[^/]+)\/infra\/(.+)$/.exec(file.path) ?? [];
663
+ if (!rest || /^([a-z0-9-]+)\/[a-z0-9-]+-\1(\.test)?\.ts$/.test(rest)) return;
664
+ const parts = rest.split("/");
665
+ const name = fileName(file.path);
666
+ if (parts.length === 2 && /^[a-z0-9-]+$/.test(parts[0])) {
667
+ const [folder] = parts;
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.`;
671
+ }
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}/` : ""}.`;
674
+ }
250
675
  },
251
676
  {
252
677
  name: "infra-folder-implements-port",
253
678
  description: "Each folder of infra/ is for a port that the domain declares: infra/{port}/ goes with domain/port/{port}/.",
254
679
  on: "file",
255
- check: ({ path }, { paths }) => {
256
- const adapter = adapterOf(path);
257
- return adapter && !paths.has(`${adapter.portModule}.ts`) && `infra/${adapter.folder}/ implements a port: the domain must declare port/${adapter.port}`;
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.`;
258
687
  }
259
688
  },
260
689
  {
261
690
  name: "adapter-implements-port",
262
- 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}/.",
263
692
  on: "file",
264
- check: ({ path, classes }, { paths }) => {
265
- const adapter = adapterOf(path);
266
- return adapter && !adapter.test && paths.has(`${adapter.portModule}.ts`) && !classes.some(declared => declared.implements.includes(adapter.port))
267
- && `an adapter of infra/${adapter.folder}/ implements the port ${adapter.port}`;
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}.`);
704
+ const others = declared.implements.length > 0 ? `; it implements ${listOf(declared.implements)}` : "";
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}.`);
268
706
  }
269
707
  },
270
708
  {
271
709
  name: "adapter-test-runs-contract",
272
710
  description: "The test of an adapter runs the contract of its port. Thus all the adapters of a port do the same things.",
273
711
  on: "file",
274
- check: (file, { paths, root }) => {
275
- const adapter = adapterOf(file.path);
276
- return adapter?.test && paths.has(`${adapter.portModule}.ts`) && !importedModules(file, root).includes(`${adapter.portModule}.contract`)
277
- && `the test of an adapter runs the contract of its port (${adapter.port}.contract)`;
712
+ check: (file, { files, paths, root }) => {
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"}.`;
278
722
  }
279
723
  },
280
724
 
@@ -286,110 +730,449 @@ export const rules = [
286
730
  on: "file",
287
731
  methods: new Set(["to", "toValue", "toFactory", "with"]),
288
732
  /** @this {{ methods: Set<string> }} */
289
- check({ path, exports, defaultArray }) {
733
+ check({ path, exports, defaultArray }, { files }) {
290
734
  if (!/^src\/(backend|frontend)\/[^/]+\/(test\.)?di\.ts$/.test(path)) return;
291
- if (exports.length !== 1 || !defaultArray) return "a di.ts has one export: `export default [bind(Port).to(Adapter), …]`";
292
- const others = defaultArray.filter(({ chain: [first, ...methods] }) => first !== "bind" || methods.length === 0 || !methods.every(method => this.methods.has(method)));
293
- return others.length > 0 && `a di.ts holds bindings only: bind(Port).to(Adapter) (${others.map(({ source }) => source).join("; ")})`;
735
+ const file = fileName(path);
736
+ const fix = "Write `export default [bind(Port).to(Adapter), …]`, and nothing else.";
737
+ if (exports.length > 1) {
738
+ const other = exports.find(({ kind }) => kind !== "default") ?? exports[1];
739
+ return at(other.line, `${file} exports ${exports.length} names (${exports.map(({ name }) => name).join(", ")}), but a di.ts has one export, the list of its bindings. ${fix}`);
740
+ }
741
+ if (!defaultArray) {
742
+ const message = `${file} exports no list, but a di.ts has one export, the list of its bindings. ${fix}`;
743
+ return exports.length === 0 ? message : at(exports[0].line, message);
744
+ }
745
+ return defaultArray.filter(({ chain: [first, ...methods] }) => first !== "bind" || methods.length === 0 || !methods.every(method => this.methods.has(method)))
746
+ .map(({ chain, source, subject, constructed: [built], line }) => {
747
+ const adapter = built ?? "Adapter";
748
+ const port = (built && files.flatMap(other => other.classes).find(({ name }) => name === built)?.implements[0]) ?? (chain[0] === "bind" ? subject : null) ?? "Port";
749
+ return at(line, `The item \`${source}\` is not a binding, but a di.ts only binds: the kernel builds the dependencies. Write it \`bind(${port}).to(${adapter})\`.`);
750
+ });
294
751
  }
295
752
  },
296
753
  {
297
754
  name: "di-read-by-kernel",
298
755
  description: "No layer imports the di.ts of a context. Only the kernel reads it.",
299
756
  on: "import",
300
- check: ({ from, to }) => from.kind === "layer" && to.layer === "di" && "a context's di.ts is read by the kernel only"
757
+ check: ({ from, to, target }) => from.kind === "layer" && to.layer === "di"
758
+ && `This file imports ${target}, the di.ts of ${to.context}, but only the kernel reads a di.ts. Depend on the port that it needs instead, and the kernel gives it the adapter that di.ts binds.`
301
759
  },
302
760
 
303
761
  // Contexts: their folders, and what their layers import
304
762
 
305
763
  {
306
764
  name: "sides-hold-contexts",
307
- description: "src/backend holds only the contexts and the context map (context-map.ts and its tests). src/frontend holds only the contexts.",
765
+ description: "src/backend and src/frontend hold only the contexts: a context map goes in its context. src/backend holds the integration tests of the whole backend too (*.integration.test.ts).",
308
766
  on: "file",
309
- check: ({ path }) => {
767
+ check: ({ path }, { paths }) => {
310
768
  const [, side, inside] = /^src\/(backend|frontend)\/([^/]+)$/.exec(path) ?? [];
311
- return side && (side === "frontend" || !/^context-map(\.test|\.integration\.test)?\.ts$/.test(inside))
312
- && `src/${side} holds only the contexts${side === "backend" ? " and the context map (context-map.ts and its tests)" : ""}`;
769
+ if (!side || (side === "backend" && /^[\w.-]+\.integration\.test\.ts$/.test(inside))) return;
770
+ const integration = side === "backend" ? ", and the integration tests of the whole backend (*.integration.test.ts)" : "";
771
+ if (/^context-map\.(js|ts)$/.test(inside)) {
772
+ return `${path} is not in a context, but src/${side} holds only its contexts${integration}. Move each of its lines into the context map of the context that reacts, src/${side}/{context}/context-map.ts.`;
773
+ }
774
+ if (side === "backend" && inside.endsWith(".test.ts")) {
775
+ return `${inside} is a test of the whole backend, but its name does not say so. Rename it ${inside.replace(/\.test\.ts$/, "")}.integration.test.ts, or move it into the context that it tests.`;
776
+ }
777
+ const contexts = [...new Set([...paths].flatMap(other => new RegExp(`^src/${side}/[^/]+/`).exec(other)?.[0] ?? []))].sort();
778
+ return `${inside} is in src/${side} but in no context: src/${side} holds only its contexts${integration}. Move it into the context that it belongs to${contexts.length > 0 ? `, such as ${contexts.join(" or ")}` : ""}.`;
313
779
  }
314
780
  },
315
781
  {
316
782
  name: "context-content",
317
- description: "A context holds only its four layers (domain, application, infra, presentation), its di.ts and its test.di.ts (and its package.json, which gives the shortcut #context).",
783
+ description: "A context holds its domain, its infra, and its use cases in command/ and query/: a use case holds its controllers. A context of the frontend holds its events too, in event/, and its presentation, in presentation/: its views, its components and its store serve several use cases. A context holds also its di.ts and its test.di.ts (and its package.json, which gives the shortcut #context), and its context map, context-map.ts and its test: the events of the context shared that it hears, and the events of its own that it shares.",
784
+ on: "file",
785
+ folders: { backend: ["domain", "infra", "command", "query"], frontend: ["domain", "infra", "command", "query", "event", "presentation"] },
786
+ /** @this {{ folders: Record<string, string[]> }} */
787
+ check({ path }) {
788
+ const [, context, side, inside] = /^(src\/(backend|frontend)\/[^/]+)\/(.+)$/.exec(path) ?? [];
789
+ const folders = side && this.folders[side];
790
+ if (!folders || folders.some(folder => inside.startsWith(`${folder}/`)) || /^(di|test\.di|context-map(\.test)?)\.ts$/.test(inside)) return;
791
+ const moved = /^application\/((command|query)\/.+)$/.exec(inside)?.[1] ?? (side === "frontend" ? /^application\/(event\/.+)$/.exec(inside)?.[1] : undefined);
792
+ if (moved) return `${inside} is in application/, but a context holds its use cases at its root, in command/ and query/, and the events of the frontend in event/. Move it to ${context}/${moved}.`;
793
+ return `${inside} is in no folder of its context: a context of the ${side} holds ${listOf(folders.map(folder => `${folder}/`))}, with di.ts, test.di.ts and context-map.ts. Move its code into the domain if it holds business rules, into the use case that needs it, or into infra/, behind a port, if it talks to the outside world.`;
794
+ }
795
+ },
796
+ {
797
+ name: "context-map-translates-shared",
798
+ description: "The context map of a context translates between its context and the context shared. It turns the events of shared into what its context reacts to: input DTOs of its context in the backend, events of its context in the frontend. It also turns the events of its context that it shares into events of shared. It imports only those, the events of its context and of shared, and the values of the domain.",
799
+ on: "import",
800
+ check: ({ file, to, target }) => {
801
+ const [, side, context] = /^src\/(backend|frontend)\/([^/]+)\/context-map\.ts$/.exec(file.path) ?? [];
802
+ if (!side || to.kind !== "layer" || to.side !== side || (to.context !== context && to.context !== "shared")) return;
803
+ const reads = side === "backend"
804
+ ? to.layer === "domain" || (to.context === context && /^src\/backend\/[^/]+\/(command|query)\/[^/]+\/[^/.]+\.dto\.(js|ts)$/.test(target))
805
+ : to.layer === "domain" || /^src\/frontend\/[^/]+\/event\//.test(target);
806
+ if (reads) return;
807
+ if (side === "frontend") {
808
+ return `The context map imports ${target}, ${fileKindOf(target)}, but a context map of the frontend only translates: it imports events and the values of the domain. Give an event of ${context} from the line instead: the store and the handlers hear it.`;
809
+ }
810
+ const head = `The context map imports ${target}, ${fileKindOf(target)}, but a context map only translates: it imports events, the input DTOs of its context, and the values of the domain.`;
811
+ if (to.context === context && to.useCase && /\.(handler|controller)\.(js|ts)$/.test(target)) {
812
+ return `${head} Import the input DTO from src/backend/${context}/${to.useCase}/${to.useCase.split("/")[1]}.dto.js instead, and give it from the line: the kernel sends it to the handler.`;
813
+ }
814
+ return `${head} Remove the import, and give an event or an input DTO from the line.`;
815
+ }
816
+ },
817
+ {
818
+ name: "context-map-holds-lines",
819
+ description: "A context map has one export, the list of its lines: `export default [when(Event, event => […]), share(Event, event => […])]`. It takes when and share from the kernel, for its side: @ingenioz-it/caits/messaging/ContextMap in the backend, @ingenioz-it/caits/messaging/FrontendContextMap in the frontend. The kernel reads the list.",
820
+ on: "file",
821
+ modules: { backend: "@ingenioz-it/caits/messaging/ContextMap", frontend: "@ingenioz-it/caits/messaging/FrontendContextMap" },
822
+ /** @this {{ modules: Record<string, string> }} */
823
+ check({ path, exports, defaultArray, imported }) {
824
+ const [, side, context] = /^src\/(backend|frontend)\/([^/]+)\/context-map\.ts$/.exec(path) ?? [];
825
+ if (!side) return;
826
+ if (exports.length !== 1 || !defaultArray) {
827
+ const what = exports.length === 0 ? "nothing"
828
+ : exports.length > 1 ? `${exports.length} names (${exports.map(({ name }) => name).join(", ")})`
829
+ : exports[0].kind === "default" ? "a default that is not a list" : `its lines by name, ${exports[0].name}`;
830
+ const message = `The context map exports ${what}, but a context map has one export, the list of its lines. Write \`export default [when(Event, event => […]), share(Event, event => […])]\`, and nothing else.`;
831
+ return exports.length === 0 ? message : at((exports.find(({ kind }) => kind !== "default") ?? exports[0]).line, message);
832
+ }
833
+ const module = this.modules[side];
834
+ return [
835
+ ...defaultArray.filter(({ chain }) => chain.length !== 1 || (chain[0] !== "when" && chain[0] !== "share"))
836
+ .map(({ source, line }) => at(line, `The item \`${source}\` is not a line: each line of a context map is made with when(…) or share(…). Write it \`when(SharedEvent, event => […])\` to hear an event of shared, or \`share(OwnEvent, event => […])\` to share an event of ${context}.`)),
837
+ ...imported.filter(({ name, from }) => (name === "when" || name === "share") && from !== module)
838
+ .map(({ name, from, line }) => at(line, `The context map takes ${name} from ${from}, but a context map of the ${side} takes it from ${module}. Import ${name} from ${module}.`))
839
+ ];
840
+ }
841
+ },
842
+ {
843
+ name: "context-map-line-direction",
844
+ description: "Each line of a context map goes one way. share(Event, …) starts from an event of its context, that a handler publishes (in the frontend, also a view or a component), and gives events of shared. when(Event, …) starts from an event of shared, which comes on the shared bus, and gives what its context reacts to: its input DTOs in the backend, its events in the frontend. The kernel reads the lines that share on the events of the context, and those that hear on the shared bus: a line that goes the other way never starts, and its test passes all the same.",
845
+ on: "file",
846
+ check(file, { root }) {
847
+ const [, side, context] = /^src\/(backend|frontend)\/([^/]+)\/context-map\.ts$/.exec(file.path) ?? [];
848
+ if (!side) return;
849
+ const events = side === "backend" ? "domain/event" : "event";
850
+ const own = `src/${side}/${context}/${events}/`;
851
+ const shared = `src/${side}/shared/${events}/`;
852
+ /** What the context reacts to: the input DTOs of its use cases in the backend, its events in the frontend. @type {(module: string) => boolean} */
853
+ const reacts = module => side === "backend" ? [`src/backend/${context}/command/`, `src/backend/${context}/query/`].some(folder => module.startsWith(folder)) : module.startsWith(own);
854
+ /** @type {(module: string) => boolean} */
855
+ const isMessage = module => /^src\/(backend|frontend)\/[^/]+\/(domain\/event|event|command|query)\//.test(module);
856
+ const reaction = side === "backend" ? `an input DTO of src/backend/${context}/command/ or src/backend/${context}/query/` : `an event of src/frontend/${context}/event/`;
857
+ return linesOf(file, root).flatMap(({ shares, event, gives, line }) => {
858
+ const name = event?.name ?? "…";
859
+ /** @type {string[]} */
860
+ const messages = [];
861
+ if (event && shares && !event.module.startsWith(own)) {
862
+ messages.push(event.module.startsWith(shared)
863
+ ? `The line \`share(${name}, …)\` starts from ${name}, an event of shared, but share(…) starts from an event of ${context}: this line never runs. Write it \`when(${name}, …)\` to react to ${name}, or start it from an event of ${own}.`
864
+ : `The line \`share(${name}, …)\` starts from ${name}, which is not an event of ${context}: this line never runs. Start it from an event of ${own}.`);
865
+ }
866
+ if (event && !shares && !event.module.startsWith(shared)) {
867
+ messages.push(event.module.startsWith(own)
868
+ ? `The line \`when(${name}, …)\` starts from ${name}, an event of ${context}, but when(…) hears an event of shared, from the shared bus: this line never runs. Write it \`share(${name}, …)\` to share it, or hear an event of ${shared}.`
869
+ : `The line \`when(${name}, …)\` starts from ${name}, which is not an event of shared: this line never runs. Hear an event of ${shared}.`);
870
+ }
871
+ for (const given of gives.filter(({ module }) => isMessage(module) && !(shares ? module.startsWith(shared) : reacts(module)))) {
872
+ messages.push(shares
873
+ ? `The line \`share(${name}, …)\` gives ${given.name}, which is not an event of shared, but share(…) gives events of shared. Give an event of ${shared} instead.`
874
+ : `The line \`when(${name}, …)\` gives ${given.name}, which is not ${side === "backend" ? "an input DTO" : "an event"} of ${context}, but when(…) gives what its context reacts to. Give ${reaction} instead.`);
875
+ }
876
+ return messages.map(message => at(line, message));
877
+ });
878
+ }
879
+ },
880
+ {
881
+ name: "context-maps-without-cycle",
882
+ description: "In the frontend, a bus carries an event at once: a line when(Shared, …) that gives Own, and a line share(Own, …) that gives Shared, would carry them without end, and the page would stop. The context maps of the frontend make no cycle of events, in one map or across several. In the backend, a cycle goes through a handler, which the maps do not show.",
883
+ on: "project",
884
+ check({ files, root }) {
885
+ /** @typedef {{ module: string; map: string; line: number }} Step a step of a cycle: the event that it gives, and the line of the map that gives it */
886
+ /** @type {Map<string, { to: string; map: string; line: number }[]>} */
887
+ const edges = new Map();
888
+ /** @type {(module: string) => boolean} */
889
+ const isShared = module => module.startsWith("src/frontend/shared/");
890
+ for (const file of files) {
891
+ if (!/^src\/frontend\/[^/]+\/context-map\.ts$/.test(file.path)) continue;
892
+ for (const { event, gives, line } of linesOf(file, root)) {
893
+ if (!event) continue;
894
+ // An event goes from shared to the context, or from the context to shared: a line that goes otherwise never starts.
895
+ for (const { module } of gives.filter(given => /^src\/frontend\/[^/]+\/event\//.test(given.module) && isShared(given.module) !== isShared(event.module))) {
896
+ edges.set(event.module, [...edges.get(event.module) ?? [], { to: module, map: file.path, line }]);
897
+ }
898
+ }
899
+ }
900
+ /** @type {Map<string, { file: string; line: number; message: string }>} */
901
+ const cycles = new Map();
902
+ /** @type {(path: Step[]) => void} */
903
+ const follow = path => {
904
+ for (const { to, map, line } of edges.get(path[path.length - 1].module) ?? []) {
905
+ const steps = [...path.slice(1), { module: to, map, line }];
906
+ if (to === path[0].module) {
907
+ const first = steps.reduce((least, step, index) => step.module < steps[least].module ? index : least, 0);
908
+ const cycle = [...steps.slice(first), ...steps.slice(0, first)];
909
+ const names = [...cycle, cycle[0]].map(({ module }) => module.split("/").pop());
910
+ // The step that gives an event is the line of the map that leads to it: names[i] → names[i + 1] is cycle[i + 1].
911
+ const lines = cycle.map((_, index) => `${names[index]} → ${names[index + 1]} in ${cycle[(index + 1) % cycle.length].map}`);
912
+ cycles.set(names.join(" → "), {
913
+ file: cycle[0].map,
914
+ line: cycle[0].line,
915
+ message: `The context maps of the frontend make a cycle of events, ${names.join(" → ")}, which the buses would carry without end, and the page would stop. Remove one of its lines: ${lines.join(", or ")}.`
916
+ });
917
+ } else if (!path.some(step => step.module === to)) follow([...path, { module: to, map, line }]);
918
+ }
919
+ };
920
+ for (const module of edges.keys()) follow([{ module, map: "", line: 0 }]);
921
+ return [...cycles.values()];
922
+ }
923
+ },
924
+ {
925
+ name: "shared-events-in-map",
926
+ description: "A context talks to the other contexts only through its context map: a handler (in the frontend, also a view or a component) publishes an event of its context, the map shares it as an event of shared on the shared bus, and the map of each context that hears it turns it into what its context reacts to. Thus, in a context other than shared, only the context map imports the events of shared: those of the backend, in domain/event/, and those of the frontend, in event/.",
927
+ on: "import",
928
+ // The layers that could import them, but the context map; the rules on the layers refuse the others already.
929
+ events: {
930
+ backend: { folder: /^src\/backend\/shared\/domain\/event\//, layers: ["domain", "application", "infra", "presentation", "di"] },
931
+ frontend: { folder: /^src\/frontend\/shared\/event\//, layers: ["application", "infra", "presentation"] }
932
+ },
933
+ /** @this {{ events: Record<string, { folder: RegExp; layers: string[] }> }} */
934
+ check({ file, from, specifier, target }) {
935
+ const events = from.kind === "layer" && from.context !== "shared" && this.events[from.side];
936
+ if (!events || !events.layers.includes(from.layer) || !events.folder.test(target)) return;
937
+ const names = file.imported.filter(({ from: module }) => module === specifier).map(({ name }) => name);
938
+ const context = contextFolderOf(file.path);
939
+ return `This file imports ${names.length > 0 ? `${listOf(names)} (${target})` : target}, an event of shared, but in a context other than shared only the context map names the events of shared. Publish an event of ${from.context} instead, in ${context}/${from.side === "backend" ? "domain/event" : "event"}/, and share it as ${names.length > 0 ? listOf(names, "or") : "an event of shared"} in ${context}/context-map.ts.`;
940
+ }
941
+ },
942
+ {
943
+ name: "shared-hears-no-context",
944
+ description: "The context shared, in the backend and in the frontend, has no context map: it hears no other context, and its events are on the shared bus already. The contexts hear it.",
318
945
  on: "file",
319
- check: ({ path }) => /^src\/(backend|frontend)\/[^/]+\//.test(path) && !/^src\/(backend|frontend)\/[^/]+\/((domain|application|infra|presentation)\/|(di|test\.di)\.ts$)/.test(path)
320
- && "a context holds only domain, application, infra and presentation, with its di.ts and test.di.ts"
946
+ check: ({ path }) => /^src\/(backend|frontend)\/shared\/context-map\.ts$/.test(path)
947
+ && "The context shared has a context map, but shared hears no other context: its events are on the shared bus already, and the contexts hear them. Remove this file, and move each of its lines into the context map of the context that reacts."
321
948
  },
322
949
  {
323
950
  name: "context-has-di",
324
951
  description: "Each folder of src/backend and src/frontend is a context, and has a di.ts. The kernel finds the contexts by their di.ts.",
325
952
  on: "project",
326
- check: ({ paths }) => {
953
+ check: ({ paths, files }) => {
327
954
  const contexts = new Set([...paths].map(path => /^src\/(backend|frontend)\/[^/]+(?=\/)/.exec(path)?.[0]).filter(context => context !== undefined));
328
- return [...contexts].filter(context => !paths.has(`${context}/di.ts`))
329
- .map(context => ({ file: context, message: `a folder of ${context.split("/").slice(0, 2).join("/")} is a context: it says in di.ts what each of its tokens stands for` }));
955
+ const adapters = files.filter(file => /\/infra\//.test(file.path) && !isTestCode(file.path)).sort((first, second) => first.path < second.path ? -1 : 1);
956
+ return [...contexts].filter(context => !paths.has(`${context}/di.ts`)).map(context => {
957
+ const bindings = [...paths].sort().flatMap(path => {
958
+ const port = path.startsWith(`${context}/`) ? portOf(path) : null;
959
+ if (!port || port.contract) return [];
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})`] : [];
963
+ });
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(", ")}]\`.` };
965
+ });
966
+ }
967
+ },
968
+ {
969
+ name: "shared-components-are-tags",
970
+ description: "A component of the frontend context shared is a Marko tag: its folder holds its index.marko, and src/frontend/marko.json gives Marko the folder of these tags, { \"tags-dir\": \"./shared/presentation/component\" }. A context places it by its name, <badge>: Marko finds it, where an import of its file would not build.",
971
+ on: "project",
972
+ check: ({ root, paths }) => {
973
+ const folders = new Set([...paths].map(path => /^src\/frontend\/shared\/presentation\/component\/[a-z0-9-]+(?=\/)/.exec(path)?.[0]).filter(folder => folder !== undefined));
974
+ const untagged = [...folders].filter(folder => !paths.has(`${folder}/index.marko`)).map(folder => {
975
+ const templates = [...paths].filter(path => folderOf(path) === folder && path.endsWith(".marko"));
976
+ return {
977
+ file: folder,
978
+ message: `The component ${fileName(folder)} of shared has no index.marko, but a component of the frontend context shared is a Marko tag, which its index.marko holds. ${templates.length === 1 ? `Rename ${templates[0]} to index.marko.` : `Write ${folder}/index.marko.`}`
979
+ };
980
+ });
981
+ if (folders.size === 0 || givesSharedTags(root)) return untagged;
982
+ const missing = !existsSync(join(root, "src/frontend/marko.json"));
983
+ return [...untagged, {
984
+ file: "src/frontend/marko.json",
985
+ message: `src/frontend/marko.json ${missing ? "is missing" : "does not give Marko the folder of the components of shared"}, so the contexts cannot place them by their tag. Write \`{ "tags-dir": "./shared/presentation/component" }\` in src/frontend/marko.json.`
986
+ }];
987
+ }
988
+ },
989
+ {
990
+ name: "shared-components-placed-by-tag",
991
+ description: "A context of the frontend places a component of the context shared by its tag (<badge>), and does not import its file: Marko reads an import of a template from the package.json of the context, whose shortcuts lead only into it.",
992
+ on: "import",
993
+ check: ({ from, specifier, target }) => {
994
+ if (from.kind !== "layer" || from.context === "shared" || !specifier.startsWith("#") || !/^src\/frontend\/shared\/presentation\/component\/[a-z0-9-]+\/[^/]+\.marko$/.test(target)) return;
995
+ const name = target.split("/")[5];
996
+ return `This file imports ${target}, the component ${name} of shared, but Marko does not build an import of its file from another context. Remove the import, and place the component by its tag, \`<${name}>\`.`;
330
997
  }
331
998
  },
332
999
  {
333
1000
  name: "imports-by-shortcut",
334
- description: "In src, an import does not go up a folder (../). A context imports its own files with #context/ and the context shared with #shared/; a route imports the contexts with #backend/ and #frontend/. Thus an import does not change when its file moves. A stylesheet loads only the files of its folder: Sass reads # as the start of a fragment, and a View reads the values of the theme as CSS variables.",
1001
+ description: "In src, an import does not go up a folder (../). A context imports its own files with #context/, and the context shared of its side with #shared/ in the backend, #frontend-shared/ in the frontend; a route imports the contexts with #backend/ and #frontend/. Thus an import does not change when its file moves. A stylesheet loads only the files of its folder: Sass reads # as the start of a fragment, and a view reads the values of the theme as CSS variables.",
335
1002
  on: "file",
336
- check: ({ path, imports }) => path.startsWith("src/")
337
- && imports.filter(specifier => specifier.split("/").includes(".."))
338
- .map(specifier => path.endsWith(".scss")
339
- ? `a stylesheet does not go up a folder: it loads the files of its folder, and a View reads the values of the theme as CSS variables (${specifier})`
340
- : `an import does not go up a folder: use #context/ in a context, #shared/ for shared, #backend/ or #frontend/ in a route (${specifier})`)
1003
+ check: ({ path, imports, lines }, { root }) => path.startsWith("src/") && imports.flatMap((specifier, index) => {
1004
+ if (!specifier.split("/").includes("..")) return [];
1005
+ if (path.endsWith(".scss")) {
1006
+ return [at(lines.imports[index], `This stylesheet loads ${specifier}, which goes up a folder, but a stylesheet loads only the files of its folder: Sass reads # as the start of a fragment. Read the values of the theme as CSS variables instead.`)];
1007
+ }
1008
+ const shortcut = shortcutOf(path, resolveImport(path, specifier, root).target);
1009
+ return [at(lines.imports[index], `This file imports ${specifier}, which goes up a folder: the import breaks when the file moves. ${shortcut ? `Import it as ${shortcut}.` : "Use #context/ in a context, #shared/ or #frontend-shared/ for shared, and #backend/ or #frontend/ in a route."}`)];
1010
+ })
341
1011
  },
342
1012
  {
343
1013
  name: "contexts-isolated",
344
- description: "A context does not import another context of its side, except the backend context shared. Backend contexts talk through events and the context map; frontend contexts do not talk: a route places their views side by side.",
1014
+ description: "A context does not import another context of its side, except the context shared of its side. The contexts talk through the shared bus: the context map of a context shares an event of the context as an event of shared, and the context map of each context that hears it turns it into what it reacts to, input DTOs of its own in the backend, events of its own in the frontend. In the frontend, a route places the views of several contexts side by side.",
345
1015
  on: "import",
346
- check: ({ from, to }) => from.kind === "layer" && to.kind === "layer" && to.side === from.side && to.layer !== "di" && to.context !== from.context && to.context !== "shared"
347
- && (from.side === "backend"
348
- ? "a context never imports another context: it publishes events, and the context map turns them into the inputs of the others"
349
- : "a context never imports another context: in the frontend, a route places the views of several contexts side by side")
1016
+ check: ({ file, from, to, target }) => {
1017
+ if (from.kind !== "layer" || to.kind !== "layer" || to.side !== from.side || to.layer === "di" || to.context === from.context || to.context === "shared") return;
1018
+ const reaction = from.side === "backend" ? "an input" : "an event";
1019
+ const view = /\/presentation\/view\/([^/]+)\//;
1020
+ const views = from.side === "frontend" && view.exec(file.path);
1021
+ if (views && view.test(target)) return `The view ${views[1]} of ${from.context} imports ${target}, a view of ${to.context}, but a context never imports another context. Let the route place the two views side by side.`;
1022
+ const head = `The context ${from.context} imports ${target}, a file of the context ${to.context}, but a context never imports another context.`;
1023
+ if (from.context === "shared") return `${head} Publish an event of shared: the context map of ${to.context} turns it into ${reaction} of its own.`;
1024
+ return `${head} Publish an event of ${from.context}, share it as an event of shared in ${contextFolderOf(file.path)}/context-map.ts, and turn it into ${reaction} of ${to.context} in src/${to.side}/${to.context}/context-map.ts.`;
1025
+ }
1026
+ },
1027
+ {
1028
+ name: "tests-in-one-context",
1029
+ description: "A test reads one context, and the context shared of its side: a test in a context reads only its context, and a test out of the contexts (src/backend/*.integration.test.ts, test/) one context at most. No test crosses contexts: each context is tested alone, and its context map on its own lines, what it shares and what it does with an event of shared. A test of the frontend builds only the frontend of its context, with testPresentation.",
1030
+ on: "file",
1031
+ check: (file, { root }) => {
1032
+ if (!isTestCode(file.path)) return;
1033
+ const [source, side, context, ...rest] = file.path.split("/");
1034
+ const own = source === "src" && (side === "backend" || side === "frontend") && rest.length > 0 ? `${side}/${context}` : undefined;
1035
+ /** Each context that the test reads, at the line of the import, or of testPresentation. @type {{ context: string; line: number }[]} */
1036
+ const reads = [
1037
+ ...file.imports.flatMap((specifier, index) => {
1038
+ const { internal, target } = resolveImport(file.path, specifier, root);
1039
+ return internal && /^src\/(backend|frontend)\/[^/]+\/./.test(target) ? [{ context: target.split("/").slice(1, 3).join("/"), line: file.lines.imports[index] }] : [];
1040
+ }),
1041
+ ...file.calls.flatMap(({ name, args: [built], line }) => name === "testPresentation" && typeof built === "string" ? [{ context: `frontend/${built}`, line }] : [])
1042
+ ].sort((first, second) => first.line - second.line);
1043
+ const read = [...new Set(reads.map(({ context: name }) => name))];
1044
+ if (own) {
1045
+ const others = read.filter(name => name !== own && name !== `${side}/shared`);
1046
+ const first = reads.find(({ context: name }) => others.includes(name));
1047
+ return first && at(first.line, `This test of ${own} reads ${listOf(others)}, but a test reads only its context and shared: each context is tested alone. Split it: test in ${own} what its context map shares, and in ${others[0]} what its context map does with that event of shared.`);
1048
+ }
1049
+ /** Whether contexts are too many for a test out of the contexts: two contexts, or two sides. @type {(names: string[]) => boolean} */
1050
+ const tooMany = names => names.filter(name => !name.endsWith("/shared")).length > 1 || new Set(names.map(name => name.split("/")[0])).size > 1;
1051
+ const first = reads.find((_, index) => tooMany([...new Set(reads.slice(0, index + 1).map(({ context: name }) => name))]));
1052
+ return first && at(first.line, `This test reads ${listOf(read)}, but a test outside the contexts reads one context at most, and the context shared of its side. Split it into one test per context.`);
1053
+ }
350
1054
  },
351
1055
  {
352
1056
  name: "sides-apart",
353
1057
  description: "A frontend context and a backend context do not import each other. They are two projects: they talk only through the HTTP API.",
354
1058
  on: "import",
355
- check: ({ from, to }) => from.kind === "layer" && to.kind === "layer" && to.side !== from.side
356
- && "frontend and backend never import each other: they are two projects, which talk only through the HTTP API"
1059
+ check: ({ file, from, to, target }) => {
1060
+ if (from.kind !== "layer" || to.kind !== "layer" || to.side === from.side) return;
1061
+ const context = contextFolderOf(file.path);
1062
+ return from.side === "frontend"
1063
+ ? `The frontend imports ${target}, a file of the backend, but the frontend and the backend are two projects, which talk only through the HTTP API. Declare what the frontend needs in ${context}, and call the API through an adapter of ${context}/infra/, behind a port.`
1064
+ : `The backend imports ${target}, a file of the frontend, but the frontend and the backend are two projects, which talk only through the HTTP API. Declare what the backend needs in ${context}.`;
1065
+ }
357
1066
  },
358
1067
  {
359
- name: "layer-direction",
360
- description: "In a context, and from a context to shared, each layer imports only the layers that it may use. The domain imports the domain; the application adds the domain; the presentation adds the application; the infra imports the domain (and the application in the frontend); the di.ts imports the domain and the infra.",
1068
+ name: "domain-imports-no-application",
1069
+ description: "The domain does not import the application: a use case (its DTOs, its handler), or an event of the frontend. The use cases depend on the domain, not the other way.",
361
1070
  on: "import",
362
- allowed: {
363
- backend: { domain: ["domain"], application: ["application", "domain"], presentation: ["presentation", "application", "domain"], infra: ["infra", "domain"], di: ["domain", "infra"] },
364
- frontend: { domain: ["domain"], application: ["application", "domain"], presentation: ["presentation", "application", "domain"], infra: ["infra", "application", "domain"], di: ["domain", "infra"] }
365
- },
366
- /** @this {{ allowed: Record<string, Record<string, string[]>> }} */
367
- check({ from, to }) {
368
- return from.kind === "layer" && to.kind === "layer" && to.side === from.side && to.layer !== "di" && (to.context === from.context || to.context === "shared")
369
- && !this.allowed[from.side][from.layer]?.includes(to.layer) && `${from.layer} must not depend on ${to.layer}`;
1071
+ check: ({ file, from, to, target }) => {
1072
+ if (!withinContext(from, to) || from.layer !== "domain" || to.layer !== "application") return;
1073
+ const head = `The domain layer must not import the application layer (${target}): the use cases depend on the domain, not the other way.`;
1074
+ return /\.dto\.(js|ts)$/.test(target)
1075
+ ? `${head} Let the handler read the DTO, and give ${fileName(file.path).replace(/\.[^.]+$/, "")} the values it needs.`
1076
+ : `${head} Move what the domain needs into ${contextFolderOf(file.path)}/domain/.`;
370
1077
  }
371
1078
  },
372
1079
  {
373
- name: "contexts-without-routes",
374
- description: "A context does not import the routes or the context map. The routes and the context map use the contexts, not the opposite.",
1080
+ name: "domain-imports-no-presentation",
1081
+ description: "The domain does not import the presentation: a controller, or the presentation of the frontend. The presentation reads the domain, not the other way.",
375
1082
  on: "import",
376
- check: ({ from, to }) => from.kind === "layer" && (to.kind === "routes" || to.kind === "map") && "contexts never import routes or the context map"
1083
+ check: ({ file, from, to, target }) => withinContext(from, to) && from.layer === "domain" && to.layer === "presentation"
1084
+ && `The domain layer must not import the presentation layer (${target}): the presentation reads the domain, not the other way. Move what the domain needs into ${contextFolderOf(file.path)}/domain/.`
377
1085
  },
378
-
379
- // Context map: src/backend/context-map.ts, which turns the events of a context into the inputs of the others
380
-
381
1086
  {
382
- name: "context-map-reads-events-and-dtos",
383
- description: "The context map imports from the contexts only their domain events and their input DTOs. It turns an event of a context into the input of another context.",
1087
+ name: "domain-imports-no-infra",
1088
+ description: "The domain does not import the infra: it declares what it needs as a port, and an adapter of the infra implements it.",
384
1089
  on: "import",
385
- check: ({ from, to, target }) => from.kind === "map" && to.kind === "layer" && !/\/domain\/event\/[^/]+$|\.dto\.(js|ts)$/.test(target)
386
- && "the context map reads only the events (domain/event) and the input DTOs (*.dto.ts) of the contexts"
1090
+ check: ({ file, from, to, target }) => withinContext(from, to) && from.layer === "domain" && to.layer === "infra"
1091
+ && `The domain layer must not import the infra layer (${target}): the domain declares what it needs, and the infra implements it. Declare it as a port in ${contextFolderOf(file.path)}/domain/port/, and let a handler call the port and give the result to ${fileName(file.path).replace(/\.[^.]+$/, "")}.`
387
1092
  },
388
1093
  {
389
- name: "context-map-without-routes",
390
- description: "The context map does not import the routes.",
1094
+ name: "application-imports-no-presentation",
1095
+ description: "The application (the handlers and the DTOs of the use cases, and the events of the frontend) does not import the presentation: a controller, a view, a component or the store. The presentation sends the use cases their DTOs, and hears their events.",
391
1096
  on: "import",
392
- check: ({ from, to }) => from.kind === "map" && to.kind === "routes" && "the context map never imports routes"
1097
+ check: ({ file, from, to, target }) => {
1098
+ if (!withinContext(from, to) || from.layer !== "application" || to.layer !== "presentation") return;
1099
+ const head = `The application layer must not import the presentation layer (${target}):`;
1100
+ if (from.side === "frontend") return `${head} the store and the views hear the use cases through events. Publish an event of ${from.context} instead, and let the store or the view hear it.`;
1101
+ const context = contextFolderOf(file.path);
1102
+ return `${head} the controller sends the use case its DTO, not the other way. ${from.useCase ? `Move what the handler needs into ${from.useCase.split("/")[1]}.dto.ts, or into ${context}/domain/.` : `Move what it needs into ${context}/domain/.`}`;
1103
+ }
1104
+ },
1105
+ {
1106
+ name: "application-imports-no-infra",
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.",
1108
+ on: "import",
1109
+ check: ({ from, to, target }, project) => {
1110
+ if (!withinContext(from, to) || from.layer !== "application" || to.layer !== "infra") return;
1111
+ const port = portOfAdapter(target, project);
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.`;
1113
+ }
1114
+ },
1115
+ {
1116
+ name: "presentation-imports-no-infra",
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.",
1118
+ on: "import",
1119
+ check: ({ file, from, to, target }, project) => {
1120
+ if (!withinContext(from, to) || from.layer !== "presentation" || to.layer !== "infra") return;
1121
+ const port = portOfAdapter(target, project);
1122
+ const named = port ? `${port.name} (${port.path})` : "its port";
1123
+ if (from.side === "frontend") {
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}.`;
1125
+ }
1126
+ const context = contextFolderOf(file.path);
1127
+ const useCase = from.useCase?.split("/")[1];
1128
+ return `The presentation layer must not import the infra layer (${target}): a controller sends a DTO to its use case, and the handler uses the port. ${useCase ? `Send the DTO of ${useCase}, and let ${context}/${from.useCase}/${useCase}.handler.ts depend on ${named}` : `Send the DTO of its use case, and let the handler depend on ${named}`}; ${contextFolderOf(target)}/di.ts binds the adapter.`;
1129
+ }
1130
+ },
1131
+ {
1132
+ name: "infra-imports-no-presentation",
1133
+ description: "The infra does not import the presentation: an adapter implements a port of the domain, and knows neither the views, the components, the store nor the controllers.",
1134
+ on: "import",
1135
+ check: ({ file, from, to, target }) => withinContext(from, to) && from.layer === "infra" && to.layer === "presentation"
1136
+ && `The infra layer must not import the presentation layer (${target}): an adapter implements a port of the domain, and knows neither the views nor the controllers. Move what the adapter needs into ${contextFolderOf(file.path)}/domain/${from.side === "frontend" ? ", or the application of the frontend" : ""}.`
1137
+ },
1138
+ {
1139
+ name: "infra-imports-no-application",
1140
+ description: "In the backend, the infra does not import the application: an adapter implements a port, and the use cases call it. The infra of the frontend may import its application: its events and its DTOs.",
1141
+ on: "import",
1142
+ check: ({ file, from, to, target }) => withinContext(from, to) && from.side === "backend" && from.layer === "infra" && to.layer === "application"
1143
+ && `In the backend, the infra layer must not import the application layer (${target}): an adapter implements a port, and the use cases call it. Move what the adapter needs into ${contextFolderOf(file.path)}/domain/, such as a value object that the port takes.`
1144
+ },
1145
+ {
1146
+ name: "di-imports-no-application",
1147
+ description: "A di.ts does not import the application: it binds the ports of the domain to the adapters of the infra. The kernel finds the handlers and the DTOs by itself.",
1148
+ on: "import",
1149
+ check: ({ from, to, target }) => withinContext(from, to) && from.layer === "di" && to.layer === "application"
1150
+ && `di.ts must not import the application layer (${target}): it binds the ports of the domain to the adapters of the infra. Remove the import: the kernel finds the handlers and the DTOs in command/ and query/ by itself.`
1151
+ },
1152
+ {
1153
+ name: "di-imports-no-presentation",
1154
+ description: "A di.ts does not import the presentation: it binds the ports of the domain to the adapters of the infra. The kernel finds the controllers by itself, and the routes place the views.",
1155
+ on: "import",
1156
+ check: ({ from, to, target }) => withinContext(from, to) && from.layer === "di" && to.layer === "presentation"
1157
+ && `di.ts must not import the presentation layer (${target}): it binds the ports of the domain to the adapters of the infra. Remove the import: the kernel finds the controllers in command/ and query/ by itself, and the routes place the views.`
1158
+ },
1159
+ {
1160
+ name: "context-map-read-by-kernel",
1161
+ description: "No layer imports the context map of its context, or of shared. Only the kernel reads it: it shares the events of the context, and hears those of shared.",
1162
+ on: "import",
1163
+ check: ({ from, to, target }) => withinContext(from, to) && to.layer === "map"
1164
+ && `${subjectOf(from.layer ?? "")} imports ${target}, the context map of ${to.context}, but only the kernel reads a context map. Remove the import, publish an event of ${from.context}, and let the context map share it.`
1165
+ },
1166
+ {
1167
+ name: "contexts-without-routes",
1168
+ description: "A context does not import the routes. The routes use the contexts, not the opposite.",
1169
+ on: "import",
1170
+ check: ({ file, from, to, target }, { paths }) => {
1171
+ if (from.kind !== "layer" || to.kind !== "routes") return;
1172
+ return file.path.endsWith(".scss")
1173
+ ? `This stylesheet loads ${stylesheetOf(target, paths)}, a stylesheet of the routes, but the routes use the contexts, never the other way. Read the values of the theme as CSS variables instead.`
1174
+ : `The context ${from.context} imports ${target}, a route, but the routes use the contexts, never the other way. Move what it needs into ${contextFolderOf(file.path)}.`;
1175
+ }
393
1176
  },
394
1177
 
395
1178
  // Routes: the pages and the handlers of Marko Run
@@ -398,70 +1181,140 @@ export const rules = [
398
1181
  name: "routes-folders",
399
1182
  description: "src/routes holds only _frontend (the pages) and _backend (the HTTP handlers).",
400
1183
  on: "file",
401
- check: ({ path }) => path.startsWith("src/routes/") && !/^src\/routes\/_(frontend|backend)\//.test(path) && "routes holds only _frontend and _backend"
1184
+ check: ({ path }) => {
1185
+ if (!path.startsWith("src/routes/") || /^src\/routes\/_(frontend|backend)\//.test(path)) return;
1186
+ const rest = path.slice("src/routes/".length);
1187
+ const fix = /(^|\/)\+handler\.(js|ts)$/.test(path) ? `Move it to src/routes/_backend/${rest}.`
1188
+ : /(^|\/)\+(page|layout)\.marko$/.test(path) ? `Move it to src/routes/_frontend/${rest}.`
1189
+ : "Move a page or a layout to src/routes/_frontend/, and an HTTP handler to src/routes/_backend/.";
1190
+ return `${path} is outside _frontend and _backend, so it is on no side. ${fix}`;
1191
+ }
402
1192
  },
403
1193
  {
404
1194
  name: "frontend-routes-content",
405
- description: "src/routes/_frontend holds the pages (+page.marko), the layouts (+layout.marko), in component/ the components that only these pages use, in style/ the stylesheets (SCSS) of the app, and next to a page or a layout its stylesheet.",
1195
+ description: "src/routes/_frontend holds the pages (+page.marko), the layouts (+layout.marko), in component/ the components of Marko that only these pages use and their stylesheets, in style/ the stylesheets (SCSS) of the app, and next to a page or a layout its stylesheet. A document that the pages give in another format than HTML (a sitemap, a feed, llms.txt) has its route as a page has: a folder named after its file, `sitemap.xml`/+handler.ts, which only gives the GET of a view of a frontend context. The routes hold no logic and no test: the code that a test reads lives in a context, with its test.",
406
1196
  on: "file",
407
- check: ({ path }, { paths }) => path.startsWith("src/routes/_frontend/") && !/\/\+(page|layout)\.marko$/.test(path) && !/\/component\/[^/]+$/.test(path)
408
- && !/^src\/routes\/_frontend\/style\/([^/]+\/)*[^/]+\.scss$/.test(path)
409
- && !(path.endsWith(".scss") && ["+page.marko", "+layout.marko"].some(name => paths.has(`${path.slice(0, path.lastIndexOf("/"))}/${name}`)))
410
- && "routes/_frontend holds +page.marko, +layout.marko, in component/ the components that only its pages use, in style/ the stylesheets of the app, and next to a page or a layout its stylesheet"
1197
+ check: ({ path }, { paths }) => {
1198
+ if (!path.startsWith("src/routes/_frontend/") || /\/\+(page|layout)\.marko$/.test(path) || /\/component\/[^/]+\.(marko|scss)$/.test(path)
1199
+ || /^src\/routes\/_frontend\/style\/([^/]+\/)*[^/]+\.scss$/.test(path)
1200
+ || (path.endsWith(".scss") && ["+page.marko", "+layout.marko"].some(name => paths.has(`${folderOf(path)}/${name}`)))
1201
+ || /\/`[\w-]+(\.[\w-]+)+`\/\+handler\.ts$/.test(path)) return;
1202
+ const relative = path.slice("src/routes/_frontend/".length);
1203
+ const [, before, name, after] = /^(.*?)`([^`]+)`(.*)$/.exec(relative) ?? [];
1204
+ if (name !== undefined && !name.includes(".")) {
1205
+ return `${relative} is not the route of a document: such a route is a folder named after its file, with its extension, and holds its +handler.ts. Name the folder after the file that it gives, as \`sitemap.xml\`/+handler.ts.`;
1206
+ }
1207
+ if (name !== undefined && !after.startsWith("/")) {
1208
+ return `${relative} is not the route of a document: such a route is a folder named after its file, with its +handler.ts. Move it to src/routes/_frontend/${before}\`${name}\`/+handler.ts.`;
1209
+ }
1210
+ if (path.endsWith(".scss")) return `${fileName(path)} is next to no page or layout, but a stylesheet of the routes is in style/, in component/, or next to its page. Move it to src/routes/_frontend/style/, or next to the page that uses it.`;
1211
+ if (path.endsWith(".marko")) return `${relative} is a template outside component/, but the routes hold their pages, their layouts, and in component/ the components that only their pages use. Move it to src/routes/_frontend/component/, or into a view of a frontend context.`;
1212
+ return `${relative} is ${path.endsWith(".test.ts") ? "a test" : "code"} in the routes, but the routes hold no logic and no test. Move it into a frontend context: the code into its layers, with its test, and let the page place the view that uses it.`;
1213
+ }
411
1214
  },
412
1215
  {
413
1216
  name: "backend-routes-content",
414
1217
  description: "src/routes/_backend holds only +handler.ts files. The code of a handler lives in a context: the route only gives the request to a controller.",
415
1218
  on: "file",
416
- check: ({ path }) => path.startsWith("src/routes/_backend/") && !path.endsWith("/+handler.ts") && "routes/_backend holds only +handler.ts: the rest lives in a context"
1219
+ check: ({ path }) => path.startsWith("src/routes/_backend/") && !path.endsWith("/+handler.ts")
1220
+ && `${fileName(path)} is not a +handler.ts, but the backend routes only give the request to a controller. Move its code into a backend context, in a controller or a handler, and let the route import the controller.`
417
1221
  },
418
1222
  {
419
1223
  name: "routes-on-their-side",
420
1224
  description: "A frontend route imports only the frontend; a backend route imports only the backend.",
421
1225
  on: "import",
422
- check: ({ from, to }) => from.kind === "routes" && to.kind === "layer" && to.side !== from.side
423
- && "frontend and backend never import each other: they are two projects, which talk only through the HTTP API"
1226
+ check: ({ from, to, target }) => {
1227
+ if (from.kind !== "routes" || to.kind !== "layer" || to.side === from.side) return;
1228
+ return from.side === "frontend"
1229
+ ? `This route of the frontend imports ${target}, a file of the backend, but the frontend and the backend are two projects, which talk only through the HTTP API. Place a view of a frontend context instead, which gets its data from the API through its adapter.`
1230
+ : `This route of the backend imports ${target}, a file of the frontend, but the frontend and the backend are two projects, which talk only through the HTTP API. Give the request to a controller of a backend context instead.`;
1231
+ }
424
1232
  },
425
1233
  {
426
1234
  name: "routes-through-presentation",
427
1235
  description: "A route imports only the presentation of the contexts of its side: the views for a page, the controllers for a handler.",
428
1236
  on: "import",
429
- check: ({ from, to }) => from.kind === "routes" && to.kind === "layer" && to.side === from.side && to.layer !== "presentation" && "routes reach contexts through presentation"
430
- },
431
- {
432
- name: "routes-without-context-map",
433
- description: "A route does not import the context map. The kernel reads it.",
434
- on: "import",
435
- check: ({ from, to }) => from.kind === "routes" && to.kind === "map" && "routes never import the context map: the kernel reads it"
1237
+ check: ({ from, to, target }, { files }) => {
1238
+ if (from.kind !== "routes" || to.kind !== "layer" || to.side !== from.side || to.layer === "presentation") return;
1239
+ const head = `This route imports ${target}, of ${objectOf(to.layer)} of ${to.context}, but a route reaches a context only through its presentation.`;
1240
+ if (from.side === "frontend") return `${head} Place a view of src/frontend/${to.context}/presentation/view/ instead.`;
1241
+ const folder = to.useCase && `src/backend/${to.context}/${to.useCase}/`;
1242
+ const controller = folder ? files.find(file => file.path.startsWith(folder) && file.path.endsWith(".controller.ts")) : undefined;
1243
+ const name = controller?.classes.find(({ exported }) => exported)?.name;
1244
+ return controller && name
1245
+ ? `${head} Import the controller of the use case, ${name} (${controller.path}), and give it the request: \`controller(${name}).handle(ctx.request)\`.`
1246
+ : `${head} Import the controller of the use case, and give it the request: \`controller(SomethingController).handle(ctx.request)\`.`;
1247
+ }
436
1248
  },
437
1249
  {
438
1250
  name: "routes-take-no-frontend",
439
1251
  description: "A route does not call frontendOf. It only places the views; each view takes the frontend of its context.",
440
1252
  on: "file",
441
1253
  production: true,
442
- check: file => locate(file.path).kind === "routes"
443
- && frontendsOf(file).map(context => `a route takes no frontend: the views of a context take theirs with frontendOf (${context})`)
1254
+ check: file => locate(file.path).kind === "routes" && frontendCallsOf(file).map(({ context, line }) => at(line,
1255
+ `This route takes the frontend of ${context}, but a route only places views. Move the code into a view of src/frontend/${context}/presentation/view/, which takes its frontend with frontendOf, and place the view in the page.`))
444
1256
  },
445
1257
 
446
1258
  // Test code: what the production code never imports
447
1259
 
448
1260
  {
449
- name: "no-test-environment",
1261
+ name: "production-imports-no-test-environment",
450
1262
  description: "The production code does not import a test environment (test.di.ts). A test environment replaces dependencies in the tests only.",
451
1263
  on: "import",
452
- check: ({ to }) => to.kind === "test-environment" && "production code never imports a test environment (test.di)"
1264
+ check: ({ to, target }) => {
1265
+ if (to.kind !== "test-environment") return;
1266
+ const context = contextFolderOf(target);
1267
+ return `This file imports ${target}, the test environment of ${context ? context.split("/")[2] : "a context"}, but test.di.ts replaces dependencies in the tests only. Remove the import, and bind what the production code needs in ${context ? `${context}/di.ts` : "the di.ts of the context"}.`;
1268
+ }
453
1269
  },
454
1270
  {
455
- name: "no-test-support",
1271
+ name: "production-imports-no-test-support",
456
1272
  description: "The production code does not import the test support of the kernel (@ingenioz-it/caits/testing).",
457
1273
  on: "import",
458
- check: ({ specifier }) => kernelPathOf(specifier)?.startsWith("testing/") && "production code never imports test support (@ingenioz-it/caits/testing)"
1274
+ check: ({ specifier }) => kernelPathOf(specifier)?.startsWith("testing/")
1275
+ && `This file imports ${specifier}, the test support of the kernel, but the production code never uses the tools of the tests. Move the code that needs it into a test.`
459
1276
  },
460
1277
  {
461
- name: "nothing-outside-src",
1278
+ name: "production-imports-only-src",
462
1279
  description: "The production code in src imports no file outside src, test/ and quality/ included. It imports the kernel by its name, as a package, never by a path.",
463
1280
  on: "import",
464
- check: ({ from, specifier, target }) => specifier.startsWith(".") && from.kind !== "other" && !target.startsWith("src/")
465
- && "production code (src) never imports a file outside src, tests included; it imports the kernel by its name (@ingenioz-it/caits/…)"
1281
+ check: ({ file, from, specifier, target }) => {
1282
+ if (!specifier.startsWith(".") || from.kind === "other" || target.startsWith("src/")) return;
1283
+ const kernel = /(^|\/)node_modules\/@ingenioz-it\/caits\/(.+)$/.exec(target)?.[2];
1284
+ if (kernel) return `This file imports ${target}, a file of the kernel, by its path. Import it by its name, @ingenioz-it/caits/${kernel.replace(/^src\//, "").replace(/\.(ts|js|mjs)$/, "")}.`;
1285
+ return `This file imports ${target}, outside src, but the production code imports nothing outside src: ${/^(test|quality)\//.test(target) ? "test/ and quality/ are" : "that folder is"} not part of the app. Move what it needs into ${contextFolderOf(file.path) ?? "src"}.`;
1286
+ }
1287
+ },
1288
+
1289
+ // Exceptions: they live in quality/, never in the code
1290
+
1291
+ {
1292
+ name: "exceptions-in-quality",
1293
+ description: "The code turns off no check: no comment that turns off a rule of ESLint (eslint-disable…), an error of TypeScript (@ts-ignore, @ts-expect-error, @ts-nocheck), lines of the coverage (v8 ignore, c8 ignore, istanbul ignore) or mutants (Stryker disable), and no test that does not run, runs alone or may fail (skip, skipIf, runIf, only, fails, fail, fixme). An exception lives in quality/, as narrow as it can be: a rule of ESLint turned off for one file in quality/eslint.config.js, a file out of the coverage in quality/vite.config.ts, a file out of the mutants in quality/stryker.config.mjs, a rule of quality/architecture.mjs that leaves one file aside.",
1294
+ on: "file",
1295
+ /** The comments that turn off a check: ESLint, TypeScript, the coverage (V8, c8, istanbul) and Stryker. */
1296
+ comments: /^[\s*]*(?:eslint-(?:disable|enable)|eslint-env\s|eslint\s+[@\w/-]+\s*:|@ts-(?:ignore|expect-error|nocheck)\b|(?:v8|c8|istanbul|node:coverage)\s+(?:ignore|disable)\b|[Ss]tryker\s+(?:disable|restore)\b)/,
1297
+ /** Where the exception of each tool lives, by the start of its comment. */
1298
+ fixes: [
1299
+ [/^[\s*]*eslint/, "Fix the code, or turn the rule off for this file only in quality/eslint.config.js."],
1300
+ [/^[\s*]*@ts-/, "Fix the type."],
1301
+ [/^[\s*]*(?:v8|c8|istanbul|node:coverage)/, "Test these lines, or leave this file out of the coverage in quality/vite.config.ts."],
1302
+ [/^[\s*]*[Ss]tryker/, "Kill the mutant with a test, or leave this file out of the mutants in quality/stryker.config.mjs."]
1303
+ ],
1304
+ /** The functions of Vitest and Playwright that keep a test from running, or from failing: what each one does. */
1305
+ skips: new Map([
1306
+ ["skip", "keeps the test from running"], ["skipIf", "keeps the test from running"], ["runIf", "keeps the test from running"], ["fixme", "keeps the test from running"],
1307
+ ["only", "keeps the other tests from running"], ["fails", "lets the test pass when it fails"], ["fail", "lets the test pass when it fails"]
1308
+ ]),
1309
+ testFunctions: new Set(["test", "it", "describe", "suite"]),
1310
+ /** @this {{ comments: RegExp; fixes: [RegExp, string][]; skips: Map<string, string>; testFunctions: Set<string> }} */
1311
+ check(file) {
1312
+ return [
1313
+ ...file.comments.flatMap((comment, index) => this.comments.test(comment) ? [at(file.lines.comments[index],
1314
+ `This comment turns off a check, \`${comment.trim()}\`, so the exception hides in the code. ${this.fixes.find(([start]) => start.test(comment))?.[1] ?? this.fixes[0][1]}`)] : []),
1315
+ ...file.calls.flatMap(({ name, root, line }) => name === null || root === null || name === root || !this.skips.has(name) || !this.testFunctions.has(root) ? [] : [at(line,
1316
+ `\`${root}.${name}\` ${this.skips.get(name)}, but every test runs, every time, and passes. Remove .${name}${name === "only" ? "" : ", and fix the test or the code"}.`)])
1317
+ ];
1318
+ }
466
1319
  }
467
1320
  ];