@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.
- package/LICENSE +5 -0
- package/README.md +36 -2
- package/bin/caits.js +184 -0
- package/bin/init.js +359 -0
- package/package.json +114 -4
- package/quality/architecture/adapters/marko.mjs +23 -0
- package/quality/architecture/adapters/scss.mjs +52 -0
- package/quality/architecture/adapters/typescript.mjs +13 -0
- package/quality/architecture/facts.mjs +159 -0
- package/quality/architecture/project.mjs +148 -0
- package/quality/architecture.mjs +116 -0
- package/quality/check.mjs +104 -0
- package/quality/dev.mjs +110 -0
- package/quality/e2e.mjs +125 -0
- package/quality/mutation-suite.mjs +31 -0
- package/quality/mutation-summary.mjs +41 -0
- package/quality/route-types.mjs +40 -0
- package/quality/test-docs/page.mjs +301 -0
- package/quality/test-docs/playwright-reporter.mjs +55 -0
- package/quality/test-docs/test-docs.mjs +180 -0
- package/quality/test-docs/vitest-reporter.mjs +38 -0
- package/src/DomainEvent.ts +4 -0
- package/src/Result.ts +6 -0
- package/src/Token.ts +8 -0
- package/src/di/Container.ts +123 -0
- package/src/di/DataDirectory.ts +6 -0
- package/src/di/Fetcher.ts +6 -0
- package/src/di/RequestContext.ts +6 -0
- package/src/di/backend.ts +19 -0
- package/src/di/createBackend.ts +106 -0
- package/src/di/createFrontend.ts +53 -0
- package/src/di/declareDependencies.js +93 -0
- package/src/di/discover.ts +39 -0
- package/src/di/discoverFrontends.ts +36 -0
- package/src/di/discovered.ts +6 -0
- package/src/di/discoveredFrontends.ts +6 -0
- package/src/di/frontend.ts +33 -0
- package/src/http/failureOf.ts +10 -0
- package/src/messaging/BackendEventBus.ts +60 -0
- package/src/messaging/ContextMap.ts +22 -0
- package/src/messaging/Controller.ts +109 -0
- package/src/messaging/EventPublisher.ts +12 -0
- package/src/messaging/EventSubscriber.ts +4 -0
- package/src/messaging/FrontendEventBus.ts +38 -0
- package/src/messaging/InProcessMessageBus.ts +35 -0
- package/src/messaging/Input.ts +4 -0
- package/src/messaging/MessageBus.ts +12 -0
- package/src/messaging/ReportError.ts +9 -0
- package/src/state/Loadable.ts +13 -0
- package/src/state/createStore.ts +45 -0
- package/src/state/follow.ts +7 -0
- package/src/state/request.ts +21 -0
- package/src/testing/backend/application.ts +12 -0
- package/src/testing/backend/applicationScenario.ts +60 -0
- package/src/testing/backend/applications.ts +62 -0
- package/src/testing/backend/context-map.ts +12 -0
- package/src/testing/backend/infra.ts +6 -0
- package/src/testing/backend/integration.ts +8 -0
- package/src/testing/backend/presentation.ts +14 -0
- package/src/testing/backend/presentationApplication.ts +25 -0
- package/src/testing/backend/presentationScenario.ts +47 -0
- package/src/testing/frontend/application.ts +12 -0
- package/src/testing/frontend/applicationScenario.ts +36 -0
- package/src/testing/frontend/applications.ts +70 -0
- package/src/testing/frontend/frontends.ts +28 -0
- package/src/testing/frontend/infra.ts +2 -0
- package/src/testing/frontend/marko.ts +12 -0
- package/src/testing/frontend/presentation.ts +7 -0
- package/src/testing/shared/FakeMessageBus.ts +58 -0
- package/src/testing/shared/tempDirectories.ts +15 -0
- package/src/virtual-modules.d.ts +33 -0
- package/src/vite.js +162 -0
- package/src/vite.types.d.ts +14 -0
- package/template/caits/architecture.mjs +467 -0
- package/template/caits/eslint.js +32 -0
- package/template/caits/marko-modules.d.ts +4 -0
- package/template/caits/playwright.ts +35 -0
- package/template/caits/stryker.mjs +20 -0
- package/template/caits/tsconfig.json +21 -0
- package/template/caits/tsconfig.mutation.json +5 -0
- package/template/caits/vite.ts +34 -0
- package/template/empty/src/routes/_frontend/+page.marko +9 -0
- package/template/empty/test/e2e/home.spec.ts +6 -0
- package/template/project/AGENTS +28 -0
- package/template/project/gitignore +16 -0
- package/template/project/nvmrc +1 -0
- package/template/project/quality/architecture.mjs +9 -0
- package/template/project/quality/eslint.config.js +6 -0
- package/template/project/quality/playwright.config.ts +10 -0
- package/template/project/quality/stryker.config.mjs +6 -0
- package/template/project/quality/vite.config.ts +5 -0
- 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
|
-
#
|
|
1
|
+
# CAITS
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|