@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/package.json CHANGED
@@ -1,6 +1,116 @@
1
1
  {
2
2
  "name": "@ingenioz-it/caits",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.5.0",
4
+ "description": "CAITS (Clean Architecture In TypeScript): the technical base of a Marko project with a backend and a frontend.",
5
+ "keywords": [
6
+ "clean-architecture",
7
+ "typescript",
8
+ "marko",
9
+ "marko-run",
10
+ "vite",
11
+ "vitest",
12
+ "ddd",
13
+ "hexagonal-architecture",
14
+ "starter",
15
+ "template"
16
+ ],
17
+ "homepage": "https://caits.dev",
18
+ "bugs": "https://github.com/IngeniozIT/caits/issues",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/IngeniozIT/caits.git"
22
+ },
23
+ "author": "Ingenioz IT",
24
+ "license": "0BSD",
25
+ "type": "module",
26
+ "bin": {
27
+ "caits": "bin/caits.js"
28
+ },
29
+ "exports": {
30
+ "./vite": {
31
+ "types": "./src/vite.types.d.ts",
32
+ "default": "./src/vite.js"
33
+ },
34
+ "./quality/*": "./quality/*.mjs",
35
+ "./*": "./src/*.ts"
36
+ },
37
+ "files": [
38
+ "bin",
39
+ "quality",
40
+ "src",
41
+ "template",
42
+ "!bin/**/*.test.ts",
43
+ "!quality/**/*.test.mjs",
44
+ "!src/**/*.test.ts"
45
+ ],
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "engines": {
50
+ "node": "^22.18.0 || ^24.0.0 || >=26.0.0"
51
+ },
52
+ "scripts": {
53
+ "typecheck": "tsc -p .",
54
+ "lint": "eslint .",
55
+ "test": "vitest run",
56
+ "test:watch": "vitest",
57
+ "test:coverage": "vitest run --coverage",
58
+ "test:mutation": "stryker run stryker.config.json",
59
+ "test:template": "node test/template.js",
60
+ "check": "npm run typecheck && npm run lint && npm run test:coverage"
61
+ },
62
+ "dependencies": {
63
+ "magic-string": "^0.30.21"
64
+ },
65
+ "peerDependencies": {
66
+ "@eslint/js": "^10.0.1",
67
+ "@marko/compiler": "^5.42.10",
68
+ "@marko/run": "^0.11.13",
69
+ "@playwright/test": "^1.64.0",
70
+ "typescript-eslint": "^8.71.0",
71
+ "vite": "^8.0.0",
72
+ "vitest": "^5.0.0"
73
+ },
74
+ "peerDependenciesMeta": {
75
+ "@eslint/js": {
76
+ "optional": true
77
+ },
78
+ "@marko/compiler": {
79
+ "optional": true
80
+ },
81
+ "@marko/run": {
82
+ "optional": true
83
+ },
84
+ "@playwright/test": {
85
+ "optional": true
86
+ },
87
+ "typescript-eslint": {
88
+ "optional": true
89
+ },
90
+ "vite": {
91
+ "optional": true
92
+ },
93
+ "vitest": {
94
+ "optional": true
95
+ }
96
+ },
97
+ "devDependencies": {
98
+ "@eslint/js": "^10.0.1",
99
+ "@marko/compiler": "^5.42.10",
100
+ "@marko/run": "^0.11.13",
101
+ "@marko/type-check": "^3.2.1",
102
+ "@playwright/test": "^1.64.0",
103
+ "@stryker-mutator/core": "^10.0.0",
104
+ "@stryker-mutator/typescript-checker": "^10.0.0",
105
+ "@types/node": "^26.6.4",
106
+ "@vitest/coverage-v8": "^5.0.3",
107
+ "eslint": "^10.11.0",
108
+ "happy-dom": "^20.14.5",
109
+ "marko": "^6.4.0",
110
+ "sass": "^1.105.1",
111
+ "typescript": "^6.0.3",
112
+ "typescript-eslint": "^8.71.0",
113
+ "vite": "^8.3.2",
114
+ "vitest": "^5.0.3"
115
+ }
116
+ }
@@ -0,0 +1,23 @@
1
+ import compiler from "@marko/compiler";
2
+ import { dirname, relative } from "node:path";
3
+ import { factsOf } from "../facts.mjs";
4
+
5
+ /**
6
+ * Reads a Marko template (its absolute path, and its text) with the Marko compiler: it parses the markup and the JavaScript in it,
7
+ * and translates nothing.
8
+ * A tag that Marko finds by discovery (in a tags/ folder of the project) is a dependency without an import: it counts as one.
9
+ * @param {string} file
10
+ * @param {string} text
11
+ */
12
+ export function readMarko(file, text) {
13
+ const { ast } = compiler.compileSync(text, file, { output: "source", ast: true, code: false, sourceMaps: false });
14
+ const facts = factsOf(ast.program, text);
15
+ const lookup = compiler.taglib.buildLookup(dirname(file));
16
+ for (const name of new Set(facts.tags)) {
17
+ const template = lookup.getTag(name)?.template;
18
+ if (!template || template === file || template.includes("/node_modules/")) continue;
19
+ const specifier = relative(dirname(file), template).replaceAll("\\", "/");
20
+ facts.imports.push(specifier.startsWith(".") ? specifier : `./${specifier}`);
21
+ }
22
+ return facts;
23
+ }
@@ -0,0 +1,52 @@
1
+ import { dirname, posix } from "node:path";
2
+
3
+ /** A string, or a comment: the comments are removed, the strings stay (a string can hold "//", as a URL does). */
4
+ const STRING_OR_COMMENT = /("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*')|\/\*[\s\S]*?\*\/|\/\/[^\n]*/g;
5
+ const USE_OR_FORWARD = /@(?:use|forward)\s+(["'])(.+?)\1/g;
6
+ const IMPORT = /@import\s+([^;]+);/g;
7
+ const QUOTED = /^\s*(["'])(.+)\1\s*$/;
8
+
9
+ /** The load path that the Vite configuration of CAITS gives to Sass: the folder of the stylesheets of the app (quality/caits/vite.ts). */
10
+ export const STYLE_FOLDER = "src/routes/_frontend/style";
11
+
12
+ /**
13
+ * Reads a stylesheet (SCSS): the files that it loads with `@use`, `@forward` and `@import`. The comments do not count.
14
+ * Sass looks for a path first next to the file, then in its load path, then in the packages. Thus a path that leads to a file next to
15
+ * the stylesheet (`theme` for _theme.scss) becomes relative (`./theme`); a path that leads to a file of the load path becomes a path
16
+ * from the root of the project (`/src/routes/_frontend/style/values`); the others stay names of packages, as `sass:math` does.
17
+ * @param {string} path the path of the stylesheet, from the root of the project
18
+ * @param {string} text
19
+ * @param {Set<string>} paths the paths of all the files of the project
20
+ */
21
+ export function readScss(path, text, paths) {
22
+ const code = text.replace(STRING_OR_COMMENT, (/** @type {string} */ _match, /** @type {string | undefined} */ string) => string ?? "");
23
+ const specifiers = [...code.matchAll(USE_OR_FORWARD)].map(match => match[2]);
24
+ for (const [, list] of code.matchAll(IMPORT)) {
25
+ for (const item of list.split(",")) {
26
+ const [, , specifier] = QUOTED.exec(item) ?? [];
27
+ if (specifier) specifiers.push(specifier);
28
+ }
29
+ }
30
+ const imports = specifiers.map(specifier => {
31
+ if (isIn(dirname(path), specifier, paths)) return `./${specifier}`;
32
+ return isIn(STYLE_FOLDER, specifier, paths) ? `/${posix.join(STYLE_FOLDER, specifier)}` : specifier;
33
+ });
34
+ return { imports, exports: [], defaultArray: null, classes: [], constructed: [], calls: [], objectTypes: [], strings: [], tags: [] };
35
+ }
36
+
37
+ /**
38
+ * Whether Sass finds a path without a dot (`theme`, `parts/menu`) in a folder: `theme.scss`, the partial `_theme.scss`, `theme.css`, or
39
+ * the index of a folder `theme/`.
40
+ * @param {string} folder
41
+ * @param {string} specifier
42
+ * @param {Set<string>} paths
43
+ */
44
+ function isIn(folder, specifier, paths) {
45
+ if (/^[./#]|^[a-z]+:/.test(specifier)) return false;
46
+ const base = posix.join(folder, specifier);
47
+ const partial = posix.join(posix.dirname(base), `_${posix.basename(base)}`);
48
+ const candidates = /\.(scss|sass|css)$/.test(base)
49
+ ? [base, partial]
50
+ : [`${base}.scss`, `${partial}.scss`, `${base}.css`, `${base}/_index.scss`, `${base}/index.scss`];
51
+ return candidates.some(candidate => paths.has(candidate));
52
+ }
@@ -0,0 +1,13 @@
1
+ import { parseSync } from "vite";
2
+ import { factsOf } from "../facts.mjs";
3
+
4
+ /**
5
+ * Reads a TypeScript file with oxc, the parser of Vite, which the build already uses (the plugin of the kernel, @ingenioz-it/caits/vite).
6
+ * @param {string} path
7
+ * @param {string} text
8
+ */
9
+ export function readTypeScript(path, text) {
10
+ const { program, errors } = parseSync(path, text);
11
+ if (errors.length > 0) throw new Error(`${path}: ${errors[0].message}`);
12
+ return factsOf(program, text);
13
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * What the rules read in a file. An adapter parses the file, then gives its tree to factsOf: the tree of oxc (ESTree) for
3
+ * TypeScript, the tree of Babel for Marko. The two trees differ in a few names only, so one walk reads both.
4
+ */
5
+
6
+ /**
7
+ * A node of the tree of oxc or of Babel. The nodes have many shapes, and the walk reads the fields of the two trees: they have no type.
8
+ * @typedef {any} Node
9
+ */
10
+ /** @typedef {{ name: string | null; kind: string }} Export an exported name, and its kind */
11
+ /** @typedef {{ name: string | null; readonly: boolean; value: string | null; asConst: boolean }} Property a property of a class */
12
+ /** @typedef {{ name: string | null; exported: boolean; extends: string | null; implements: string[]; properties: Property[] }} Class */
13
+ /** @typedef {{ chain: string[]; source: string }} Item an item of `export default [ … ]` */
14
+ /** @typedef {{ name: string | null; args: (string | null)[] }} Call */
15
+ /**
16
+ * What the rules read in a file: see factsOf.
17
+ * @typedef {{
18
+ * imports: string[]; exports: Export[]; defaultArray: Item[] | null; classes: Class[]; constructed: (string | null)[]; calls: Call[];
19
+ * objectTypes: string[]; strings: string[]; tags: string[]
20
+ * }} Facts
21
+ */
22
+
23
+ const SKIPPED_KEYS = new Set(["loc", "range", "start", "end", "extra", "leadingComments", "innerComments", "trailingComments", "comments"]);
24
+ const WITH_SOURCE = new Set(["ImportDeclaration", "ExportNamedDeclaration", "ExportAllDeclaration", "ImportExpression"]);
25
+ /** @type {Record<string, string>} */
26
+ const DECLARATION_KINDS = { ClassDeclaration: "class", FunctionDeclaration: "function", TSInterfaceDeclaration: "interface", TSTypeAliasDeclaration: "type", TSEnumDeclaration: "enum" };
27
+
28
+ /** @type {(node: Node) => string | null} */
29
+ const stringOf = node => {
30
+ if ((node?.type === "Literal" || node?.type === "StringLiteral") && typeof node.value === "string") return node.value;
31
+ if (node?.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0].value.cooked;
32
+ if (node?.type === "TSAsExpression" || node?.type === "TSLiteralType") return stringOf(node.expression ?? node.literal);
33
+ return null;
34
+ };
35
+ /** @type {(node: Node) => string | null} */
36
+ const nameOf = node => node?.type === "Identifier" ? node.name : node?.type === "MemberExpression" && !node.computed ? node.property.name : null;
37
+ /** @type {(node: Node) => string | null} */
38
+ const keyOf = node => node.key?.name ?? stringOf(node.key);
39
+
40
+ /**
41
+ * The names a call chain calls, from the first: `bind(X).to(Y).with(z)` calls bind, to, with.
42
+ * @param {Node} node
43
+ * @returns {string[]}
44
+ */
45
+ function chainOf(node) {
46
+ /** @type {string[]} */
47
+ const names = [];
48
+ while (node?.type === "CallExpression") {
49
+ if (node.callee.type === "Identifier") return [node.callee.name, ...names];
50
+ if (node.callee.type !== "MemberExpression" || node.callee.computed) return [];
51
+ names.unshift(node.callee.property.name);
52
+ node = node.callee.object;
53
+ }
54
+ return [];
55
+ }
56
+
57
+ /**
58
+ * @param {Node} node
59
+ * @returns {Export[]}
60
+ */
61
+ function exportsOf(node) {
62
+ if (node.type === "ExportDefaultDeclaration") return [{ name: "default", kind: "default" }];
63
+ if (node.type === "ExportAllDeclaration") return [{ name: node.exported ? nameOf(node.exported) ?? stringOf(node.exported) : "*", kind: "all" }];
64
+ const { declaration } = node;
65
+ if (!declaration) return node.specifiers.map((/** @type {Node} */ specifier) => ({ name: nameOf(specifier.exported) ?? stringOf(specifier.exported), kind: "specifier" }));
66
+ if (declaration.type === "VariableDeclaration") return declaration.declarations.map((/** @type {Node} */ declarator) => ({ name: declarator.id.name, kind: declaration.kind }));
67
+ return [{ name: declaration.id?.name, kind: DECLARATION_KINDS[declaration.type] ?? declaration.type }];
68
+ }
69
+
70
+ /** @type {(node: Node, exported: boolean) => Class} */
71
+ const classOf = (node, exported) => ({
72
+ name: node.id?.name ?? null,
73
+ exported,
74
+ extends: nameOf(node.superClass),
75
+ implements: (node.implements ?? []).map((/** @type {Node} */ heritage) => nameOf(heritage.expression)).filter(Boolean),
76
+ properties: node.body.body.filter((/** @type {Node} */ member) => member.type === "PropertyDefinition" || member.type === "ClassProperty").map((/** @type {Node} */ member) => ({
77
+ name: keyOf(member),
78
+ readonly: Boolean(member.readonly),
79
+ value: stringOf(member.value),
80
+ asConst: member.value?.type === "TSAsExpression" && member.value.typeAnnotation?.typeName?.name === "const"
81
+ }))
82
+ });
83
+
84
+ /**
85
+ * - imports: every module the file imports or re-exports, dynamic imports included;
86
+ * - exports: each exported name, with its kind (class, interface, type, const, function, enum, specifier, default, all);
87
+ * - defaultArray: the items of `export default [ … ]`, with the calls of each item (`bind(X).to(Y)` calls bind, to), or null;
88
+ * - classes: the classes, with what they extend and implement, and their properties;
89
+ * - constructed: the class of each `new X(…)`;
90
+ * - calls: each call, with the value of its arguments that are strings (null for the others);
91
+ * - objectTypes: the value of each `type: "…"` of an object literal;
92
+ * - strings: every string of the code (comments and import paths aside);
93
+ * - tags: the name of each Marko tag (Marko only).
94
+ * @param {Node} program
95
+ * @param {string} text
96
+ * @returns {Facts}
97
+ */
98
+ export function factsOf(program, text) {
99
+ /** @type {Facts} */
100
+ const facts = { imports: [], exports: [], defaultArray: null, classes: [], constructed: [], calls: [], objectTypes: [], strings: [], tags: [] };
101
+ const exportedClasses = new WeakSet();
102
+ const seen = new WeakSet();
103
+
104
+ /** @param {Node} node */
105
+ const visit = node => {
106
+ if (WITH_SOURCE.has(node.type)) {
107
+ const source = stringOf(node.source);
108
+ if (source !== null) facts.imports.push(source);
109
+ }
110
+ if (node.type === "TSImportType") {
111
+ const source = stringOf(node.source ?? node.argument);
112
+ if (source !== null) facts.imports.push(source);
113
+ }
114
+ if (node.type === "CallExpression" && node.callee.type === "Import") {
115
+ const source = stringOf(node.arguments[0]);
116
+ if (source !== null) facts.imports.push(source);
117
+ }
118
+ if (node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration" || node.type === "ExportAllDeclaration") {
119
+ facts.exports.push(...exportsOf(node));
120
+ if (node.declaration?.type === "ClassDeclaration") exportedClasses.add(node.declaration);
121
+ if (node.type === "ExportDefaultDeclaration" && node.declaration.type === "ArrayExpression") {
122
+ facts.defaultArray = node.declaration.elements.map((/** @type {Node} */ item) => ({ chain: chainOf(item), source: item ? text.slice(item.start, item.end).replace(/\s+/g, " ") : "" }));
123
+ }
124
+ }
125
+ if (node.type === "ClassDeclaration") facts.classes.push(classOf(node, exportedClasses.has(node)));
126
+ if (node.type === "NewExpression") facts.constructed.push(nameOf(node.callee));
127
+ if (node.type === "CallExpression") facts.calls.push({ name: nameOf(node.callee), args: node.arguments.map(stringOf) });
128
+ if (node.type === "ObjectExpression") {
129
+ for (const property of node.properties) {
130
+ const type = stringOf(property.value);
131
+ if ((property.type === "Property" || property.type === "ObjectProperty") && !property.computed && keyOf(property) === "type" && type !== null) {
132
+ facts.objectTypes.push(type);
133
+ }
134
+ }
135
+ }
136
+ if ((node.type === "Literal" || node.type === "StringLiteral") && typeof node.value === "string") facts.strings.push(node.value);
137
+ if (node.type === "TemplateElement") facts.strings.push(node.value.cooked);
138
+ const tag = node.type === "MarkoTag" ? stringOf(node.name) : null;
139
+ if (tag !== null) facts.tags.push(tag);
140
+ };
141
+
142
+ /** @type {(node: Node, parentType?: string, key?: string) => void} */
143
+ const walk = (node, parentType = "", key = "") => {
144
+ if (!node || typeof node !== "object" || seen.has(node)) return;
145
+ seen.add(node);
146
+ if (Array.isArray(node)) {
147
+ for (const item of node) walk(item, parentType, key);
148
+ return;
149
+ }
150
+ if (key === "source" && WITH_SOURCE.has(parentType)) return;
151
+ if (typeof node.type === "string") visit(node);
152
+ for (const [childKey, child] of Object.entries(node)) {
153
+ if (!SKIPPED_KEYS.has(childKey)) walk(child, node.type, childKey);
154
+ }
155
+ };
156
+
157
+ walk(program);
158
+ return facts;
159
+ }
@@ -0,0 +1,148 @@
1
+ import { dirname, posix, relative, resolve } from "node:path";
2
+
3
+ /** What the rules know of the project: where a file lives, what it imports, and how the parts are named. */
4
+
5
+ /** @import { Facts } from "./facts.mjs" */
6
+ /** @typedef {"backend" | "frontend"} Side */
7
+ /**
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
12
+ */
13
+ /** 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
+ * 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
19
+ */
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
+ /**
22
+ * 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
+ * reads with `this`: a project can change them.
24
+ * @typedef {{ name: string; description: string; [value: string]: unknown } & (
25
+ * | { on: "import"; check(link: Import): Messages }
26
+ * | { on: "file"; production?: boolean; check(file: File, project: Project): Messages }
27
+ * | { on: "project"; check(project: Project): { file: string; message: string }[] }
28
+ * )} Rule
29
+ */
30
+
31
+ /** The kernel is a package: the app imports it by its name, and it holds no file of the project. */
32
+ export const KERNEL = { name: "@ingenioz-it/caits" };
33
+
34
+ /**
35
+ * The path of an import in the kernel, without the name of the kernel (`di/Container`), or null for an import of something else.
36
+ * @type {(specifier: string) => string | null}
37
+ */
38
+ export const kernelPathOf = specifier => specifier.startsWith(`${KERNEL.name}/`) ? specifier.slice(KERNEL.name.length + 1) : null;
39
+
40
+ /**
41
+ * 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.
43
+ * @param {string} path
44
+ * @returns {Location}
45
+ */
46
+ export function locate(path) {
47
+ const parts = path.split("/");
48
+ if (parts[0] !== "src") return { kind: "other" };
49
+ if (parts[1] === "routes") return { kind: "routes", side: parts[2] === "_frontend" ? "frontend" : "backend" };
50
+ if (parts[1] === "backend" && parts.length === 3) return { kind: "map" };
51
+ 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" };
55
+ }
56
+
57
+ /** The domain and the application: the code that holds no technology. @type {(where: Location) => boolean} */
58
+ export const isCore = where => where.kind === "layer" && (where.layer === "domain" || where.layer === "application");
59
+
60
+ /** Tests, contracts, test environments (test.di.ts) and test/. @type {(path: string) => boolean} */
61
+ export const isTestCode = path => /\.(test|contract)\.ts$|(^|\/)test\.di\.ts$/.test(path) || path.startsWith("test/");
62
+
63
+ /**
64
+ * 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.
67
+ */
68
+ const SHORTCUTS = new Map([["shared", "src/backend/shared"], ["backend", "src/backend"], ["frontend", "src/frontend"]]);
69
+
70
+ /** 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];
72
+
73
+ /**
74
+ * 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.
75
+ * So is a shortcut: `#context/domain/entity/Note.js`, in src/backend/notes, is src/backend/notes/domain/entity/Note.js.
76
+ * So is a path from the root of the project (`/src/…`): Vite reads it, and the reading of a stylesheet gives one for a load path.
77
+ * @param {string} from the path of the file that imports, from the root of the project
78
+ * @param {string} specifier
79
+ * @param {string} root
80
+ * @returns {{ internal: boolean; target: string }}
81
+ */
82
+ export function resolveImport(from, specifier, root) {
83
+ const [, shortcut, rest] = /^#([^/]+)\/(.*)$/.exec(specifier) ?? [];
84
+ const folder = shortcut === "context" ? contextFolderOf(from) : SHORTCUTS.get(shortcut);
85
+ if (folder) return { internal: true, target: posix.normalize(`${folder}/${rest}`) };
86
+ if (specifier.startsWith("/")) return { internal: true, target: posix.normalize(specifier.slice(1)) };
87
+ if (!specifier.startsWith(".")) return { internal: false, target: specifier };
88
+ const absolute = resolve(root, dirname(from), specifier);
89
+ return { internal: true, target: relative(root, absolute).replaceAll("\\", "/") };
90
+ }
91
+
92
+ /** The files of the project that a file imports, without their extension. @type {(file: File, root: string) => string[]} */
93
+ export const importedModules = (file, root) => file.imports
94
+ .map(specifier => resolveImport(file.path, specifier, root))
95
+ .filter(({ internal }) => internal)
96
+ .map(({ target }) => target.replace(/\.(js|ts)$/, ""));
97
+
98
+ /** @type {(specifier: string, ...names: string[]) => boolean} */
99
+ export const isPackage = (specifier, ...names) => names.some(name => specifier === name || specifier.startsWith(`${name}/`));
100
+ /** @type {(specifier: string) => boolean} */
101
+ export const isMarko = specifier => isPackage(specifier, "marko", "@marko/run", "@marko/vite") || specifier.endsWith(".marko");
102
+ /** @type {(specifier: string) => boolean} */
103
+ export const isNode = specifier => specifier.startsWith("node:");
104
+
105
+ /**
106
+ * A port, or its contract: {context}/domain/port/{folder}/{Port}.ts or {Port}.contract.ts. Its module is its path without the extension.
107
+ * @param {string} path
108
+ * @returns {{ folder: string; name: string; contract: boolean; module: string } | null}
109
+ */
110
+ export function portOf(path) {
111
+ const match = /^(src\/(?:backend|frontend)\/[^/]+)\/domain\/port\/([a-z0-9-]+)\/([A-Z]\w*?)(\.contract)?\.ts$/.exec(path);
112
+ if (!match) return null;
113
+ const [, context, folder, name, contract] = match;
114
+ return { folder, name, contract: Boolean(contract), module: `${context}/domain/port/${folder}/${name}` };
115
+ }
116
+
117
+ /**
118
+ * An adapter, or its test: {context}/infra/{folder}/{file}. It implements the port named after its folder.
119
+ * @param {string} path
120
+ * @returns {{ folder: string; test: boolean; port: string; portModule: string } | null}
121
+ */
122
+ export function adapterOf(path) {
123
+ const match = /^(src\/(?:backend|frontend)\/[^/]+)\/infra\/([a-z0-9-]+)\/([^/]+)$/.exec(path);
124
+ if (!match) return null;
125
+ 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}` };
128
+ }
129
+
130
+ /** 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] : []);
132
+
133
+ /** @type {(name: string) => string} */
134
+ export const kebab = name => name.replace(/(?<!^)([A-Z])/g, "-$1").toLowerCase();
135
+ /** @type {(kebab: string) => string} */
136
+ export const pascal = kebab => kebab.split("-").map(word => word[0].toUpperCase() + word.slice(1)).join("");
137
+
138
+ /**
139
+ * The type of a frontend event, which the store reads: the context, then the name of the event without the context, in kebab-case.
140
+ * @param {string} context
141
+ * @param {string} name
142
+ */
143
+ export function typeOfEvent(context, name) {
144
+ const plural = pascal(context);
145
+ const singular = plural.replace(/s$/, "");
146
+ const rest = name.startsWith(plural) ? name.slice(plural.length) : name.startsWith(singular) ? name.slice(singular.length) : name;
147
+ return `${context}.${kebab(rest)}`;
148
+ }
@@ -0,0 +1,116 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { readMarko } from "./architecture/adapters/marko.mjs";
5
+ import { readScss } from "./architecture/adapters/scss.mjs";
6
+ import { readTypeScript } from "./architecture/adapters/typescript.mjs";
7
+ import { isTestCode, locate, resolveImport } from "./architecture/project.mjs";
8
+
9
+ /** What a rule knows of the project. The rules of CAITS use it, and the rules of a project can use it too. */
10
+ export * from "./architecture/project.mjs";
11
+
12
+ /** @import { File, Import, Messages, Rule } from "./architecture/project.mjs" */
13
+ /** @typedef {{ rule: string; file: string; message: string; subject?: string }} Violation */
14
+
15
+ /** The file of a project that gives its rules: its default export is the list of the rules. */
16
+ export const RULES_FILE = "quality/architecture.mjs";
17
+
18
+ /**
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.
21
+ * @param {string} root
22
+ * @param {{ path: string; text: string }[]} sources
23
+ * @param {Rule[]} rules
24
+ * @returns {{ production: number; tests: number; violations: Violation[] }}
25
+ */
26
+ export function checkArchitecture(root, sources, rules) {
27
+ checkRules(rules);
28
+ const paths = new Set(sources.map(({ path }) => path));
29
+ /** @type {(path: string, text: string) => import("./architecture/facts.mjs").Facts} */
30
+ const read = (path, text) => {
31
+ if (path.endsWith(".marko")) return readMarko(join(root, path), text);
32
+ return path.endsWith(".scss") ? readScss(path, text, paths) : readTypeScript(path, text);
33
+ };
34
+ /** @type {File[]} */
35
+ const files = sources.map(({ path, text }) => ({ path, ...read(path, text) }));
36
+ const production = files.filter(file => !isTestCode(file.path));
37
+ const project = { root, files, paths };
38
+
39
+ /** @type {Violation[]} */
40
+ 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];
45
+
46
+ for (const rule of rules) {
47
+ if (rule.on === "file") {
48
+ for (const file of rule.production ? production : files) {
49
+ for (const message of messagesOf(rule.check(file, project))) report(rule, file.path, message);
50
+ }
51
+ }
52
+ if (rule.on === "project") {
53
+ for (const { file, message } of rule.check(project)) report(rule, file, message);
54
+ }
55
+ }
56
+
57
+ const importRules = rules.filter(/** @returns {rule is Extract<Rule, { on: "import" }>} */ rule => rule.on === "import");
58
+ for (const file of production) {
59
+ const from = locate(file.path);
60
+ for (const specifier of file.imports) {
61
+ const { internal, target } = resolveImport(file.path, specifier, root);
62
+ /** @type {Import} */
63
+ const link = { file, from, to: internal ? locate(target) : { kind: "other" }, specifier, internal, target };
64
+ for (const rule of importRules) {
65
+ for (const message of messagesOf(rule.check(link))) report(rule, file.path, message, target);
66
+ }
67
+ }
68
+ }
69
+
70
+ return { production: production.length, tests: files.length - production.length, violations };
71
+ }
72
+
73
+ /**
74
+ * Each rule has its own kebab-case name, a description, reads an import, a file or the project, and has a check.
75
+ * @param {unknown} rules
76
+ * @returns {asserts rules is Rule[]}
77
+ */
78
+ function checkRules(rules) {
79
+ if (!Array.isArray(rules)) throw new Error(`${RULES_FILE} must export a list of rules`);
80
+ /** @type {Set<string>} */
81
+ const seen = new Set();
82
+ for (const rule of /** @type {unknown[]} */ (rules)) {
83
+ const { name, description, on, check } = /** @type {Partial<Record<string, unknown>>} */ (rule ?? {});
84
+ const valid = typeof name === "string" && /^[a-z]+(-[a-z]+)*$/.test(name) && description && ["import", "file", "project"].includes(String(on)) && typeof check === "function";
85
+ if (!valid) throw new Error(`each rule has a kebab-case name, a description, reads an import, a file or the project ("on"), and has a check: ${typeof name === "string" ? name : JSON.stringify(rule)}`);
86
+ if (seen.has(name)) throw new Error(`two rules have the name ${name}`);
87
+ seen.add(name);
88
+ }
89
+ }
90
+
91
+ /** @type {(file: string) => Promise<unknown>} */
92
+ const importFile = file => import(pathToFileURL(file).href);
93
+
94
+ /** `caits architecture`: checks the rules of quality/architecture.mjs on the files of src/ and test/: the code, and the stylesheets (SCSS). */
95
+ export async function run() {
96
+ const root = process.cwd();
97
+ if (!existsSync(join(root, RULES_FILE))) throw new Error(`${RULES_FILE} is missing: it gives the rules that \`caits architecture\` checks. Run \`npx caits init .\` to write it.`);
98
+ const { default: rules } = /** @type {{ default: Rule[] }} */ (await importFile(join(root, RULES_FILE)));
99
+ /** @type {(directory: string) => string[]} */
100
+ const walk = directory => !existsSync(directory) ? [] : readdirSync(directory).flatMap(name => {
101
+ const path = join(directory, name);
102
+ return statSync(path).isDirectory() ? walk(path) : [path];
103
+ });
104
+ const sources = [...walk(join(root, "src")), ...walk(join(root, "test"))]
105
+ .filter(path => /\.(ts|marko|scss)$/.test(path))
106
+ .map(absolute => ({ path: relative(root, absolute).replaceAll("\\", "/"), text: readFileSync(absolute, "utf8") }));
107
+ const { production, tests, violations } = checkArchitecture(root, sources, rules);
108
+
109
+ if (violations.length === 0) {
110
+ console.log(`Architecture: ${production} production files and ${tests} test files respect the ${rules.length} rules.`);
111
+ return 0;
112
+ }
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"}.`);
115
+ return 1;
116
+ }