@ingenioz-it/caits 0.5.0 → 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 +19 -12
- package/bin/init.js +16 -7
- 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 +29 -12
- package/quality/mutation.mjs +29 -0
- 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,30 +1,45 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, posix, relative, resolve } from "node:path";
|
|
2
3
|
|
|
3
4
|
/** What the rules know of the project: where a file lives, what it imports, and how the parts are named. */
|
|
4
5
|
|
|
5
6
|
/** @import { Facts } from "./facts.mjs" */
|
|
6
7
|
/** @typedef {"backend" | "frontend"} Side */
|
|
7
8
|
/**
|
|
8
|
-
* Where a file lives: see locate. Only a layer has a context and a layer, and only a layer and the routes have a side.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* | { kind: "
|
|
9
|
+
* Where a file lives: see locate. Only a layer has a context and a layer, and only a layer and the routes have a side. A file of a use
|
|
10
|
+
* case has its use case too: command/add-note.
|
|
11
|
+
* @typedef {{ kind: "other" | "test-environment"; side?: undefined; context?: undefined; layer?: undefined; useCase?: undefined }
|
|
12
|
+
* | { kind: "routes"; side: Side; context?: undefined; layer?: undefined; useCase?: undefined }
|
|
13
|
+
* | { kind: "layer"; side: Side; context: string; layer: string; useCase?: string }} Location
|
|
12
14
|
*/
|
|
13
15
|
/** A file of the project: its path from the root of the project, and what the rules read in it. @typedef {Facts & { path: string }} File */
|
|
14
|
-
/** The project: its root, its files, and their paths. @typedef {{ root: string; files: File[]; paths: Set<string> }} Project */
|
|
15
16
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* @typedef {
|
|
17
|
+
* The packages that each layer may import, by the key of its list (see LISTS): a package (`uuid`), a module (`@ingenioz-it/caits/Token`), a
|
|
18
|
+
* Node API (`node:crypto`), or a folder of modules (`@ingenioz-it/caits/messaging/*`). A layer without a list imports any package.
|
|
19
|
+
* @typedef {Partial<Record<"domain" | "application" | "presentation" | "infra" | "di" | "contextMap", string[]>>} Packages
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* The project: its root, its files, their paths, and the packages that each layer may import (quality/imports.mjs).
|
|
23
|
+
* @typedef {{ root: string; files: File[]; paths: Set<string>; packages: Packages }} Project
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* An import of the production code: its file, where the file and its target live, the specifier, the target (a path from the root of the
|
|
27
|
+
* project when the import is internal, else the specifier), and the line of the import.
|
|
28
|
+
* @typedef {{ file: File; from: Location; to: Location; specifier: string; internal: boolean; target: string; line: number }} Import
|
|
29
|
+
*/
|
|
30
|
+
/** A message, at a line of the file: the rule points at one construct (an import, a class, a call…). @typedef {{ line?: number; message: string }} Located */
|
|
31
|
+
/**
|
|
32
|
+
* What a check gives: nothing, or false, when the rule holds; else a message, or a list of messages. A message is a text, or a text with
|
|
33
|
+
* its line. The message of a rule on the imports is at the line of the import, unless it gives another.
|
|
34
|
+
* @typedef {string | Located | (string | Located)[] | false | null | undefined | void} Messages
|
|
19
35
|
*/
|
|
20
|
-
/** What a check gives: nothing, or false, when the rule holds; else a message, or a list of messages. @typedef {string | string[] | false | null | undefined | void} Messages */
|
|
21
36
|
/**
|
|
22
37
|
* A rule of the architecture: see the rules of CAITS (quality/caits/architecture.mjs). A rule can keep values of its own, which its check
|
|
23
38
|
* reads with `this`: a project can change them.
|
|
24
39
|
* @typedef {{ name: string; description: string; [value: string]: unknown } & (
|
|
25
|
-
* | { on: "import"; check(link: Import): Messages }
|
|
40
|
+
* | { on: "import"; check(link: Import, project: Project): Messages }
|
|
26
41
|
* | { on: "file"; production?: boolean; check(file: File, project: Project): Messages }
|
|
27
|
-
* | { on: "project"; check(project: Project): { file: string; message: string }[] }
|
|
42
|
+
* | { on: "project"; check(project: Project): { file: string; line?: number; message: string }[] }
|
|
28
43
|
* )} Rule
|
|
29
44
|
*/
|
|
30
45
|
|
|
@@ -39,7 +54,9 @@ export const kernelPathOf = specifier => specifier.startsWith(`${KERNEL.name}/`)
|
|
|
39
54
|
|
|
40
55
|
/**
|
|
41
56
|
* Where a file lives: a test environment (test.di.ts), the routes of one side, the context map, a layer of a context (its di.ts is a
|
|
42
|
-
* layer too), or elsewhere.
|
|
57
|
+
* layer too), or elsewhere. The folders of a context say its layers: a use case, in command/ or query/, is of the application, but its
|
|
58
|
+
* controllers ({usecase}.{name}.controller.ts) and their tests ({usecase}.{name}.presentation.test.ts), which are of the presentation;
|
|
59
|
+
* the events of the frontend, in event/, are of the application.
|
|
43
60
|
* @param {string} path
|
|
44
61
|
* @returns {Location}
|
|
45
62
|
*/
|
|
@@ -47,11 +64,16 @@ export function locate(path) {
|
|
|
47
64
|
const parts = path.split("/");
|
|
48
65
|
if (parts[0] !== "src") return { kind: "other" };
|
|
49
66
|
if (parts[1] === "routes") return { kind: "routes", side: parts[2] === "_frontend" ? "frontend" : "backend" };
|
|
50
|
-
|
|
67
|
+
|
|
51
68
|
if (/^test\.di\.(js|ts)$/.test(parts[parts.length - 1])) return { kind: "test-environment" };
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
return { kind: "
|
|
69
|
+
if (parts[1] !== "backend" && parts[1] !== "frontend") return { kind: "other" };
|
|
70
|
+
const [, side, context, folder] = parts;
|
|
71
|
+
if (parts.length === 4) return { kind: "layer", context, side, layer: /^context-map(\.test)?\.(js|ts)$/.test(folder) ? "map" : "di" };
|
|
72
|
+
if ((folder === "command" || folder === "query") && parts.length === 6) {
|
|
73
|
+
const layer = /\.(controller|presentation\.test)\.(js|ts)$/.test(parts[5]) ? "presentation" : "application";
|
|
74
|
+
return { kind: "layer", context, side, layer, useCase: `${folder}/${parts[4]}` };
|
|
75
|
+
}
|
|
76
|
+
return { kind: "layer", context, side, layer: ["command", "query", "event"].includes(folder) ? "application" : folder };
|
|
55
77
|
}
|
|
56
78
|
|
|
57
79
|
/** The domain and the application: the code that holds no technology. @type {(where: Location) => boolean} */
|
|
@@ -62,13 +84,34 @@ export const isTestCode = path => /\.(test|contract)\.ts$|(^|\/)test\.di\.ts$/.t
|
|
|
62
84
|
|
|
63
85
|
/**
|
|
64
86
|
* The shortcuts of the imports, to the folders they lead to. #context leads to the context of the file: each context has a package.json
|
|
65
|
-
* that says it. #shared leads to the backend context shared
|
|
66
|
-
* #backend and #frontend lead to the sides, for the routes: the package.json of the
|
|
87
|
+
* that says it. #shared leads to the backend context shared, and #frontend-shared to the frontend context shared: tsconfig.json says it
|
|
88
|
+
* to TypeScript, and the kernel's plugin to Vite. #backend and #frontend lead to the sides, for the routes: the package.json of the
|
|
89
|
+
* project says it.
|
|
67
90
|
*/
|
|
68
|
-
const SHORTCUTS = new Map([
|
|
91
|
+
const SHORTCUTS = new Map([
|
|
92
|
+
["shared", "src/backend/shared"], ["frontend-shared", "src/frontend/shared"], ["backend", "src/backend"], ["frontend", "src/frontend"]
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
/** The folder of the tags of the components of the frontend context shared, as the marko.json of the frontend gives it. */
|
|
96
|
+
const SHARED_TAGS = "shared/presentation/component";
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether src/frontend/marko.json gives Marko the folder of the components of the frontend context shared as a folder of tags
|
|
100
|
+
* ("tags-dir": "./shared/presentation/component", alone or among others).
|
|
101
|
+
* @param {string} root
|
|
102
|
+
* @returns {boolean}
|
|
103
|
+
*/
|
|
104
|
+
export function givesSharedTags(root) {
|
|
105
|
+
const file = join(root, "src/frontend/marko.json");
|
|
106
|
+
if (!existsSync(file)) return false;
|
|
107
|
+
/** @type {unknown} */
|
|
108
|
+
const read = JSON.parse(readFileSync(file, "utf8"));
|
|
109
|
+
const taglib = /** @type {{ "tags-dir"?: string | string[] }} */ (read);
|
|
110
|
+
return [taglib["tags-dir"] ?? []].flat().some(folder => posix.normalize(folder).replace(/\/$/, "") === SHARED_TAGS);
|
|
111
|
+
}
|
|
69
112
|
|
|
70
113
|
/** The folder of the context that holds a file (src/backend/notes), or undefined. @type {(path: string) => string | undefined} */
|
|
71
|
-
const contextFolderOf = path => /^src\/(backend|frontend)\/[^/]+(?=\/)/.exec(path)?.[0];
|
|
114
|
+
export const contextFolderOf = path => /^src\/(backend|frontend)\/[^/]+(?=\/)/.exec(path)?.[0];
|
|
72
115
|
|
|
73
116
|
/**
|
|
74
117
|
* An import names a package (the kernel is one), or a file of the project: its target is then the path from the root of the project.
|
|
@@ -95,6 +138,38 @@ export const importedModules = (file, root) => file.imports
|
|
|
95
138
|
.filter(({ internal }) => internal)
|
|
96
139
|
.map(({ target }) => target.replace(/\.(js|ts)$/, ""));
|
|
97
140
|
|
|
141
|
+
/** A class that a file names: its name, and its module (the file of the project without its extension, or the specifier of a package). @typedef {{ name: string; module: string }} Named */
|
|
142
|
+
/**
|
|
143
|
+
* A line of a context map: whether it shares (share) or hears (when), the event that starts it, the classes that it builds, as the
|
|
144
|
+
* file imports them, and its line in the file. A name that the file does not import has no module: the line names a class of its own file.
|
|
145
|
+
* @typedef {{ shares: boolean; source: string; event: Named | null; gives: Named[]; line: number }} MapLine
|
|
146
|
+
*/
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The lines of a context map, `export default [when(Event, …), share(Event, …)]`, in their order. Its other items are left aside.
|
|
150
|
+
* @param {File} file
|
|
151
|
+
* @param {string} root
|
|
152
|
+
* @returns {MapLine[]}
|
|
153
|
+
*/
|
|
154
|
+
export function linesOf(file, root) {
|
|
155
|
+
/** @type {(name: string | null) => Named | null} */
|
|
156
|
+
const named = name => {
|
|
157
|
+
const imported = file.imported.find(entry => entry.name === name);
|
|
158
|
+
if (!name || !imported) return null;
|
|
159
|
+
const { internal, target } = resolveImport(file.path, imported.from, root);
|
|
160
|
+
return { name, module: internal ? target.replace(/\.(js|ts)$/, "") : target };
|
|
161
|
+
};
|
|
162
|
+
return (file.defaultArray ?? [])
|
|
163
|
+
.filter(({ chain }) => chain.length === 1 && (chain[0] === "when" || chain[0] === "share"))
|
|
164
|
+
.map(({ chain: [call], source, subject, constructed, line }) => ({
|
|
165
|
+
shares: call === "share",
|
|
166
|
+
source,
|
|
167
|
+
event: named(subject),
|
|
168
|
+
gives: constructed.map(named).filter(/** @returns {given is Named} */ given => given !== null),
|
|
169
|
+
line
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
|
|
98
173
|
/** @type {(specifier: string, ...names: string[]) => boolean} */
|
|
99
174
|
export const isPackage = (specifier, ...names) => names.some(name => specifier === name || specifier.startsWith(`${name}/`));
|
|
100
175
|
/** @type {(specifier: string) => boolean} */
|
|
@@ -128,7 +203,24 @@ export function adapterOf(path) {
|
|
|
128
203
|
}
|
|
129
204
|
|
|
130
205
|
/** The contexts whose frontend a file takes: `frontendOf($global, "notes")` takes the frontend of notes. @type {(file: File) => string[]} */
|
|
131
|
-
export const frontendsOf = file => file.
|
|
206
|
+
export const frontendsOf = file => frontendCallsOf(file).map(({ context }) => context);
|
|
207
|
+
/** Each call of a file that takes a frontend: its context, and its line. @type {(file: File) => { context: string; line: number }[]} */
|
|
208
|
+
export const frontendCallsOf = file => file.calls.flatMap(({ name, args: [, context], line }) => name === "frontendOf" && typeof context === "string" ? [{ context, line }] : []);
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The key of the list of each layer in quality/imports.mjs. The layers of a context have one each; the routes and the files out of the
|
|
212
|
+
* contexts have none.
|
|
213
|
+
* @type {Record<string, keyof Packages>}
|
|
214
|
+
*/
|
|
215
|
+
export const LISTS = { domain: "domain", application: "application", presentation: "presentation", infra: "infra", di: "di", map: "contextMap" };
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Whether a list of quality/imports.mjs holds an import of a package: the package, the module or the Node API itself, or a folder of
|
|
219
|
+
* modules that holds it (`@ingenioz-it/caits/messaging/*` holds `@ingenioz-it/caits/messaging/Input`).
|
|
220
|
+
* @param {string} specifier
|
|
221
|
+
* @param {string[]} list
|
|
222
|
+
*/
|
|
223
|
+
export const isListed = (specifier, list) => list.some(entry => entry.endsWith("/*") ? specifier.startsWith(entry.slice(0, -1)) : specifier === entry);
|
|
132
224
|
|
|
133
225
|
/** @type {(name: string) => string} */
|
|
134
226
|
export const kebab = name => name.replace(/(?<!^)([A-Z])/g, "-$1").toLowerCase();
|
package/quality/architecture.mjs
CHANGED
|
@@ -4,67 +4,76 @@ import { pathToFileURL } from "node:url";
|
|
|
4
4
|
import { readMarko } from "./architecture/adapters/marko.mjs";
|
|
5
5
|
import { readScss } from "./architecture/adapters/scss.mjs";
|
|
6
6
|
import { readTypeScript } from "./architecture/adapters/typescript.mjs";
|
|
7
|
-
import { isTestCode, locate, resolveImport } from "./architecture/project.mjs";
|
|
7
|
+
import { isTestCode, LISTS, locate, resolveImport } from "./architecture/project.mjs";
|
|
8
|
+
import { painterOf } from "./terminal.mjs";
|
|
8
9
|
|
|
9
10
|
/** What a rule knows of the project. The rules of CAITS use it, and the rules of a project can use it too. */
|
|
10
11
|
export * from "./architecture/project.mjs";
|
|
11
12
|
|
|
12
|
-
/** @import { File, Import, Messages, Rule } from "./architecture/project.mjs" */
|
|
13
|
-
/** @typedef {{ rule: string; file: string; message: string;
|
|
13
|
+
/** @import { File, Import, Located, Messages, Packages, Rule } from "./architecture/project.mjs" */
|
|
14
|
+
/** A rule that a file breaks: the rule, the file, the message, and the line of the construct that breaks it, if the rule points at one. @typedef {{ rule: string; file: string; message: string; line?: number }} Violation */
|
|
14
15
|
|
|
15
16
|
/** The file of a project that gives its rules: its default export is the list of the rules. */
|
|
16
17
|
export const RULES_FILE = "quality/architecture.mjs";
|
|
18
|
+
/** The file of a project that gives the packages that each layer may import: its default export. Without it, those of CAITS apply. */
|
|
19
|
+
export const PACKAGES_FILE = "quality/imports.mjs";
|
|
20
|
+
/** The packages that each layer may import in CAITS (`caits update` writes them): their export `allowed`. */
|
|
21
|
+
export const DEFAULT_PACKAGES_FILE = "quality/caits/imports.mjs";
|
|
17
22
|
|
|
18
23
|
/**
|
|
19
|
-
* Checks the rules on the files of a project. Each source is a file: its path from the root of the project, and its text.
|
|
20
|
-
*
|
|
24
|
+
* Checks the rules on the files of a project. Each source is a file: its path from the root of the project, and its text. The packages
|
|
25
|
+
* are those that each layer may import: the rules read them in the project.
|
|
26
|
+
* Gives the production files, the test files, and each violation: its rule, its file, its message, and its line.
|
|
21
27
|
* @param {string} root
|
|
22
28
|
* @param {{ path: string; text: string }[]} sources
|
|
23
29
|
* @param {Rule[]} rules
|
|
30
|
+
* @param {Packages} [packages]
|
|
24
31
|
* @returns {{ production: number; tests: number; violations: Violation[] }}
|
|
25
32
|
*/
|
|
26
|
-
export function checkArchitecture(root, sources, rules) {
|
|
33
|
+
export function checkArchitecture(root, sources, rules, packages = {}) {
|
|
27
34
|
checkRules(rules);
|
|
35
|
+
checkPackages(packages);
|
|
28
36
|
const paths = new Set(sources.map(({ path }) => path));
|
|
29
37
|
/** @type {(path: string, text: string) => import("./architecture/facts.mjs").Facts} */
|
|
30
38
|
const read = (path, text) => {
|
|
31
|
-
if (path.endsWith(".marko")) return readMarko(join(root, path), text);
|
|
39
|
+
if (path.endsWith(".marko")) return readMarko(join(root, path), text, root);
|
|
32
40
|
return path.endsWith(".scss") ? readScss(path, text, paths) : readTypeScript(path, text);
|
|
33
41
|
};
|
|
34
42
|
/** @type {File[]} */
|
|
35
43
|
const files = sources.map(({ path, text }) => ({ path, ...read(path, text) }));
|
|
36
44
|
const production = files.filter(file => !isTestCode(file.path));
|
|
37
|
-
const project = { root, files, paths };
|
|
45
|
+
const project = { root, files, paths, packages };
|
|
38
46
|
|
|
39
47
|
/** @type {Violation[]} */
|
|
40
48
|
const violations = [];
|
|
41
|
-
/** @type {(rule: Rule, file: string,
|
|
42
|
-
const report = (rule, file,
|
|
43
|
-
/** @type {(result: Messages) =>
|
|
44
|
-
const messagesOf = result => !result ? [] : Array.isArray(result) ? result : [result]
|
|
49
|
+
/** @type {(rule: Rule, file: string, located: Located) => void} */
|
|
50
|
+
const report = (rule, file, { line, message }) => { violations.push({ rule: rule.name, file, message, ...(line === undefined ? {} : { line }) }); };
|
|
51
|
+
/** The messages of a check, each with its line, or the line of the import for a rule on the imports. @type {(result: Messages, line?: number) => Located[]} */
|
|
52
|
+
const messagesOf = (result, line) => (!result ? [] : Array.isArray(result) ? result : [result])
|
|
53
|
+
.map(message => typeof message === "string" ? { line, message } : { ...message, line: message.line ?? line });
|
|
45
54
|
|
|
46
55
|
for (const rule of rules) {
|
|
47
56
|
if (rule.on === "file") {
|
|
48
57
|
for (const file of rule.production ? production : files) {
|
|
49
|
-
for (const
|
|
58
|
+
for (const located of messagesOf(rule.check(file, project))) report(rule, file.path, located);
|
|
50
59
|
}
|
|
51
60
|
}
|
|
52
61
|
if (rule.on === "project") {
|
|
53
|
-
for (const { file,
|
|
62
|
+
for (const { file, ...located } of rule.check(project)) report(rule, file, located);
|
|
54
63
|
}
|
|
55
64
|
}
|
|
56
65
|
|
|
57
66
|
const importRules = rules.filter(/** @returns {rule is Extract<Rule, { on: "import" }>} */ rule => rule.on === "import");
|
|
58
67
|
for (const file of production) {
|
|
59
68
|
const from = locate(file.path);
|
|
60
|
-
|
|
69
|
+
file.imports.forEach((specifier, index) => {
|
|
61
70
|
const { internal, target } = resolveImport(file.path, specifier, root);
|
|
62
71
|
/** @type {Import} */
|
|
63
|
-
const link = { file, from, to: internal ? locate(target) : { kind: "other" }, specifier, internal, target };
|
|
72
|
+
const link = { file, from, to: internal ? locate(target) : { kind: "other" }, specifier, internal, target, line: file.lines.imports[index] };
|
|
64
73
|
for (const rule of importRules) {
|
|
65
|
-
for (const
|
|
74
|
+
for (const located of messagesOf(rule.check(link, project), link.line)) report(rule, file.path, located);
|
|
66
75
|
}
|
|
67
|
-
}
|
|
76
|
+
});
|
|
68
77
|
}
|
|
69
78
|
|
|
70
79
|
return { production: production.length, tests: files.length - production.length, violations };
|
|
@@ -88,9 +97,35 @@ function checkRules(rules) {
|
|
|
88
97
|
}
|
|
89
98
|
}
|
|
90
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Each list of packages has the key of a layer (see LISTS), and holds names of packages.
|
|
102
|
+
* @param {unknown} packages
|
|
103
|
+
* @returns {asserts packages is Packages}
|
|
104
|
+
*/
|
|
105
|
+
function checkPackages(packages) {
|
|
106
|
+
const keys = new Set(Object.values(LISTS));
|
|
107
|
+
const lists = packages !== null && typeof packages === "object" && !Array.isArray(packages) ? Object.entries(packages) : null;
|
|
108
|
+
const wrong = lists?.find(([key, list]) => !keys.has(/** @type {keyof Packages} */ (key)) || !Array.isArray(list) || list.some(entry => typeof entry !== "string"));
|
|
109
|
+
if (!lists || wrong) {
|
|
110
|
+
throw new Error(`${PACKAGES_FILE} must export the packages that each layer may import, as lists of names: { ${[...keys].map(key => `${key}: […]`).join(", ")} }${wrong ? ` (${wrong[0]})` : ""}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
91
114
|
/** @type {(file: string) => Promise<unknown>} */
|
|
92
115
|
const importFile = file => import(pathToFileURL(file).href);
|
|
93
116
|
|
|
117
|
+
/**
|
|
118
|
+
* The packages that each layer of a project may import: those of its quality/imports.mjs, else those of CAITS (quality/caits/imports.mjs),
|
|
119
|
+
* else none: then every layer imports any package.
|
|
120
|
+
* @param {string} root
|
|
121
|
+
* @returns {Promise<unknown>}
|
|
122
|
+
*/
|
|
123
|
+
async function packagesOf(root) {
|
|
124
|
+
if (existsSync(join(root, PACKAGES_FILE))) return /** @type {{ default: unknown }} */ (await importFile(join(root, PACKAGES_FILE))).default;
|
|
125
|
+
if (existsSync(join(root, DEFAULT_PACKAGES_FILE))) return /** @type {{ allowed: unknown }} */ (await importFile(join(root, DEFAULT_PACKAGES_FILE))).allowed;
|
|
126
|
+
return {};
|
|
127
|
+
}
|
|
128
|
+
|
|
94
129
|
/** `caits architecture`: checks the rules of quality/architecture.mjs on the files of src/ and test/: the code, and the stylesheets (SCSS). */
|
|
95
130
|
export async function run() {
|
|
96
131
|
const root = process.cwd();
|
|
@@ -104,13 +139,24 @@ export async function run() {
|
|
|
104
139
|
const sources = [...walk(join(root, "src")), ...walk(join(root, "test"))]
|
|
105
140
|
.filter(path => /\.(ts|marko|scss)$/.test(path))
|
|
106
141
|
.map(absolute => ({ path: relative(root, absolute).replaceAll("\\", "/"), text: readFileSync(absolute, "utf8") }));
|
|
107
|
-
const
|
|
142
|
+
const packages = /** @type {Packages} */ (await packagesOf(root));
|
|
143
|
+
const { production, tests, violations } = checkArchitecture(root, sources, rules, packages);
|
|
108
144
|
|
|
109
145
|
if (violations.length === 0) {
|
|
110
|
-
console.log(`Architecture: ${production} production files and ${tests} test files respect the ${rules.length} rules.`);
|
|
146
|
+
console.log(painterOf(process.stdout)("green", `Architecture: ${production} production files and ${tests} test files respect the ${rules.length} rules.`));
|
|
111
147
|
return 0;
|
|
112
148
|
}
|
|
113
|
-
|
|
114
|
-
|
|
149
|
+
const paint = painterOf(process.stderr);
|
|
150
|
+
for (const violation of violations) console.error(lineOf(violation, paint));
|
|
151
|
+
console.error(`\n${paint(["red", "bold"], `${violations.length} architecture ${violations.length === 1 ? "violation" : "violations"}.`)}`);
|
|
115
152
|
return 1;
|
|
116
153
|
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The line of a violation: the file and its line in cyan, the rule dim, then the message.
|
|
157
|
+
* @param {Violation} violation
|
|
158
|
+
* @param {import("./terminal.mjs").Paint} paint
|
|
159
|
+
*/
|
|
160
|
+
export function lineOf({ rule, file, message, line }, paint) {
|
|
161
|
+
return `${paint("cyan", `${file}${line ? `:${line}` : ""}`)} ${paint("dim", `[${rule}]`)} ${message}`;
|
|
162
|
+
}
|
package/quality/check.mjs
CHANGED
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
// The builds of Marko and the starts of Vitest write their files in the build folder: the checks that run at once would empty the
|
|
4
4
|
// folder of each other. Each check gets a folder of its own in CAITS_OUT_DIR, which quality/caits/vite.ts reads. The end-to-end tests
|
|
5
5
|
// build in dist/ all the same: `npm start` runs dist/index.mjs.
|
|
6
|
+
// In a terminal, the report has colors, and so has the output of a check that fails: the checks run with FORCE_COLOR. In a pipe (an AI
|
|
7
|
+
// agent, a log), the report and the checks have none: they run with NO_COLOR.
|
|
6
8
|
import { spawn } from "node:child_process";
|
|
7
9
|
import { existsSync, readFileSync } from "node:fs";
|
|
8
10
|
import { constants } from "node:os";
|
|
9
11
|
import { join } from "node:path";
|
|
12
|
+
import { colors, painterOf } from "./terminal.mjs";
|
|
10
13
|
|
|
11
14
|
export const OUT_DIRS = "node_modules/.caits/check";
|
|
12
15
|
|
|
@@ -26,16 +29,17 @@ export async function run(args) {
|
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
31
|
* Runs each npm script of `scripts` in the project of `root`, all at once, each in a group of processes of its own: the end of the
|
|
29
|
-
* command, or Ctrl+C, stops the servers and the workers that a check starts. `print` gets each line of the report
|
|
30
|
-
* 0 when all the checks pass.
|
|
32
|
+
* command, or Ctrl+C, stops the servers and the workers that a check starts. `print` gets each line of the report, and `paint` colors
|
|
33
|
+
* its parts. Gives the exit code: 0 when all the checks pass.
|
|
31
34
|
* @param {string} root
|
|
32
35
|
* @param {string[]} scripts
|
|
33
36
|
* @param {(line: string) => void} [print]
|
|
37
|
+
* @param {import("./terminal.mjs").Paint} [paint]
|
|
34
38
|
* @returns {Promise<number>}
|
|
35
39
|
*/
|
|
36
|
-
export async function check(root, scripts, print = line => console.log(line)) {
|
|
40
|
+
export async function check(root, scripts, print = line => console.log(line), paint = painterOf(process.stdout)) {
|
|
37
41
|
if (scripts.length === 0) {
|
|
38
|
-
print("Give the npm scripts of the checks: caits check typecheck lint test");
|
|
42
|
+
print(paint("red", "Give the npm scripts of the checks: caits check typecheck lint test"));
|
|
39
43
|
return 1;
|
|
40
44
|
}
|
|
41
45
|
// npm says nothing of a missing script when it runs silent: the command names them, and runs nothing.
|
|
@@ -43,14 +47,14 @@ export async function check(root, scripts, print = line => console.log(line)) {
|
|
|
43
47
|
const known = existsSync(manifest) ? /** @type {{ scripts?: Record<string, string> }} */ (readJson(manifest)).scripts ?? {} : {};
|
|
44
48
|
const missing = scripts.filter(script => !Object.hasOwn(known, script));
|
|
45
49
|
if (missing.length > 0) {
|
|
46
|
-
print(`package.json has no script ${missing.join(", ")}.`);
|
|
50
|
+
print(paint("red", `package.json has no script ${missing.join(", ")}.`));
|
|
47
51
|
return 1;
|
|
48
52
|
}
|
|
49
53
|
const width = Math.max(...scripts.map(script => script.length));
|
|
50
54
|
const started = Date.now();
|
|
51
|
-
print(`${scripts.length} checks at once: ${scripts.join(", ")}`);
|
|
55
|
+
print(paint("dim", `${scripts.length} checks at once: ${scripts.join(", ")}`));
|
|
52
56
|
|
|
53
|
-
const steps = scripts.map(script => start(root, script));
|
|
57
|
+
const steps = scripts.map(script => start(root, script, colors(paint)));
|
|
54
58
|
const stop = () => { for (const step of steps) step.stop(); };
|
|
55
59
|
/** @param {NodeJS.Signals} signal Ctrl+C (SIGINT) exits with 130, SIGTERM with 143, as a shell tells a command that a signal stops. */
|
|
56
60
|
const interrupt = signal => { stop(); process.exit(128 + constants.signals[signal]); };
|
|
@@ -59,15 +63,15 @@ export async function check(root, scripts, print = line => console.log(line)) {
|
|
|
59
63
|
try {
|
|
60
64
|
const results = await Promise.all(steps.map(async step => {
|
|
61
65
|
const result = await step.done;
|
|
62
|
-
print(`${result.passed ? "✓" : "✗"} ${step.script.padEnd(width)} ${durationOf(result.duration)}`);
|
|
66
|
+
print(`${result.passed ? paint("green", "✓") : paint("red", "✗")} ${step.script.padEnd(width)} ${paint("dim", durationOf(result.duration))}`);
|
|
63
67
|
return { script: step.script, ...result };
|
|
64
68
|
}));
|
|
65
69
|
const failed = results.filter(result => !result.passed);
|
|
66
|
-
for (const { script, output } of failed) print(`\n
|
|
70
|
+
for (const { script, output } of failed) print(`\n${paint("bold", `── ${script} ──`)}\n${output.trimEnd()}`);
|
|
67
71
|
const total = durationOf(Date.now() - started);
|
|
68
72
|
print(failed.length === 0
|
|
69
|
-
? `\
|
|
70
|
-
: `\n${failed.length} of ${results.length} checks failed: ${failed.map(({ script }) => script).join(", ")}.`);
|
|
73
|
+
? `\n${paint(["green", "bold"], `All ${results.length} checks passed in ${total}.`)}`
|
|
74
|
+
: `\n${paint(["red", "bold"], `${failed.length} of ${results.length} checks failed: ${failed.map(({ script }) => script).join(", ")}.`)}`);
|
|
71
75
|
return failed.length === 0 ? 0 : 1;
|
|
72
76
|
} finally {
|
|
73
77
|
process.off("SIGINT", interrupt);
|
|
@@ -77,13 +81,15 @@ export async function check(root, scripts, print = line => console.log(line)) {
|
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
/**
|
|
80
|
-
* Starts `npm run <script>`, with its build folder, and keeps its output.
|
|
84
|
+
* Starts `npm run <script>`, with its build folder, and keeps its output: with the colors of its tool when the report has colors.
|
|
81
85
|
* @param {string} root
|
|
82
86
|
* @param {string} script
|
|
87
|
+
* @param {boolean} colored
|
|
83
88
|
*/
|
|
84
|
-
function start(root, script) {
|
|
89
|
+
function start(root, script, colored) {
|
|
85
90
|
const began = Date.now();
|
|
86
|
-
const
|
|
91
|
+
const { FORCE_COLOR: _force, NO_COLOR: _no, ...rest } = process.env;
|
|
92
|
+
const env = { ...rest, ...(colored ? { FORCE_COLOR: "1" } : { NO_COLOR: "1" }), CAITS_OUT_DIR: join(OUT_DIRS, script.replaceAll(":", "-")) };
|
|
87
93
|
const child = spawn("npm", ["run", "--silent", script], { cwd: root, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
88
94
|
let output = "";
|
|
89
95
|
child.stdout.on("data", chunk => { output += chunk; });
|
package/quality/dev.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { existsSync } from "node:fs";
|
|
12
12
|
import adapter from "@marko/run/adapter";
|
|
13
13
|
import { loadConfigFromFile } from "vite";
|
|
14
|
+
import { painterOf } from "./terminal.mjs";
|
|
14
15
|
|
|
15
16
|
export const VITE_CONFIG = "quality/vite.config.ts";
|
|
16
17
|
export const OUT_DIR = "node_modules/.caits/dev";
|
|
@@ -78,7 +79,7 @@ export async function run(args) {
|
|
|
78
79
|
await restartOnce();
|
|
79
80
|
} while (changedAgain);
|
|
80
81
|
})().catch(error => {
|
|
81
|
-
console.error(`\
|
|
82
|
+
console.error(`\n${painterOf(process.stderr)("red", `The development server stopped: ${String(error instanceof Error ? error.message : error)}\nCorrect the error, then start the server again: npm run dev`)}`);
|
|
82
83
|
process.exit(1);
|
|
83
84
|
}).finally(() => {
|
|
84
85
|
restarting = undefined;
|
|
@@ -95,7 +96,7 @@ async function configurationLoads() {
|
|
|
95
96
|
await loadConfigFromFile({ command: "serve", mode: "development" }, VITE_CONFIG, process.cwd(), "silent");
|
|
96
97
|
return true;
|
|
97
98
|
} catch (error) {
|
|
98
|
-
console.error(`\n${VITE_CONFIG} does not load: the server keeps the configuration that it has.\n${error instanceof Error ? error.message : String(error)}`);
|
|
99
|
+
console.error(`\n${painterOf(process.stderr)("red", `${VITE_CONFIG} does not load: the server keeps the configuration that it has.\n${error instanceof Error ? error.message : String(error)}`)}`);
|
|
99
100
|
return false;
|
|
100
101
|
}
|
|
101
102
|
}
|
|
@@ -1,31 +1,82 @@
|
|
|
1
1
|
// `caits mutation-suite`: the command that Stryker runs for each mutant. It runs the unit tests from a private copy of the sandbox.
|
|
2
2
|
// Stryker runs many suites at once in a single sandbox, and every Vitest start rewrites the files that the Marko plugin generates
|
|
3
3
|
// (dist/, .marko-run/): suites that run at the same time would fail at random, and a failed run counts as a killed mutant.
|
|
4
|
+
// The copy shares the packages of the sandbox, not the caches that the tools keep beside them (node_modules/.vite/, .vitest-cache/…): the
|
|
5
|
+
// cache of Vite depends on the folder of the project, so each suite would write it again while the others read it.
|
|
4
6
|
import { spawn } from "node:child_process";
|
|
5
|
-
import { cpSync, existsSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
|
|
7
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync } from "node:fs";
|
|
6
8
|
import { constants, tmpdir } from "node:os";
|
|
7
9
|
import { join } from "node:path";
|
|
10
|
+
import { painterOf } from "./terminal.mjs";
|
|
8
11
|
|
|
9
12
|
const ENTRIES = ["src", "test", "quality", "dist", ".marko-run", "package.json", "tsconfig.json"];
|
|
10
13
|
|
|
14
|
+
/** The report of Vitest, in the copy: Vitest writes it once the tests ran, also when one fails. */
|
|
15
|
+
const REPORT = "vitest-report.json";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What the suite prints when the unit tests did not run. Stryker counts the mutant as killed, as for any exit code but 0:
|
|
19
|
+
* `caits mutation-summary` finds these words in the report of Stryker, and does not.
|
|
20
|
+
*/
|
|
21
|
+
export const NOT_RUN = "The unit tests did not run";
|
|
22
|
+
|
|
23
|
+
/** The copies, in the temporary folder: `caits-mutation-<pid of their suite>-<random>`. */
|
|
24
|
+
const COPY = /^caits-mutation-(\d+)-/;
|
|
25
|
+
|
|
11
26
|
/** @param {string[]} args the options of Vitest, which Stryker gives */
|
|
12
27
|
export async function run(args) {
|
|
28
|
+
removeOrphans();
|
|
13
29
|
const sandbox = process.cwd();
|
|
14
|
-
const copy = mkdtempSync(join(tmpdir(),
|
|
30
|
+
const copy = mkdtempSync(join(tmpdir(), `caits-mutation-${process.pid}-`));
|
|
15
31
|
const remove = () => rmSync(copy, { recursive: true, force: true });
|
|
16
32
|
for (const entry of ENTRIES) {
|
|
17
33
|
if (existsSync(join(sandbox, entry))) cpSync(join(sandbox, entry), join(copy, entry), { recursive: true });
|
|
18
34
|
}
|
|
19
|
-
|
|
35
|
+
// The packages and their commands (.bin/). The other entries that start with a dot are the caches and the files of the tools.
|
|
36
|
+
mkdirSync(join(copy, "node_modules"));
|
|
37
|
+
for (const entry of readdirSync(join(sandbox, "node_modules"))) {
|
|
38
|
+
if (!entry.startsWith(".") || entry === ".bin") symlinkSync(join(sandbox, "node_modules", entry), join(copy, "node_modules", entry));
|
|
39
|
+
}
|
|
20
40
|
|
|
21
41
|
// The suite builds in the dist/ of its copy: the build folder that `caits check` gives (CAITS_OUT_DIR) is in node_modules/, which the
|
|
22
42
|
// suites share.
|
|
23
43
|
const { CAITS_OUT_DIR: _, ...env } = process.env;
|
|
24
|
-
const
|
|
25
|
-
|
|
44
|
+
const vitest = ["vitest", "run", "--config", "quality/vite.config.ts", "--reporter=dot", "--reporter=json", `--outputFile.json=${REPORT}`, ...args];
|
|
45
|
+
const suite = spawn("npx", vitest, { cwd: copy, env, stdio: "inherit" });
|
|
46
|
+
// Ctrl+C and `caits check` stop the suite: the copy goes away with it. The exit code tells the signal: 130 for SIGINT, 143 for SIGTERM.
|
|
26
47
|
for (const signal of /** @type {const} */ (["SIGINT", "SIGTERM"])) process.on(signal, () => { suite.kill(signal); remove(); process.exit(128 + constants.signals[signal]); });
|
|
27
48
|
/** @type {number} */
|
|
28
49
|
const status = await new Promise(resolve => suite.on("close", code => resolve(code ?? 1)));
|
|
50
|
+
const ran = existsSync(join(copy, REPORT));
|
|
29
51
|
remove();
|
|
52
|
+
// Vitest stopped before the tests, on an error of its own or of the system: no test failed, and the mutant is not tested.
|
|
53
|
+
if (status !== 0 && !ran) console.error(painterOf(process.stderr)("red", `${NOT_RUN}: Vitest stopped before them (exit ${status}). The mutant is not tested.`));
|
|
30
54
|
return status;
|
|
31
55
|
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Stryker stops a suite that takes too long with SIGKILL, which no code can follow: its copy stays. Each suite removes the copies whose
|
|
59
|
+
* suite no longer runs.
|
|
60
|
+
*/
|
|
61
|
+
function removeOrphans() {
|
|
62
|
+
for (const entry of readdirSync(tmpdir())) {
|
|
63
|
+
const pid = COPY.exec(entry)?.[1];
|
|
64
|
+
if (pid === undefined || runs(Number(pid))) continue;
|
|
65
|
+
try {
|
|
66
|
+
rmSync(join(tmpdir(), entry), { recursive: true, force: true });
|
|
67
|
+
} catch {
|
|
68
|
+
// The copy of another user.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @param {number} pid */
|
|
74
|
+
function runs(pid) {
|
|
75
|
+
try {
|
|
76
|
+
process.kill(pid, 0);
|
|
77
|
+
return true;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
// EPERM: the process runs, but belongs to another user.
|
|
80
|
+
return /** @type {NodeJS.ErrnoException} */ (error).code === "EPERM";
|
|
81
|
+
}
|
|
82
|
+
}
|