@ingenioz-it/caits 0.0.0-stage → 0.5.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 (92) hide show
  1. package/LICENSE +5 -0
  2. package/README.md +36 -2
  3. package/bin/caits.js +184 -0
  4. package/bin/init.js +359 -0
  5. package/package.json +114 -4
  6. package/quality/architecture/adapters/marko.mjs +23 -0
  7. package/quality/architecture/adapters/scss.mjs +52 -0
  8. package/quality/architecture/adapters/typescript.mjs +13 -0
  9. package/quality/architecture/facts.mjs +159 -0
  10. package/quality/architecture/project.mjs +148 -0
  11. package/quality/architecture.mjs +116 -0
  12. package/quality/check.mjs +104 -0
  13. package/quality/dev.mjs +110 -0
  14. package/quality/e2e.mjs +125 -0
  15. package/quality/mutation-suite.mjs +31 -0
  16. package/quality/mutation-summary.mjs +41 -0
  17. package/quality/route-types.mjs +40 -0
  18. package/quality/test-docs/page.mjs +301 -0
  19. package/quality/test-docs/playwright-reporter.mjs +55 -0
  20. package/quality/test-docs/test-docs.mjs +180 -0
  21. package/quality/test-docs/vitest-reporter.mjs +38 -0
  22. package/src/DomainEvent.ts +4 -0
  23. package/src/Result.ts +6 -0
  24. package/src/Token.ts +8 -0
  25. package/src/di/Container.ts +123 -0
  26. package/src/di/DataDirectory.ts +6 -0
  27. package/src/di/Fetcher.ts +6 -0
  28. package/src/di/RequestContext.ts +6 -0
  29. package/src/di/backend.ts +19 -0
  30. package/src/di/createBackend.ts +106 -0
  31. package/src/di/createFrontend.ts +53 -0
  32. package/src/di/declareDependencies.js +93 -0
  33. package/src/di/discover.ts +39 -0
  34. package/src/di/discoverFrontends.ts +36 -0
  35. package/src/di/discovered.ts +6 -0
  36. package/src/di/discoveredFrontends.ts +6 -0
  37. package/src/di/frontend.ts +33 -0
  38. package/src/http/failureOf.ts +10 -0
  39. package/src/messaging/BackendEventBus.ts +60 -0
  40. package/src/messaging/ContextMap.ts +22 -0
  41. package/src/messaging/Controller.ts +109 -0
  42. package/src/messaging/EventPublisher.ts +12 -0
  43. package/src/messaging/EventSubscriber.ts +4 -0
  44. package/src/messaging/FrontendEventBus.ts +38 -0
  45. package/src/messaging/InProcessMessageBus.ts +35 -0
  46. package/src/messaging/Input.ts +4 -0
  47. package/src/messaging/MessageBus.ts +12 -0
  48. package/src/messaging/ReportError.ts +9 -0
  49. package/src/state/Loadable.ts +13 -0
  50. package/src/state/createStore.ts +45 -0
  51. package/src/state/follow.ts +7 -0
  52. package/src/state/request.ts +21 -0
  53. package/src/testing/backend/application.ts +12 -0
  54. package/src/testing/backend/applicationScenario.ts +60 -0
  55. package/src/testing/backend/applications.ts +62 -0
  56. package/src/testing/backend/context-map.ts +12 -0
  57. package/src/testing/backend/infra.ts +6 -0
  58. package/src/testing/backend/integration.ts +8 -0
  59. package/src/testing/backend/presentation.ts +14 -0
  60. package/src/testing/backend/presentationApplication.ts +25 -0
  61. package/src/testing/backend/presentationScenario.ts +47 -0
  62. package/src/testing/frontend/application.ts +12 -0
  63. package/src/testing/frontend/applicationScenario.ts +36 -0
  64. package/src/testing/frontend/applications.ts +70 -0
  65. package/src/testing/frontend/frontends.ts +28 -0
  66. package/src/testing/frontend/infra.ts +2 -0
  67. package/src/testing/frontend/marko.ts +12 -0
  68. package/src/testing/frontend/presentation.ts +7 -0
  69. package/src/testing/shared/FakeMessageBus.ts +58 -0
  70. package/src/testing/shared/tempDirectories.ts +15 -0
  71. package/src/virtual-modules.d.ts +33 -0
  72. package/src/vite.js +162 -0
  73. package/src/vite.types.d.ts +14 -0
  74. package/template/caits/architecture.mjs +467 -0
  75. package/template/caits/eslint.js +32 -0
  76. package/template/caits/marko-modules.d.ts +4 -0
  77. package/template/caits/playwright.ts +35 -0
  78. package/template/caits/stryker.mjs +20 -0
  79. package/template/caits/tsconfig.json +21 -0
  80. package/template/caits/tsconfig.mutation.json +5 -0
  81. package/template/caits/vite.ts +34 -0
  82. package/template/empty/src/routes/_frontend/+page.marko +9 -0
  83. package/template/empty/test/e2e/home.spec.ts +6 -0
  84. package/template/project/AGENTS +28 -0
  85. package/template/project/gitignore +16 -0
  86. package/template/project/nvmrc +1 -0
  87. package/template/project/quality/architecture.mjs +9 -0
  88. package/template/project/quality/eslint.config.js +6 -0
  89. package/template/project/quality/playwright.config.ts +10 -0
  90. package/template/project/quality/stryker.config.mjs +6 -0
  91. package/template/project/quality/vite.config.ts +5 -0
  92. package/template/project/tsconfig.json +4 -0
package/LICENSE ADDED
@@ -0,0 +1,5 @@
1
+ Copyright (C) 2026 by Ingenioz IT
2
+
3
+ Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
4
+
5
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,37 @@
1
- # Temporary Holding Version
1
+ # CAITS
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ CAITS (Clean Architecture In TypeScript) is the technical base of a Marko project with a backend and a frontend: a container for the dependencies, the message buses, the stores of the frontend, a Vite plugin and the test helpers. Its package is [`@ingenioz-it/caits`](https://www.npmjs.com/package/@ingenioz-it/caits), on npm.
4
+
5
+ The documentation is on [caits.dev](https://caits.dev): start with [the docs](https://caits.dev/docs).
6
+
7
+ ## Quick start
8
+
9
+ ```sh
10
+ npx @ingenioz-it/caits init my-app --example
11
+ cd my-app && npm run check
12
+ ```
13
+
14
+ `init` makes the project, then installs its packages and the browser of the end-to-end tests. With `--example`, the project is a small app that shows each layer and each kind of test; without it, the project has one empty page.
15
+
16
+ ## Requirements
17
+
18
+ - Node.js 22 (22.18 or later), 24, or 26 and later.
19
+ - git, for `init --example`: it gets the example with git.
20
+ - Linux, macOS or WSL 2. Native Windows is not supported: CAITS starts npm and npx without a shell, and stops the servers of the checks by their group of processes.
21
+
22
+ ## Work on CAITS
23
+
24
+ - To check CAITS, run `npm run check`: types, lint, tests and coverage. `npm run test:mutation` runs the mutation tests.
25
+ - The example of `caits init --example` is not in this repository: it is in [IngeniozIT/caits-example](https://github.com/IngeniozIT/caits-example). `npm run test:template` takes it from the folder `../caits-example`: clone it there.
26
+ - [CHANGELOG.md](CHANGELOG.md) tells the changes of each version.
27
+
28
+ ## Release a version
29
+
30
+ 1. Give the version in `package.json` (`npm version <version> --no-git-tag-version`) and in the title of its part of [CHANGELOG.md](CHANGELOG.md), then commit.
31
+ 2. Tag the same version in `caits-example`, and push the tag: CAITS `v0.5.0` writes the example of the tag `v0.5.0`.
32
+ 3. Push the tag of CAITS (`git tag v0.5.0 && git push origin v0.5.0`). The workflow [Release](.github/workflows/release.yml) runs the checks, then stages the version on npm: npm trusts this workflow, so the repository keeps no token. The workflow skips a version that npm has already.
33
+ 4. Make the version public, with the second factor of the npm account: `npm stage list @ingenioz-it/caits`, then `npm stage approve <id>`.
34
+
35
+ ## License
36
+
37
+ [0BSD](LICENSE): use, copy, change and share this code for any purpose, without asking, and without keeping this notice.
package/bin/caits.js ADDED
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env node
2
+ // The command of CAITS. Without installing CAITS first: npx @ingenioz-it/caits init my-app
3
+ import { existsSync } from "node:fs";
4
+ import { relative, resolve } from "node:path";
5
+ import { parseArgs } from "node:util";
6
+ import { defaultsVersions, DEFAULTS_FOLDER, EXAMPLE_SOURCE, init, install, otherVersions, SOURCE, update, VERSION } from "./init.js";
7
+
8
+ const USAGE = `Usage: caits <command>
9
+
10
+ init [folder] Makes a CAITS project in the folder, or adds CAITS to the project in it (the current folder if you give
11
+ none: it must have a package.json). It writes only the files that the project does not have, and adds to
12
+ package.json only the values that it does not have. Then it installs the packages and the browser of the
13
+ end-to-end tests.
14
+ --example Write a small app instead of an empty page: a menu, and a page where the user adds, edits and removes
15
+ messages, which the backend keeps in tmp/messages.json. Each layer and each kind of test is there. The
16
+ app is not in CAITS: git gets it from ${EXAMPLE_SOURCE}
17
+ --example-source <source>
18
+ Write the example of this source: a git repository, with # and a tag or a branch, or a folder.
19
+ --caits <source> Where npm gets CAITS. Default: ${SOURCE}
20
+ --no-install Write the files, but install nothing.
21
+ update Writes the defaults of the installed CAITS in ${DEFAULTS_FOLDER}/. Run it after you install another version.
22
+ dev Starts the development server.
23
+ check <script>… Runs the npm scripts of the checks at once. Shows the output of the checks that fail, and only theirs.
24
+ architecture Checks the rules of quality/architecture.mjs.
25
+ e2e Runs the end-to-end tests on the production build.
26
+ mutation-summary Gives the mutation score of each file (--survivors: the mutants that survived).
27
+ mutation-suite The unit tests, as Stryker runs them for each mutant.
28
+
29
+ --help, -h Shows this help, also after a command (caits init --help).
30
+ --version Shows the version of CAITS.`;
31
+
32
+ /** The commands that run a tool of quality/: their code is in the folder quality/ of CAITS. */
33
+ const TOOLS = new Set(["dev", "check", "architecture", "e2e", "mutation-summary", "mutation-suite"]);
34
+
35
+ /** The options of `caits init`. */
36
+ const INIT_OPTIONS = /** @type {const} */ ({ caits: { type: "string" }, example: { type: "boolean" }, "example-source": { type: "string" }, "no-install": { type: "boolean" } });
37
+
38
+ /** A command, an option or an argument that CAITS does not take: CAITS says why, then where to read the usage. */
39
+ class UsageError extends Error {}
40
+
41
+ /** @type {(count: number, noun: string) => string} */
42
+ const count = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
43
+
44
+ /** A folder as the shell reads it: in quotes when it has a space or another special character. */
45
+ const shellQuote = (/** @type {string} */ text) => /^[\w@%+=:,./-]+$/.test(text) ? text : `'${text.replaceAll("'", "'\\''")}'`;
46
+
47
+ const [command, ...rest] = process.argv.slice(2);
48
+
49
+ try {
50
+ const status = await main(command, rest);
51
+ if (typeof status === "number") process.exitCode = status;
52
+ } catch (error) {
53
+ // A known problem has a message that tells what to do: the stack of the code of CAITS does not help.
54
+ console.error(error instanceof Error ? error.message : error);
55
+ if (error instanceof UsageError) console.error("Run `caits --help`.");
56
+ process.exitCode = 1;
57
+ }
58
+
59
+ /**
60
+ * Runs a command. Gives its exit code, or nothing when it keeps running (the development server).
61
+ * @param {string | undefined} command
62
+ * @param {string[]} args
63
+ * @returns {Promise<number | void>}
64
+ */
65
+ async function main(command, args) {
66
+ if (command === undefined) {
67
+ console.error(USAGE);
68
+ return 1;
69
+ }
70
+ if (command === "--version") {
71
+ console.log(VERSION);
72
+ return 0;
73
+ }
74
+ if ([command, ...args].some(arg => arg === "--help" || arg === "-h")) {
75
+ console.log(USAGE);
76
+ return 0;
77
+ }
78
+ if (command === "init") return runInit(args);
79
+ if (command === "update") return runUpdate(args);
80
+ if (TOOLS.has(command)) {
81
+ if (command !== "mutation-suite") warnOfOldDefaults(process.cwd());
82
+ // The tools load Vite, Marko and the other packages of the project: only the command that runs imports its code.
83
+ const tool = /** @type {{ run: (args: string[]) => Promise<number | void> }} */ (await importTool(command));
84
+ return tool.run(args);
85
+ }
86
+ throw new UsageError(command.startsWith("-") ? `Unknown option: ${command}.` : `Unknown command: ${command}.`);
87
+ }
88
+
89
+ /**
90
+ * @param {string} name
91
+ * @returns {Promise<unknown>}
92
+ */
93
+ function importTool(name) {
94
+ return import(new URL(`../quality/${name}.mjs`, import.meta.url).href);
95
+ }
96
+
97
+ /**
98
+ * The options and the other arguments of a command. An option that the command does not know, an option without its value, or with
99
+ * a value that it does not take, is a UsageError.
100
+ * @param {string[]} args
101
+ * @param {Record<string, { type: "string" | "boolean" }>} options
102
+ */
103
+ function parseOptions(args, options) {
104
+ const { values, positionals, tokens } = parseArgs({ args, options, allowPositionals: true, strict: false, tokens: true });
105
+ for (const token of tokens) {
106
+ if (token.kind !== "option") continue;
107
+ const type = options[token.name]?.type;
108
+ if (!type) throw new UsageError(`Unknown option: ${token.rawName}.`);
109
+ const value = token.inlineValue ? token.value : token.value?.startsWith("-") ? undefined : token.value;
110
+ if (type === "string" && value === undefined) throw new UsageError(`The option ${token.rawName} needs a value: ${token.rawName} <value>.`);
111
+ if (type === "boolean" && token.value !== undefined) throw new UsageError(`The option ${token.rawName} takes no value.`);
112
+ }
113
+ return { values, positionals };
114
+ }
115
+
116
+ /**
117
+ * `caits init [folder]`.
118
+ * @param {string[]} args
119
+ * @returns {number}
120
+ */
121
+ function runInit(args) {
122
+ const { values, positionals } = parseOptions(args, INIT_OPTIONS);
123
+ const options = /** @type {{ caits?: string; example?: boolean; "example-source"?: string; "no-install"?: boolean }} */ (values);
124
+ if (positionals.length > 1) throw new UsageError(`init takes one folder, not ${positionals.length}: ${positionals.join(" ")}.`);
125
+ if (positionals.length === 0 && !existsSync("package.json")) {
126
+ console.error("This folder has no package.json. Give the folder of the new project (`caits init my-app`), or `caits init .` to use this folder.");
127
+ return 1;
128
+ }
129
+ const root = resolve(positionals[0] ?? ".");
130
+ const example = options["example-source"] ?? (options.example ? EXAMPLE_SOURCE : undefined);
131
+ const withInstall = !options["no-install"];
132
+ console.log(`CAITS ${VERSION}: ${root}\n`);
133
+ if (example) console.log(`The example: ${example}\n`);
134
+ const { written, kept, ignored, values: keptValues } = init(root, options.caits, { example });
135
+ console.log(`Wrote ${count(written.length, "file")}. CAITS owns those of ${DEFAULTS_FOLDER}/: the others are yours.`);
136
+ if (kept.length > 0) {
137
+ console.log(`Kept ${count(kept.length, "file")} that the project has already: ${kept.join(", ")}.`);
138
+ console.log(" Compare them with those of node_modules/@ingenioz-it/caits/template/project/ (without the dot at the start of the name).");
139
+ }
140
+ if (ignored.length > 0) console.log(`Added to .gitignore: ${ignored.join(" ")}`);
141
+ if (keptValues.length > 0) {
142
+ console.log("Kept the values that package.json has already:");
143
+ for (const { key, project, caits } of keptValues) console.log(` ${key}: ${JSON.stringify(project)} (CAITS: ${JSON.stringify(caits)})`);
144
+ }
145
+
146
+ const cd = relative(process.cwd(), root);
147
+ const steps = [...(cd ? [`cd ${shellQuote(cd)}`] : []), ...(withInstall ? [] : ["npm install", "npx playwright install chromium"])];
148
+ if (withInstall && !install(root)) {
149
+ console.error("\nThe installation failed. Correct the problem, then run the same command again: it keeps the files that it wrote.");
150
+ return 1;
151
+ }
152
+ console.log("\nNext:");
153
+ for (const step of [...steps, "npm run dev starts the app on http://localhost:3000", "npm run check runs all the checks"]) console.log(` ${step}`);
154
+ return 0;
155
+ }
156
+
157
+ /**
158
+ * `caits update`.
159
+ * @param {string[]} args
160
+ * @returns {number}
161
+ */
162
+ function runUpdate(args) {
163
+ if (args.length > 0) throw new UsageError(`update takes no argument: ${args.join(" ")}.`);
164
+ const written = update(process.cwd());
165
+ console.log(`CAITS ${VERSION}: wrote ${count(written.length, "file")} in ${DEFAULTS_FOLDER}/.`);
166
+ printOtherVersions(process.cwd());
167
+ return 0;
168
+ }
169
+
170
+ /** @param {string} root */
171
+ function printOtherVersions(root) {
172
+ const others = otherVersions(root);
173
+ if (others.length === 0) return;
174
+ console.log(`CAITS ${VERSION} suggests other versions of these packages. Change them in package.json if you agree:`);
175
+ for (const { key, project, caits } of others) console.log(` ${key}: ${JSON.stringify(project)} (CAITS suggests ${JSON.stringify(caits)})`);
176
+ }
177
+
178
+ /** @param {string} root */
179
+ function warnOfOldDefaults(root) {
180
+ const versions = defaultsVersions(root);
181
+ if (versions.length === 1 && versions[0] === VERSION) return;
182
+ const from = versions.length === 0 ? "is missing" : `comes from CAITS ${versions.join(", ")}`;
183
+ console.warn(`Warning: ${DEFAULTS_FOLDER}/ ${from}, but CAITS ${VERSION} is installed. Run \`npx caits update\`.\n`);
184
+ }
package/bin/init.js ADDED
@@ -0,0 +1,359 @@
1
+ // JavaScript, not TypeScript: npx runs this file from node_modules, where Node strips no types. TypeScript checks the types of the comments.
2
+ import { spawnSync } from "node:child_process";
3
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { basename, dirname, join, relative, sep } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ /** @typedef {{ [key: string]: unknown }} PackageJson */
9
+ /** @typedef {{ [name: string]: string }} Section */
10
+ /** @typedef {{ dependencies?: Section; devDependencies?: Section }} Packages */
11
+ /** @typedef {{ key: string; project: unknown; caits: string }} KeptValue */
12
+ /** @typedef {{ written: string[]; kept: string[]; ignored: string[]; values: KeptValue[] }} Report */
13
+
14
+ /**
15
+ * @param {URL | string} file
16
+ * @returns {unknown}
17
+ */
18
+ const readJson = file => JSON.parse(readFileSync(file, "utf8"));
19
+
20
+ /**
21
+ * A package.json: its object, or an error that names the file and tells what is wrong.
22
+ * @param {string} file
23
+ * @returns {PackageJson}
24
+ */
25
+ function readPackage(file) {
26
+ const text = readFileSync(file, "utf8");
27
+ /** @type {unknown} */
28
+ let project;
29
+ try {
30
+ project = JSON.parse(text);
31
+ } catch (error) {
32
+ throw new Error(`${file} is not valid JSON: ${/** @type {SyntaxError} */ (error).message}`, { cause: error });
33
+ }
34
+ if (typeof project !== "object" || project === null || Array.isArray(project)) throw new Error(`${file} is not valid: it must hold an object.`);
35
+ return /** @type {PackageJson} */ (project);
36
+ }
37
+
38
+ /**
39
+ * Why a command cannot start: most often, it is not installed.
40
+ * @param {string} command
41
+ * @param {Error} error
42
+ */
43
+ const notStarted = (command, error) => "code" in error && error.code === "ENOENT" ? `${command} is not installed, or is not in the PATH` : `${command} cannot start: ${error.message}`;
44
+
45
+ const OWN_PACKAGE = /** @type {{ version: string; devDependencies: Section }} */ (readJson(new URL("../package.json", import.meta.url)));
46
+
47
+ /** The version of CAITS, and where npm gets it: the npm registry, this version or a later one with the same minor version. */
48
+ export const VERSION = OWN_PACKAGE.version;
49
+ export const SOURCE = `^${VERSION}`;
50
+
51
+ /**
52
+ * The files of the project: `init` writes them once, and never changes them after. The files of quality/ import the defaults of CAITS and
53
+ * change them. Then comes the code: a first page (empty/), or the example.
54
+ * npm leaves `.gitignore` out of a package: the template names the dotfiles without their dot. It names AGENTS.md without its extension:
55
+ * an AI agent at work on CAITS would take it for the instructions of the folder template/project/.
56
+ */
57
+ const PROJECT_FILES = fileURLToPath(new URL("../template/project/", import.meta.url));
58
+ const EMPTY = fileURLToPath(new URL("../template/empty/", import.meta.url));
59
+ /** The files of the template that the project names otherwise. */
60
+ const NAMES = new Map([["gitignore", ".gitignore"], ["nvmrc", ".nvmrc"], ["AGENTS", "AGENTS.md"]]);
61
+
62
+ /**
63
+ * The example, a small app that shows each layer, is not in the package of CAITS: `init --example` gets it from its repository, at the
64
+ * tag of this version, with git over HTTPS: a public repository needs no SSH key. A source is a git repository, with `#` and a tag or a
65
+ * branch, or a folder.
66
+ * `init` takes the files of its folders src/ and test/, and the packages of its package.json.
67
+ */
68
+ export const EXAMPLE_SOURCE = `https://github.com/IngeniozIT/caits-example.git#v${VERSION}`;
69
+ const EXAMPLE_FOLDERS = ["src", "test"];
70
+
71
+ /** The defaults of CAITS: `init` and `update` write them in quality/caits/. CAITS owns this folder: `update` replaces it. */
72
+ const DEFAULTS = fileURLToPath(new URL("../template/caits/", import.meta.url));
73
+ export const DEFAULTS_FOLDER = "quality/caits";
74
+ /** @param {string} version */
75
+ const headerOf = version => `// CAITS ${version} wrote this file. Do not change it: \`npx caits update\` writes it again. Change the file of the project that uses it.\n`;
76
+ const HEADER = /^\/\/ CAITS (\S+) wrote this file\./;
77
+
78
+ /** What CAITS adds to the package.json of a project. The scripts run the tools with the configuration files of quality/. */
79
+ const PACKAGE = {
80
+ type: "module",
81
+ imports: {
82
+ "#backend/*": "./src/backend/*",
83
+ "#frontend/*": "./src/frontend/*"
84
+ },
85
+ engines: {
86
+ node: "^22.18.0 || ^24.0.0 || >=26.0.0"
87
+ },
88
+ scripts: {
89
+ dev: "caits dev",
90
+ build: "marko-run build --config quality/vite.config.ts",
91
+ start: "node dist/index.mjs",
92
+ typecheck: "npm run build && mtc",
93
+ lint: "npm run lint:code && npm run lint:architecture",
94
+ "lint:code": "eslint --config quality/eslint.config.js .",
95
+ "lint:architecture": "caits architecture",
96
+ test: "vitest run --config quality/vite.config.ts",
97
+ "test:unit": "vitest run --config quality/vite.config.ts --exclude '**/infra/**/*.test.ts' --exclude '**/*.integration.test.ts'",
98
+ "test:contracts": "vitest run --config quality/vite.config.ts /infra/",
99
+ "test:integration": "vitest run --config quality/vite.config.ts .integration.test.ts",
100
+ "test:e2e": "caits e2e",
101
+ "test:coverage": "vitest run --config quality/vite.config.ts --coverage --reporter=default --reporter=@ingenioz-it/caits/quality/test-docs/vitest-reporter",
102
+ "test:mutation": "stryker run quality/stryker.config.mjs && caits mutation-summary",
103
+ tdd: "vitest --config quality/vite.config.ts",
104
+ check: "caits check typecheck lint:code lint:architecture test:coverage test:mutation test:e2e"
105
+ }
106
+ };
107
+
108
+ /** The packages that a project needs. CAITS suggests the versions that it uses itself: those of its own devDependencies. */
109
+ const TOOLS = {
110
+ dependencies: ["@marko/run", "marko"],
111
+ devDependencies: [
112
+ "@eslint/js", "@marko/compiler", "@marko/type-check", "@playwright/test", "@stryker-mutator/core", "@stryker-mutator/typescript-checker",
113
+ "@types/node", "@vitest/coverage-v8", "eslint", "happy-dom", "sass", "typescript", "typescript-eslint", "vite", "vitest"
114
+ ]
115
+ };
116
+ /** @param {string[]} names */
117
+ const versionsOf = names => Object.fromEntries(names.map(name => [name, OWN_PACKAGE.devDependencies[name] ?? "*"]));
118
+ export const SUGGESTED = { dependencies: versionsOf(TOOLS.dependencies), devDependencies: versionsOf(TOOLS.devDependencies) };
119
+
120
+ const DEPENDENCY_KEYS = new Set(["dependencies", "devDependencies"]);
121
+
122
+ /**
123
+ * Makes a CAITS project in `root`, or adds CAITS to the project in it: writes the files of the project, with a first page or, with
124
+ * `example` (its source), the example; the defaults of CAITS (quality/caits/); the folders of the contexts; and adds to package.json what
125
+ * CAITS needs (`caits` is where npm gets CAITS), and the packages of the example. A file or a value that the project has already stays as
126
+ * it is: the report tells it. The lines of the template that .gitignore does not have go at its end. `init` installs nothing. When it
127
+ * cannot read the package.json of the project, or get the example, it throws, and writes nothing.
128
+ * @param {string} root
129
+ * @param {string} [caits]
130
+ * @param {{ example?: string }} [options]
131
+ * @returns {Report}
132
+ */
133
+ export function init(root, caits = SOURCE, { example } = {}) {
134
+ const manifest = join(root, "package.json");
135
+ const project = existsSync(manifest) ? readPackage(manifest) : { name: nameOf(root), private: true };
136
+ const code = example === undefined ? { folder: EMPTY, files: filesOf(EMPTY), packages: {}, remove() {} } : getExample(example);
137
+ try {
138
+ mkdirSync(root, { recursive: true });
139
+ /** @type {Report} */
140
+ const report = { written: [], kept: [], ignored: [], values: [] };
141
+ for (const { folder, files } of [{ folder: PROJECT_FILES, files: filesOf(PROJECT_FILES) }, code]) {
142
+ for (const file of files) {
143
+ const path = NAMES.get(file) ?? file;
144
+ const target = join(root, path);
145
+ if (path === ".gitignore" && existsSync(target)) report.ignored = addMissingLines(target, readFileSync(join(folder, file), "utf8"));
146
+ else if (existsSync(target)) report.kept.push(path);
147
+ else {
148
+ mkdirSync(dirname(target), { recursive: true });
149
+ copyFileSync(join(folder, file), target);
150
+ report.written.push(path);
151
+ }
152
+ }
153
+ }
154
+ report.written.push(...update(root));
155
+ for (const side of ["backend", "frontend"]) mkdirSync(join(root, "src", side), { recursive: true });
156
+ report.values = addToPackage(manifest, project, caits, code.packages);
157
+ return report;
158
+ } finally {
159
+ code.remove();
160
+ }
161
+ }
162
+
163
+ /**
164
+ * The example of a source: its folder, the files of its folders src/ and test/, and the packages of its package.json. A git source is
165
+ * cloned in a temporary folder: `remove` removes it, and an example that CAITS cannot read removes it at once.
166
+ * @param {string} source
167
+ * @returns {{ folder: string; files: string[]; packages: Packages; remove: () => void }}
168
+ */
169
+ function getExample(source) {
170
+ const local = existsSync(source) && statSync(source).isDirectory();
171
+ const folder = local ? source : cloneExample(source);
172
+ const remove = () => { if (!local) rmSync(folder, { recursive: true, force: true }); };
173
+ try {
174
+ if (!existsSync(join(folder, "src"))) throw new Error(`${source} is not an example of CAITS: it has no folder src/.`);
175
+ const files = EXAMPLE_FOLDERS.filter(name => existsSync(join(folder, name))).flatMap(name => filesOf(join(folder, name)).map(file => `${name}/${file}`));
176
+ const manifest = join(folder, "package.json");
177
+ const project = existsSync(manifest) ? readPackage(manifest) : {};
178
+ const packages = { dependencies: sectionOf(project, "dependencies"), devDependencies: sectionOf(project, "devDependencies") };
179
+ return { folder, files, packages, remove };
180
+ } catch (error) {
181
+ remove();
182
+ throw error;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Clones a git source (a repository, then `#` and a tag or a branch) in a temporary folder, without its history. Gives the folder.
188
+ * @param {string} source
189
+ * @returns {string}
190
+ */
191
+ function cloneExample(source) {
192
+ const hash = source.lastIndexOf("#");
193
+ const [repository, ref] = hash === -1 ? [source, undefined] : [source.slice(0, hash), source.slice(hash + 1)];
194
+ const folder = mkdtempSync(join(tmpdir(), "caits-example-"));
195
+ const args = ["clone", "--quiet", "--depth", "1", ...(ref ? ["--branch", ref] : []), "--", repository, folder];
196
+ const result = spawnSync("git", args, { encoding: "utf8", stdio: ["ignore", "ignore", "pipe"] });
197
+ if (result.status !== 0) {
198
+ rmSync(folder, { recursive: true, force: true });
199
+ if (result.error) throw new Error(`${notStarted("git", result.error)}: init needs it to get the example from ${source}.`);
200
+ throw new Error(`git cannot get the example from ${source}:\n${result.stderr}`);
201
+ }
202
+ return folder;
203
+ }
204
+
205
+ /**
206
+ * Writes the defaults of this version of CAITS in quality/caits/, in place of those that are there. Gives the files written.
207
+ * @param {string} root
208
+ * @returns {string[]}
209
+ */
210
+ export function update(root) {
211
+ const folder = join(root, DEFAULTS_FOLDER);
212
+ rmSync(folder, { recursive: true, force: true });
213
+ mkdirSync(folder, { recursive: true });
214
+ return filesOf(DEFAULTS).map(file => {
215
+ writeFileSync(join(folder, file), headerOf(VERSION) + readFileSync(join(DEFAULTS, file), "utf8"));
216
+ return `${DEFAULTS_FOLDER}/${file}`;
217
+ });
218
+ }
219
+
220
+ /**
221
+ * The versions of CAITS that wrote the files of quality/caits/: one version when they come from one update, none when there is no file.
222
+ * @param {string} root
223
+ * @returns {string[]}
224
+ */
225
+ export function defaultsVersions(root) {
226
+ const folder = join(root, DEFAULTS_FOLDER);
227
+ if (!existsSync(folder)) return [];
228
+ const versions = filesOf(folder).map(file => HEADER.exec(readFileSync(join(folder, file), "utf8"))?.[1] ?? "unknown");
229
+ return [...new Set(versions)];
230
+ }
231
+
232
+ /**
233
+ * The packages whose version in package.json is not the one that CAITS suggests. CAITS does not change them: the project chooses.
234
+ * @param {string} root
235
+ * @returns {KeptValue[]}
236
+ */
237
+ export function otherVersions(root) {
238
+ const file = join(root, "package.json");
239
+ if (!existsSync(file)) return [];
240
+ const project = readPackage(file);
241
+ return Object.values(SUGGESTED).flatMap(section => Object.entries(section).flatMap(([name, caits]) => {
242
+ const current = dependencyOf(project, name);
243
+ return current !== undefined && current !== caits ? [{ key: name, project: current, caits }] : [];
244
+ }));
245
+ }
246
+
247
+ /**
248
+ * Installs the packages of package.json, then the browser of the end-to-end tests. Gives false when a command fails.
249
+ * @param {string} root
250
+ * @returns {boolean}
251
+ */
252
+ export function install(root) {
253
+ return run("npm", ["install"], root) && run("npx", ["playwright", "install", "chromium"], root);
254
+ }
255
+
256
+ /**
257
+ * @param {string} command
258
+ * @param {string[]} args
259
+ * @param {string} cwd
260
+ */
261
+ function run(command, args, cwd) {
262
+ console.log(`\n> ${[command, ...args].join(" ")}`);
263
+ const result = spawnSync(command, args, { cwd, stdio: "inherit" });
264
+ if (result.error) console.error(`${notStarted(command, result.error)}.`);
265
+ return result.status === 0;
266
+ }
267
+
268
+ /**
269
+ * The files of a folder and of its sub-folders, from the folder, with "/" on every system. A symbolic link stops the reading: a link in
270
+ * an example could copy any file of the computer into the project.
271
+ * @param {string} folder
272
+ * @returns {string[]}
273
+ */
274
+ function filesOf(folder) {
275
+ return readdirSync(folder, { recursive: true, withFileTypes: true })
276
+ .filter(entry => {
277
+ if (entry.isSymbolicLink()) throw new Error(`${join(entry.parentPath, entry.name)} is a symbolic link: CAITS copies no link. Remove it, or put the file in its place.`);
278
+ return entry.isFile();
279
+ })
280
+ .map(entry => relative(folder, join(entry.parentPath, entry.name)).split(sep).join("/"))
281
+ .sort();
282
+ }
283
+
284
+ /**
285
+ * Adds at the end of a file the lines of `text` that it does not have, comments and empty lines aside. Gives the added lines.
286
+ * @param {string} file
287
+ * @param {string} text
288
+ * @returns {string[]}
289
+ */
290
+ function addMissingLines(file, text) {
291
+ const present = new Set(readFileSync(file, "utf8").split("\n").map(line => line.trim()));
292
+ const missing = text.split("\n").map(line => line.trim()).filter(line => line !== "" && !line.startsWith("#") && !present.has(line));
293
+ if (missing.length > 0) appendFileSync(file, `\n# CAITS\n${missing.join("\n")}\n`);
294
+ return missing;
295
+ }
296
+
297
+ /**
298
+ * Adds to a package.json (written when the project has none) the values that CAITS needs and that it does not have, and the packages
299
+ * with the versions that CAITS suggests, then those of the example. A package is present when the dependencies or the devDependencies
300
+ * name it. Gives the values that the project has already, and that differ.
301
+ * @param {string} file
302
+ * @param {PackageJson} project the values of the file, or those of a new project
303
+ * @param {string} caits
304
+ * @param {Packages} example
305
+ * @returns {KeptValue[]}
306
+ */
307
+ function addToPackage(file, project, caits, example) {
308
+ const dependencies = { ...SUGGESTED.dependencies, ...example.dependencies };
309
+ const devDependencies = { "@ingenioz-it/caits": caits, ...SUGGESTED.devDependencies, ...example.devDependencies };
310
+ const wanted = { ...PACKAGE, dependencies, devDependencies };
311
+ /** @type {KeptValue[]} */
312
+ const kept = [];
313
+ for (const [key, value] of Object.entries(wanted)) {
314
+ const present = project[key];
315
+ if (typeof value === "string") {
316
+ if (present === undefined) project[key] = value;
317
+ else if (present !== value) kept.push({ key, project: present, caits: value });
318
+ continue;
319
+ }
320
+ for (const [name, item] of Object.entries(value)) {
321
+ const current = DEPENDENCY_KEYS.has(key) ? dependencyOf(project, name) : sectionOf(project, key)?.[name];
322
+ if (current === undefined) project[key] = { ...sectionOf(project, key), [name]: item };
323
+ else if (current !== item) kept.push({ key: `${key}.${name}`, project: current, caits: item });
324
+ }
325
+ }
326
+ for (const key of DEPENDENCY_KEYS) {
327
+ const section = sectionOf(project, key);
328
+ if (section) project[key] = Object.fromEntries(Object.entries(section).sort(([a], [b]) => a.localeCompare(b)));
329
+ }
330
+ writeFileSync(file, `${JSON.stringify(project, null, 2)}\n`);
331
+ return kept;
332
+ }
333
+
334
+ /**
335
+ * @param {PackageJson} project
336
+ * @param {string} key
337
+ * @returns {Section | undefined}
338
+ */
339
+ function sectionOf(project, key) {
340
+ const section = project[key];
341
+ return typeof section === "object" && section !== null ? /** @type {Section} */ (section) : undefined;
342
+ }
343
+
344
+ /**
345
+ * The version of a package in the dependencies or the devDependencies of a project.
346
+ * @param {PackageJson} project
347
+ * @param {string} name
348
+ */
349
+ function dependencyOf(project, name) {
350
+ return sectionOf(project, "dependencies")?.[name] ?? sectionOf(project, "devDependencies")?.[name];
351
+ }
352
+
353
+ /**
354
+ * The name of a new package: the name of its folder, as npm accepts it.
355
+ * @param {string} folder
356
+ */
357
+ function nameOf(folder) {
358
+ return basename(folder).toLowerCase().replace(/[^a-z0-9._~-]+/g, "-").replace(/^[._-]+/, "") || "app";
359
+ }