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