@ingenioz-it/caits 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/bin/caits.js +17 -12
  2. package/bin/init.js +15 -6
  3. package/package.json +1 -1
  4. package/quality/architecture/adapters/marko.mjs +7 -4
  5. package/quality/architecture/adapters/scss.mjs +29 -8
  6. package/quality/architecture/adapters/typescript.mjs +2 -2
  7. package/quality/architecture/facts.mjs +158 -31
  8. package/quality/architecture/project.mjs +121 -27
  9. package/quality/architecture.mjs +68 -22
  10. package/quality/check.mjs +20 -14
  11. package/quality/dev.mjs +3 -2
  12. package/quality/mutation-suite.mjs +56 -5
  13. package/quality/mutation-summary.mjs +28 -11
  14. package/quality/mutation.mjs +5 -6
  15. package/quality/terminal.mjs +30 -0
  16. package/quality/test-docs/test-docs.mjs +1 -2
  17. package/src/DomainEvent.ts +4 -1
  18. package/src/di/Container.ts +5 -1
  19. package/src/di/createBackend.ts +60 -15
  20. package/src/di/createFrontend.ts +7 -4
  21. package/src/di/declareDependencies.js +25 -5
  22. package/src/di/discover.ts +52 -13
  23. package/src/di/discoverFrontends.ts +30 -11
  24. package/src/di/discovered.ts +3 -3
  25. package/src/di/discoveredFrontends.ts +3 -3
  26. package/src/di/frontend.ts +46 -4
  27. package/src/messaging/BackendEventBus.ts +6 -6
  28. package/src/messaging/ContextMap.ts +30 -7
  29. package/src/messaging/Controller.ts +2 -3
  30. package/src/messaging/EventPublisher.ts +2 -2
  31. package/src/messaging/FrontendContextMap.ts +33 -0
  32. package/src/messaging/InProcessMessageBus.ts +5 -7
  33. package/src/messaging/MessageBus.ts +1 -2
  34. package/src/state/createStore.ts +1 -2
  35. package/src/testing/backend/application.ts +1 -1
  36. package/src/testing/backend/applicationScenario.ts +6 -7
  37. package/src/testing/backend/applications.ts +7 -6
  38. package/src/testing/backend/context-map.ts +5 -2
  39. package/src/testing/backend/integration.ts +1 -1
  40. package/src/testing/frontend/applicationScenario.ts +2 -3
  41. package/src/testing/frontend/applications.ts +3 -3
  42. package/src/testing/frontend/context-map.ts +6 -0
  43. package/src/testing/frontend/frontends.ts +17 -11
  44. package/src/virtual-modules.d.ts +3 -3
  45. package/src/vite.js +18 -13
  46. package/template/caits/architecture.mjs +1040 -187
  47. package/template/caits/eslint.js +18 -0
  48. package/template/caits/imports.mjs +14 -0
  49. package/template/caits/tsconfig.json +1 -1
  50. package/template/caits/vite.ts +7 -3
  51. package/template/project/AGENTS +9 -5
  52. package/template/project/quality/imports.mjs +10 -0
  53. package/src/messaging/Input.ts +0 -4
@@ -1,30 +1,45 @@
1
- import { dirname, posix, relative, resolve } from "node:path";
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
- * @typedef {{ kind: "other" | "map" | "test-environment"; side?: undefined; context?: undefined; layer?: undefined }
10
- * | { kind: "routes"; side: Side; context?: undefined; layer?: undefined }
11
- * | { kind: "layer"; side: Side; context: string; layer: string }} Location
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
- * An import of the production code: its file, where the file and its target live, the specifier, and the target (a path from the root of
17
- * the project when the import is internal, else the specifier).
18
- * @typedef {{ file: File; from: Location; to: Location; specifier: string; internal: boolean; target: string }} Import
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
- if (parts[1] === "backend" && parts.length === 3) return { kind: "map" };
67
+
51
68
  if (/^test\.di\.(js|ts)$/.test(parts[parts.length - 1])) return { kind: "test-environment" };
52
- if ((parts[1] === "backend" || parts[1] === "frontend") && parts.length === 4) return { kind: "layer", context: parts[2], side: parts[1], layer: "di" };
53
- if (parts[1] === "backend" || parts[1] === "frontend") return { kind: "layer", context: parts[2], side: parts[1], layer: parts[3] };
54
- return { kind: "other" };
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: tsconfig.json says it to TypeScript, and the kernel's plugin to Vite.
66
- * #backend and #frontend lead to the sides, for the routes: the package.json of the project says it.
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([["shared", "src/backend/shared"], ["backend", "src/backend"], ["frontend", "src/frontend"]]);
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} */
@@ -115,20 +190,39 @@ export function portOf(path) {
115
190
  }
116
191
 
117
192
  /**
118
- * An adapter, or its test: {context}/infra/{folder}/{file}. It implements the port named after its folder.
193
+ * An adapter, or its test: {context}/infra/{folder}/{file}. Its port is in the folder of the same name in the domain, domain/port/{folder}/:
194
+ * the module of that folder that is not its contract, whatever its name. The module is null when the domain has no such port.
119
195
  * @param {string} path
120
- * @returns {{ folder: string; test: boolean; port: string; portModule: string } | null}
196
+ * @param {Set<string>} paths
197
+ * @returns {{ folder: string; test: boolean; portModule: string | null } | null}
121
198
  */
122
- export function adapterOf(path) {
199
+ export function adapterOf(path, paths) {
123
200
  const match = /^(src\/(?:backend|frontend)\/[^/]+)\/infra\/([a-z0-9-]+)\/([^/]+)$/.exec(path);
124
201
  if (!match) return null;
125
202
  const [, context, folder, name] = match;
126
- const port = pascal(folder);
127
- return { folder, test: name.endsWith(".test.ts"), port, portModule: `${context}/domain/port/${folder}/${port}` };
203
+ const port = [...paths].sort().map(portOf).find(found => found !== null && !found.contract && found.module.startsWith(`${context}/domain/port/${folder}/`));
204
+ return { folder, test: name.endsWith(".test.ts"), portModule: port?.module ?? null };
128
205
  }
129
206
 
130
207
  /** 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.calls.flatMap(({ name, args: [, context] }) => name === "frontendOf" && typeof context === "string" ? [context] : []);
208
+ export const frontendsOf = file => frontendCallsOf(file).map(({ context }) => context);
209
+ /** Each call of a file that takes a frontend: its context, and its line. @type {(file: File) => { context: string; line: number }[]} */
210
+ export const frontendCallsOf = file => file.calls.flatMap(({ name, args: [, context], line }) => name === "frontendOf" && typeof context === "string" ? [{ context, line }] : []);
211
+
212
+ /**
213
+ * 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
214
+ * contexts have none.
215
+ * @type {Record<string, keyof Packages>}
216
+ */
217
+ export const LISTS = { domain: "domain", application: "application", presentation: "presentation", infra: "infra", di: "di", map: "contextMap" };
218
+
219
+ /**
220
+ * 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
221
+ * modules that holds it (`@ingenioz-it/caits/messaging/*` holds `@ingenioz-it/caits/messaging/EventPublisher`).
222
+ * @param {string} specifier
223
+ * @param {string[]} list
224
+ */
225
+ export const isListed = (specifier, list) => list.some(entry => entry.endsWith("/*") ? specifier.startsWith(entry.slice(0, -1)) : specifier === entry);
132
226
 
133
227
  /** @type {(name: string) => string} */
134
228
  export const kebab = name => name.replace(/(?<!^)([A-Z])/g, "-$1").toLowerCase();
@@ -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; subject?: string }} Violation */
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
- * Gives the production files, the test files, and each violation: its rule, its file, its message, and the import that breaks it.
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, message: string, subject?: string) => void} */
42
- const report = (rule, file, message, subject) => { violations.push({ rule: rule.name, file, message, subject }); };
43
- /** @type {(result: Messages) => string[]} */
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 message of messagesOf(rule.check(file, project))) report(rule, file.path, message);
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, message } of rule.check(project)) report(rule, file, message);
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
- for (const specifier of file.imports) {
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 message of messagesOf(rule.check(link))) report(rule, file.path, message, target);
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 { production, tests, violations } = checkArchitecture(root, sources, rules);
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
- for (const { rule, file, message, subject } of violations) console.error(`${file}${subject ? ` → ${subject}` : ""}: ${message} (${rule})`);
114
- console.error(`\n${violations.length} architecture ${violations.length === 1 ? "violation" : "violations"}.`);
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. Gives the exit code:
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── ${script} ──\n${output.trimEnd()}`);
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
- ? `\nAll ${results.length} checks passed in ${total}.`
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 env = { ...process.env, CAITS_OUT_DIR: join(OUT_DIRS, script.replaceAll(":", "-")) };
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(`\nThe development server stopped: ${error instanceof Error ? error.message : error}\nCorrect the error, then start the server again: npm run dev`);
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(), "caits-mutation-"));
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
- symlinkSync(join(sandbox, "node_modules"), join(copy, "node_modules"));
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 suite = spawn("npx", ["vitest", "run", "--config", "quality/vite.config.ts", "--reporter=dot", ...args], { cwd: copy, env, stdio: "inherit" });
25
- // Stryker stops a suite that takes too long: the copy goes away with it. The exit code tells the signal: 130 for SIGINT, 143 for SIGTERM.
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
+ }