@ingenioz-it/caits 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/caits.js +17 -12
- package/bin/init.js +15 -6
- package/package.json +1 -1
- package/quality/architecture/adapters/marko.mjs +7 -4
- package/quality/architecture/adapters/scss.mjs +29 -8
- package/quality/architecture/adapters/typescript.mjs +2 -2
- package/quality/architecture/facts.mjs +158 -31
- package/quality/architecture/project.mjs +121 -27
- package/quality/architecture.mjs +68 -22
- package/quality/check.mjs +20 -14
- package/quality/dev.mjs +3 -2
- package/quality/mutation-suite.mjs +56 -5
- package/quality/mutation-summary.mjs +28 -11
- package/quality/mutation.mjs +5 -6
- package/quality/terminal.mjs +30 -0
- package/quality/test-docs/test-docs.mjs +1 -2
- package/src/DomainEvent.ts +4 -1
- package/src/di/Container.ts +5 -1
- package/src/di/createBackend.ts +60 -15
- package/src/di/createFrontend.ts +7 -4
- package/src/di/declareDependencies.js +25 -5
- package/src/di/discover.ts +52 -13
- package/src/di/discoverFrontends.ts +30 -11
- package/src/di/discovered.ts +3 -3
- package/src/di/discoveredFrontends.ts +3 -3
- package/src/di/frontend.ts +46 -4
- package/src/messaging/BackendEventBus.ts +6 -6
- package/src/messaging/ContextMap.ts +30 -7
- package/src/messaging/Controller.ts +2 -3
- package/src/messaging/EventPublisher.ts +2 -2
- package/src/messaging/FrontendContextMap.ts +33 -0
- package/src/messaging/InProcessMessageBus.ts +5 -7
- package/src/messaging/MessageBus.ts +1 -2
- package/src/state/createStore.ts +1 -2
- package/src/testing/backend/application.ts +1 -1
- package/src/testing/backend/applicationScenario.ts +6 -7
- package/src/testing/backend/applications.ts +7 -6
- package/src/testing/backend/context-map.ts +5 -2
- package/src/testing/backend/integration.ts +1 -1
- package/src/testing/frontend/applicationScenario.ts +2 -3
- package/src/testing/frontend/applications.ts +3 -3
- package/src/testing/frontend/context-map.ts +6 -0
- package/src/testing/frontend/frontends.ts +17 -11
- package/src/virtual-modules.d.ts +3 -3
- package/src/vite.js +18 -13
- package/template/caits/architecture.mjs +1040 -187
- package/template/caits/eslint.js +18 -0
- package/template/caits/imports.mjs +14 -0
- package/template/caits/tsconfig.json +1 -1
- package/template/caits/vite.ts +7 -3
- package/template/project/AGENTS +9 -5
- package/template/project/quality/imports.mjs +10 -0
- package/src/messaging/Input.ts +0 -4
|
@@ -1,41 +1,58 @@
|
|
|
1
1
|
// `caits mutation-summary`: the mutation score of each file, from the report of Stryker. With --survivors, the mutants that survived.
|
|
2
|
+
// In a terminal: the score in green when every mutant is killed, else in red; the score of each file the same way, and its path in cyan;
|
|
3
|
+
// for each survivor, its line dim, its status in red, its mutator in yellow.
|
|
2
4
|
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { NOT_RUN } from "./mutation-suite.mjs";
|
|
6
|
+
import { painterOf } from "./terminal.mjs";
|
|
3
7
|
|
|
4
8
|
/** The report that Stryker writes in JSON (quality/caits/stryker.mjs). */
|
|
5
9
|
export const REPORT = "reports/mutation/mutation.json";
|
|
6
10
|
|
|
7
|
-
/** @typedef {{ status: string; mutatorName: string; replacement?: string; location: { start: { line: number } } }} Mutant */
|
|
11
|
+
/** @typedef {{ status: string; statusReason?: string; mutatorName: string; replacement?: string; location: { start: { line: number } } }} Mutant */
|
|
8
12
|
|
|
9
13
|
/** @type {(file: string) => unknown} */
|
|
10
14
|
const readJson = file => JSON.parse(readFileSync(file, "utf8"));
|
|
11
15
|
|
|
16
|
+
/** Stryker counts a suite that stopped before its tests as a kill: the mutant is not tested (`caits mutation-suite`). @type {(m: Mutant) => boolean} */
|
|
17
|
+
const notTested = m => m.status === "Killed" && Boolean(m.statusReason?.includes(NOT_RUN));
|
|
18
|
+
|
|
19
|
+
/** @type {(m: Mutant) => string} */
|
|
20
|
+
const tag = m => (m.status === "NoCoverage" ? "[no cov]" : m.status === "Survived" ? "[survived]" : "[not tested]");
|
|
21
|
+
|
|
12
22
|
/**
|
|
13
23
|
* @param {string[]} args
|
|
14
|
-
* @returns {number}
|
|
24
|
+
* @returns {number} 1 when a mutant is not tested
|
|
15
25
|
*/
|
|
16
26
|
export function run(args) {
|
|
17
27
|
if (!existsSync(REPORT)) throw new Error(`${REPORT} is missing: run the mutation tests first (npm run test:mutation).`);
|
|
18
28
|
const report = /** @type {{ files: Record<string, { source: string; mutants: Mutant[] }> }} */ (readJson(REPORT));
|
|
19
29
|
const rows = [];
|
|
20
|
-
let killedTotal = 0, validTotal = 0;
|
|
30
|
+
let killedTotal = 0, validTotal = 0, notTestedTotal = 0;
|
|
21
31
|
for (const [file, data] of Object.entries(report.files)) {
|
|
22
32
|
/** @type {(status: string) => number} */
|
|
23
33
|
const count = status => data.mutants.filter(m => m.status === status).length;
|
|
24
|
-
const
|
|
34
|
+
const untested = data.mutants.filter(notTested).length;
|
|
35
|
+
const killed = count("Killed") - untested + count("Timeout");
|
|
25
36
|
const survived = count("Survived");
|
|
26
37
|
const noCoverage = count("NoCoverage");
|
|
27
|
-
const valid = killed + survived + noCoverage;
|
|
28
|
-
killedTotal += killed; validTotal += valid;
|
|
29
|
-
rows.push({ file, killed, survived, noCoverage, score: valid ? (100 * killed) / valid : 100, survivors: data.mutants.filter(m => m.status === "Survived" || m.status === "NoCoverage"), source: data.source.split("\n") });
|
|
38
|
+
const valid = killed + survived + noCoverage + untested;
|
|
39
|
+
killedTotal += killed; validTotal += valid; notTestedTotal += untested;
|
|
40
|
+
rows.push({ file, killed, survived, noCoverage, untested, score: valid ? (100 * killed) / valid : 100, survivors: data.mutants.filter(m => m.status === "Survived" || m.status === "NoCoverage" || notTested(m)), source: data.source.split("\n") });
|
|
30
41
|
}
|
|
31
42
|
rows.sort((a, b) => a.score - b.score);
|
|
32
43
|
const verbose = args.includes("--survivors");
|
|
33
|
-
|
|
44
|
+
const paint = painterOf(process.stdout);
|
|
45
|
+
/** @type {(killed: number, valid: number) => "green" | "red"} */
|
|
46
|
+
const colorOf = (killed, valid) => killed === valid ? "green" : "red";
|
|
47
|
+
console.log(`${paint([colorOf(killedTotal, validTotal), "bold"], `Mutation score: ${(validTotal ? (100 * killedTotal) / validTotal : 100).toFixed(1)}% (${killedTotal} killed of ${validTotal} valid mutants)`)}\n`);
|
|
34
48
|
for (const row of rows) {
|
|
35
|
-
|
|
49
|
+
const valid = row.killed + row.survived + row.noCoverage + row.untested;
|
|
50
|
+
console.log(`${paint(colorOf(row.killed, valid), `${row.score.toFixed(0).padStart(4)}%`)} ${paint("cyan", row.file)} (killed ${row.killed}, survived ${row.survived}, no coverage ${row.noCoverage}${row.untested ? `, not tested ${row.untested}` : ""})`);
|
|
36
51
|
if (verbose && row.survivors.length) {
|
|
37
|
-
for (const m of row.survivors) console.log(` L${m.location.start.line} ${
|
|
52
|
+
for (const m of row.survivors) console.log(` ${paint("dim", `L${m.location.start.line}`)} ${paint("red", tag(m))} ${paint("yellow", m.mutatorName)}: ${(row.source[m.location.start.line - 1] ?? "").trim().slice(0, 90)} ${paint("dim", "→")} ${String(m.replacement ?? "").slice(0, 60)}`);
|
|
38
53
|
}
|
|
39
54
|
}
|
|
40
|
-
return 0;
|
|
55
|
+
if (!notTestedTotal) return 0;
|
|
56
|
+
console.log(`\n${paint("red", `${notTestedTotal} ${notTestedTotal === 1 ? "mutant is" : "mutants are"} not tested: the unit tests did not run, and Stryker counts ${notTestedTotal === 1 ? "it" : "them"} as killed. Run the mutation tests again. Why they did not run: reports/mutation/index.html.`)}`);
|
|
57
|
+
return 1;
|
|
41
58
|
}
|
package/quality/mutation.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// `caits mutation`: the mutation tests (Stryker, with quality/stryker.config.mjs), then the score of each file and the mutants that
|
|
2
2
|
// survived, whether Stryker passed or not. Stryker alone ends with its threshold when the score is too low: the survivors say what to fix.
|
|
3
|
+
// The run fails also when a mutant is not tested, which Stryker counts as killed (`caits mutation-summary`).
|
|
3
4
|
import { spawn } from "node:child_process";
|
|
4
5
|
import { existsSync, rmSync } from "node:fs";
|
|
5
6
|
import { REPORT, run as summary } from "./mutation-summary.mjs";
|
|
@@ -9,7 +10,7 @@ export const STRYKER_CONFIG = "quality/stryker.config.mjs";
|
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* @param {string[]} args the options of Stryker
|
|
12
|
-
* @returns {Promise<number>} the exit code of Stryker
|
|
13
|
+
* @returns {Promise<number>} the exit code of Stryker, or 1 when a mutant is not tested
|
|
13
14
|
*/
|
|
14
15
|
export async function run(args) {
|
|
15
16
|
if (!existsSync(STRYKER_CONFIG)) throw new Error(`${STRYKER_CONFIG} is missing: it gives the configuration of the mutation tests. Run \`npx caits init .\` to write it.`);
|
|
@@ -22,9 +23,7 @@ export async function run(args) {
|
|
|
22
23
|
stryker.on("error", () => resolve(1));
|
|
23
24
|
stryker.on("close", code => resolve(code ?? 1));
|
|
24
25
|
});
|
|
25
|
-
if (existsSync(REPORT))
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
return status;
|
|
26
|
+
if (!existsSync(REPORT)) return status;
|
|
27
|
+
console.log("");
|
|
28
|
+
return summary(["--survivors"]) || status;
|
|
30
29
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The colors of the outputs of CAITS. A tool prints its lines through the painter of its stream: in a terminal, the painter colors the
|
|
2
|
+
// parts of a line; in a pipe or a file (an AI agent, a log), it gives the text as it is, without a code. NO_COLOR turns the colors off,
|
|
3
|
+
// FORCE_COLOR turns them on, as for the other tools. Each kind of line has its colors: caits.dev shows the outputs with the same ones.
|
|
4
|
+
import { styleText } from "node:util";
|
|
5
|
+
|
|
6
|
+
/** @typedef {"bold" | "dim" | "red" | "green" | "yellow" | "cyan"} Style */
|
|
7
|
+
/** Gives the text with the styles, or as it is. @typedef {(style: Style | Style[], text: string) => string} Paint */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The painter of a stream: it colors when the stream is a terminal, or when FORCE_COLOR asks for colors, and never with NO_COLOR.
|
|
11
|
+
* @param {NodeJS.WriteStream} stream
|
|
12
|
+
* @returns {Paint}
|
|
13
|
+
*/
|
|
14
|
+
export const painterOf = stream => (style, text) => styleText(style, text, { stream });
|
|
15
|
+
|
|
16
|
+
/** The painter of a text without colors. @type {Paint} */
|
|
17
|
+
export const plain = (_style, text) => text;
|
|
18
|
+
|
|
19
|
+
/** Whether a painter colors. @type {(paint: Paint) => boolean} */
|
|
20
|
+
export const colors = paint => paint("bold", "x") !== "x";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A command, then what it does, after two spaces or more: the command in cyan. A line without an explanation is all command.
|
|
24
|
+
* @param {string} step
|
|
25
|
+
* @param {Paint} paint
|
|
26
|
+
*/
|
|
27
|
+
export function commandOf(step, paint) {
|
|
28
|
+
const [, command = step, explanation = ""] = /^(\S.*?)(\s{2,}.*)?$/.exec(step) ?? [];
|
|
29
|
+
return `${paint("cyan", command)}${explanation}`;
|
|
30
|
+
}
|
|
@@ -28,13 +28,12 @@ const readJson = file => JSON.parse(readFileSync(file, "utf8"));
|
|
|
28
28
|
|
|
29
29
|
/** @type {Record<string, number>} */
|
|
30
30
|
const PROJECTS = { "End-to-end": 0, Backend: 1, Frontend: 2, Other: 3 };
|
|
31
|
-
const LAYERS = ["presentation", "
|
|
31
|
+
const LAYERS = ["presentation", "command", "query", "event", "domain", "infra"];
|
|
32
32
|
|
|
33
33
|
/** A path relative to the project root, with "/" on every system. @type {(file: string) => string} */
|
|
34
34
|
export const fromProjectRoot = file => relative(projectRoot, file).split(sep).join("/");
|
|
35
35
|
|
|
36
36
|
/** Removes the terminal colors from an error message. @type {(text: string) => string} */
|
|
37
|
-
// eslint-disable-next-line no-control-regex -- a color of the terminal starts with the character ESC.
|
|
38
37
|
export const withoutColors = text => text.replace(/\u001b\[[0-9;]*m/g, "");
|
|
39
38
|
|
|
40
39
|
/**
|
package/src/DomainEvent.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* An event a context publishes: a fact of the context, that its context map may share as an event of the context shared, which the
|
|
3
|
+
* context maps of the other contexts turn into input DTOs of their own. The publisher never waits for them.
|
|
4
|
+
*/
|
|
2
5
|
export abstract class DomainEvent {
|
|
3
6
|
declare protected readonly fact?: true;
|
|
4
7
|
}
|
package/src/di/Container.ts
CHANGED
|
@@ -52,7 +52,11 @@ export class Container {
|
|
|
52
52
|
private readonly built = new Map<Key, unknown>();
|
|
53
53
|
|
|
54
54
|
constructor(bindings: readonly Binding[] = [], private readonly parent?: Container) {
|
|
55
|
-
for (const binding of bindings)
|
|
55
|
+
for (const binding of bindings) {
|
|
56
|
+
// A di.ts that gives something else would lose it without a word: the kernel refuses it when it starts.
|
|
57
|
+
if (!isKey((binding as { key?: unknown } | undefined)?.key)) throw new Error(`A di.ts gives bindings, made with bind(Key).to(…), .toValue(…) or .toFactory(…): ${JSON.stringify(binding) ?? typeof binding} is not one`);
|
|
58
|
+
this.bindings.set(binding.key, binding);
|
|
59
|
+
}
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
/** A container whose bindings come first, and which asks this container for the others. */
|
package/src/di/createBackend.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import type { DomainEvent } from "../DomainEvent.js";
|
|
2
2
|
import type { Token } from "../Token.js";
|
|
3
3
|
import { BackendEventBus, type Published } from "../messaging/BackendEventBus.js";
|
|
4
|
-
import { ContextMap } from "../messaging/ContextMap.js";
|
|
4
|
+
import { ContextMap, type Envelope, type Translation } from "../messaging/ContextMap.js";
|
|
5
5
|
import { EventPublisher } from "../messaging/EventPublisher.js";
|
|
6
|
-
import { InProcessMessageBus } from "../messaging/InProcessMessageBus.js";
|
|
7
|
-
import type { Input } from "../messaging/Input.js";
|
|
6
|
+
import { InProcessMessageBus, UnknownInputError } from "../messaging/InProcessMessageBus.js";
|
|
8
7
|
import { MessageBus } from "../messaging/MessageBus.js";
|
|
9
8
|
import { ReportError, reportToConsole } from "../messaging/ReportError.js";
|
|
10
9
|
import { bind, Container, type Binding, type Concrete } from "./Container.js";
|
|
@@ -18,51 +17,89 @@ export type BackendOptions = {
|
|
|
18
17
|
nextEventId?: () => string;
|
|
19
18
|
};
|
|
20
19
|
|
|
21
|
-
|
|
20
|
+
/** A context, and the events of shared that an event of its handlers gives. */
|
|
21
|
+
type Context = { readonly container: Container; readonly useCases: readonly UseCase[]; readonly shares: (event: DomainEvent) => DomainEvent[] };
|
|
22
22
|
|
|
23
23
|
const SYSTEM: RequestContext = { correlationId: "system" };
|
|
24
24
|
|
|
25
|
+
/** The context whose events are those of shared: its handlers publish them on the shared bus. */
|
|
26
|
+
const SHARED = "shared";
|
|
27
|
+
|
|
25
28
|
/**
|
|
26
29
|
* Builds every context in a container of its own, child of the container of shared: the dependencies of a context are private to it,
|
|
27
30
|
* those of shared are built once for all. Each request gets a child of its context, with its correlation id, the publisher of its events
|
|
28
|
-
* and its message bus: its handlers and its controllers are built there. The backend event bus
|
|
31
|
+
* and its message bus: its handlers and its controllers are built there. The backend event bus is the shared bus: the context map of a
|
|
32
|
+
* context shares there the events of shared that the events of its handlers give, and the context map of each context turns the events
|
|
33
|
+
* of shared that it hears into inputs of the context. The handlers of the context shared publish its events there.
|
|
29
34
|
*/
|
|
30
35
|
export function createBackend(modules: BackendModules, options: BackendOptions = {}) {
|
|
31
36
|
const reportError = options.reportError ?? reportToConsole;
|
|
32
37
|
const shared = new Container([bind(ReportError).toValue(reportError), ...modules.shared, ...(options.replace?.shared ?? [])]);
|
|
33
38
|
const contexts = new Map<string, Context>();
|
|
34
39
|
const owners = new Map<unknown, Context>();
|
|
40
|
+
const listeners = new Set<(event: DomainEvent) => void>();
|
|
35
41
|
|
|
36
|
-
|
|
42
|
+
/** The context of an input DTO, or of a controller: the error of an input is that of the message bus, which names the fix. */
|
|
43
|
+
const ownerOf = (owned: Class, refusal: () => Error): Context => {
|
|
37
44
|
const context = owners.get(owned);
|
|
38
|
-
if (!context) throw
|
|
45
|
+
if (!context) throw refusal();
|
|
39
46
|
return context;
|
|
40
47
|
};
|
|
48
|
+
const ownerOfInput = (input: object): Context => ownerOf(input.constructor as Class, () => new UnknownInputError(input.constructor.name));
|
|
41
49
|
|
|
42
50
|
/** The container of a request, and its message bus: each input DTO gets a new handler. */
|
|
43
|
-
const requestOf = ({ container, useCases }: Context, request: RequestContext) => {
|
|
51
|
+
const requestOf = ({ container, useCases, shares }: Context, request: RequestContext) => {
|
|
44
52
|
const bus = new InProcessMessageBus();
|
|
45
53
|
const scope = container.child([
|
|
46
54
|
bind(RequestContext).toValue(request),
|
|
47
|
-
bind(EventPublisher).toValue({ publish: (event: DomainEvent) => {
|
|
55
|
+
bind(EventPublisher).toValue({ publish: (event: DomainEvent) => { tell(shares, event, request); } }),
|
|
48
56
|
...(container.has(MessageBus) ? [] : [bind(MessageBus).toValue(bus)])
|
|
49
57
|
]);
|
|
50
58
|
for (const { input, handler } of useCases) bus.subscribe(input as never, received => scope.create(handler as Concrete<{ execute(input: unknown): unknown }>).execute(received) as never);
|
|
51
59
|
return { scope, bus };
|
|
52
60
|
};
|
|
53
61
|
|
|
54
|
-
const send = async
|
|
55
|
-
requestOf(
|
|
62
|
+
const send = async (input: object, request: RequestContext = SYSTEM): Promise<unknown> =>
|
|
63
|
+
requestOf(ownerOfInput(input), request).bus.send(input);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* An event that a handler publishes, in its context: the events of shared that the lines of its map that share give go to the shared
|
|
67
|
+
* bus, in the request of the handler. A line that fails is reported: the handler never waits for the other contexts, nor fails for them.
|
|
68
|
+
*/
|
|
69
|
+
const tell = (shares: Context["shares"], event: DomainEvent, request: RequestContext) => {
|
|
70
|
+
for (const listener of [...listeners]) listener(event);
|
|
71
|
+
let told: DomainEvent[];
|
|
72
|
+
try {
|
|
73
|
+
told = shares(event);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
reportError(error);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const sharedEvent of told) eventBus.publish(sharedEvent, request);
|
|
79
|
+
};
|
|
56
80
|
|
|
81
|
+
// The lines that hear shared in the context maps, as one: each line gives the inputs of its own context, never those of another.
|
|
82
|
+
const lines = Object.entries(modules.contexts).flatMap(([name, { map }]) => map.flatMap((line): Translation[] => line.shares ? [] : [{
|
|
83
|
+
shares: false,
|
|
84
|
+
event: line.event,
|
|
85
|
+
inputs: (event: never, envelope: Envelope) => {
|
|
86
|
+
const inputs = line.inputs(event, envelope);
|
|
87
|
+
const foreign = inputs.find(input => ownerOfInput(input) !== contexts.get(name));
|
|
88
|
+
if (foreign) throw new Error(`the context map of ${name} gives ${foreign.constructor.name}, an input of another context: a context map gives the inputs of its own context`);
|
|
89
|
+
return inputs;
|
|
90
|
+
}
|
|
91
|
+
}]));
|
|
57
92
|
const eventBus = new BackendEventBus({
|
|
58
|
-
map: new ContextMap(
|
|
93
|
+
map: new ContextMap(lines),
|
|
59
94
|
send: (input, envelope) => send(input, { correlationId: envelope.correlationId }),
|
|
60
95
|
nextEventId: options.nextEventId ?? (() => crypto.randomUUID()),
|
|
61
96
|
reportError
|
|
62
97
|
});
|
|
63
98
|
|
|
64
|
-
for (const [name, { bindings, useCases, controllers }] of Object.entries(modules.contexts)) {
|
|
65
|
-
const
|
|
99
|
+
for (const [name, { bindings, useCases, controllers, map }] of Object.entries(modules.contexts)) {
|
|
100
|
+
const contextMap = new ContextMap(map);
|
|
101
|
+
const shares = name === SHARED ? (event: DomainEvent) => [event] : (event: DomainEvent) => contextMap.eventsFor(event);
|
|
102
|
+
const context = { container: shared.child([...bindings, ...(options.replace?.[name] ?? [])]), useCases, shares };
|
|
66
103
|
for (const { input } of useCases) owners.set(input, context);
|
|
67
104
|
for (const controller of controllers) owners.set(controller, context);
|
|
68
105
|
contexts.set(name, context);
|
|
@@ -83,10 +120,18 @@ export function createBackend(modules: BackendModules, options: BackendOptions =
|
|
|
83
120
|
send,
|
|
84
121
|
/** The controller of a context, built for a request. */
|
|
85
122
|
controller: <C>(controller: abstract new (...args: never[]) => C, request: RequestContext = SYSTEM): C =>
|
|
86
|
-
requestOf(ownerOf(controller
|
|
123
|
+
requestOf(ownerOf(controller, () => new Error(`No context has the controller ${controller.name}: write it in the folder of its use case, as <use-case>.<name>.controller.ts`)), request)
|
|
124
|
+
.scope.create(controller as unknown as Concrete<C>),
|
|
125
|
+
/** Publishes an event of shared on the shared bus. */
|
|
87
126
|
publish: (event: DomainEvent, request: RequestContext = SYSTEM) => { eventBus.publish(event, request); },
|
|
88
127
|
redeliver: (published: Published) => { eventBus.redeliver(published.event, published.envelope); },
|
|
128
|
+
/** Watches every event on the shared bus, in its envelope. */
|
|
89
129
|
subscribe: (listener: (published: Published) => void) => eventBus.subscribe(listener),
|
|
130
|
+
/** Watches every event that the handlers publish in their context, before their context map shares it. */
|
|
131
|
+
subscribeToContexts: (listener: (event: DomainEvent) => void): (() => void) => {
|
|
132
|
+
listeners.add(listener);
|
|
133
|
+
return () => { listeners.delete(listener); };
|
|
134
|
+
},
|
|
90
135
|
settled: () => eventBus.settled(),
|
|
91
136
|
/** For the tests: the dependency with this token, built now if nothing needed it before. */
|
|
92
137
|
get: <T>(token: Token<T>): T => {
|
package/src/di/createFrontend.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { Fetcher } from "./Fetcher.js";
|
|
|
11
11
|
type Handler = new () => { execute(input: unknown): unknown };
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* Builds the frontend of a context: its event bus, its message bus with the handlers of its use cases, its store, and a container with
|
|
14
|
+
* Builds the frontend of a context: its event bus, its message bus with the handlers of its use cases, its store if it has one, and a container with
|
|
15
15
|
* the bindings of its di.ts. The container gives each class what its constructor needs: a handler gets the event bus as its
|
|
16
16
|
* EventPublisher. The store gets it as its EventSubscriber.
|
|
17
17
|
*/
|
|
@@ -25,11 +25,14 @@ function build(context: FrontendModules, fetch: Fetcher, replace: readonly Bindi
|
|
|
25
25
|
container.check(handler as Handler);
|
|
26
26
|
bus.subscribe(input as never, received => container.create(handler as Handler).execute(received) as never);
|
|
27
27
|
}
|
|
28
|
-
return { events, store: createStore(context.store, { events, bus }), bus, container };
|
|
28
|
+
return { events, store: context.store && createStore(context.store, { events, bus }), bus, container };
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The frontend of a context, as the views get it: the publisher of its events, and the store they subscribe to (none, for a context
|
|
33
|
+
* without a store). The kernel subscribes to its events too, for its context map: the events of the context that it shares.
|
|
34
|
+
*/
|
|
35
|
+
export function createFrontend(context: FrontendModules, fetch: Fetcher, replace: readonly Binding[] = []): { events: FrontendEventBus<unknown>; store: Store<unknown> | undefined } {
|
|
33
36
|
const { events, store } = build(context, fetch, replace);
|
|
34
37
|
return { events, store };
|
|
35
38
|
}
|
|
@@ -9,12 +9,14 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* The code that a module needs after its own, for the containers:
|
|
12
|
+
* The code that a module needs after its own, for the containers and the message buses:
|
|
13
13
|
* - after each class that has a constructor, the dependencies of the constructor (DEPENDENCIES in Container.ts): the name of each
|
|
14
14
|
* parameter, if it is optional, and the token of its type when the type has a value of the same name, declared in the module or
|
|
15
15
|
* imported from a relative path, from a shortcut of the project (#…, which a package.json or a tsconfig.json maps to a folder) or from
|
|
16
16
|
* one of the packages given (a port and its token, or a class: the kernel gives EventPublisher, DataDirectory…). The token is read only
|
|
17
17
|
* when the container needs it, thus a cycle between modules does no harm. The name of the type goes with its token, for the errors;
|
|
18
|
+
* - after each class that has an execute method, the value of the type of its first parameter, found the same way (INPUT in
|
|
19
|
+
* discover.ts): the input DTO of a handler, which the message bus gives it;
|
|
18
20
|
* - the name of each token declared without one: the name of its constant.
|
|
19
21
|
* The names are text: the minifier can rename the code.
|
|
20
22
|
* @param {string} code
|
|
@@ -31,6 +33,8 @@ export function declareDependencies(code, filename, parse, packages) {
|
|
|
31
33
|
const values = new Set();
|
|
32
34
|
/** @type {{ name: string; parameters: readonly ESTree.ParamPattern[] }[]} */
|
|
33
35
|
const classes = [];
|
|
36
|
+
/** @type {{ name: string; input: ESTree.ParamPattern }[]} */
|
|
37
|
+
const executes = [];
|
|
34
38
|
/** @type {string[]} */
|
|
35
39
|
const tokens = [];
|
|
36
40
|
|
|
@@ -57,9 +61,12 @@ export function declareDependencies(code, filename, parse, packages) {
|
|
|
57
61
|
values.add(declaration.id.name);
|
|
58
62
|
const constructor = declaration.body.body.find(member => member.type === "MethodDefinition" && member.kind === "constructor");
|
|
59
63
|
if (constructor?.type === "MethodDefinition") classes.push({ name: declaration.id.name, parameters: constructor.value.params });
|
|
64
|
+
const execute = declaration.body.body.find(member => member.type === "MethodDefinition" && member.kind === "method" && !member.static && !member.computed
|
|
65
|
+
&& member.key.type === "Identifier" && member.key.name === "execute");
|
|
66
|
+
const input = execute?.type === "MethodDefinition" ? execute.value.params[0] : undefined;
|
|
67
|
+
if (input) executes.push({ name: declaration.id.name, input });
|
|
60
68
|
}
|
|
61
69
|
}
|
|
62
|
-
if (classes.length === 0 && tokens.length === 0) return undefined;
|
|
63
70
|
|
|
64
71
|
/** @type {Map<string, string>} */
|
|
65
72
|
const namespaces = new Map();
|
|
@@ -75,8 +82,7 @@ export function declareDependencies(code, filename, parse, packages) {
|
|
|
75
82
|
};
|
|
76
83
|
/** @type {(parameter: ESTree.ParamPattern, index: number) => string} */
|
|
77
84
|
const dependencyOf = (parameter, index) => {
|
|
78
|
-
const inner
|
|
79
|
-
const pattern = inner.type === "AssignmentPattern" ? inner.left : inner;
|
|
85
|
+
const { inner, pattern } = partsOf(parameter);
|
|
80
86
|
const name = pattern.type === "Identifier" ? pattern.name : `#${index}`;
|
|
81
87
|
const typed = tokenOf(pattern.typeAnnotation?.typeAnnotation);
|
|
82
88
|
const optional = inner.type === "AssignmentPattern" || pattern.optional === true;
|
|
@@ -84,9 +90,23 @@ export function declareDependencies(code, filename, parse, packages) {
|
|
|
84
90
|
};
|
|
85
91
|
|
|
86
92
|
const declarations = classes.map(({ name, parameters }) => `${name}[Symbol.for("kernel.dependencies")] = [${parameters.map(dependencyOf).join(", ")}];`);
|
|
93
|
+
const inputs = executes.flatMap(({ name, input }) => {
|
|
94
|
+
const typed = tokenOf(partsOf(input).pattern.typeAnnotation?.typeAnnotation);
|
|
95
|
+
return typed ? [`${name}[Symbol.for("kernel.input")] = () => ${typed.token};`] : [];
|
|
96
|
+
});
|
|
87
97
|
const imports = [...namespaces].map(([source, alias]) => `import * as ${alias} from ${JSON.stringify(source)};`);
|
|
88
98
|
const names = tokens.map(name => `${name}.name = ${JSON.stringify(name)};`);
|
|
89
|
-
|
|
99
|
+
const lines = [...imports, ...declarations, ...inputs, ...names];
|
|
100
|
+
return lines.length > 0 ? lines.join("\n") : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A parameter, without what TypeScript adds around it (`private readonly`), and its pattern, without its default value.
|
|
105
|
+
* @param {ESTree.ParamPattern} parameter
|
|
106
|
+
*/
|
|
107
|
+
function partsOf(parameter) {
|
|
108
|
+
const inner = parameter.type === "TSParameterProperty" ? parameter.parameter : parameter;
|
|
109
|
+
return { inner, pattern: inner.type === "AssignmentPattern" ? inner.left : inner };
|
|
90
110
|
}
|
|
91
111
|
|
|
92
112
|
/** @type {(name: ESTree.ModuleExportName) => string} */
|
package/src/di/discover.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { Translation } from "../messaging/ContextMap.js";
|
|
2
|
-
import { Input } from "../messaging/Input.js";
|
|
3
2
|
import type { Binding } from "./Container.js";
|
|
4
3
|
|
|
5
4
|
export type Class = abstract new (...args: never[]) => unknown;
|
|
6
5
|
export type UseCase = { readonly input: Class; readonly handler: Class };
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
/**
|
|
7
|
+
* A context of the backend: its bindings, its use cases, its controllers, and its context map: the events of shared that it hears, and
|
|
8
|
+
* the events of its own that it shares.
|
|
9
|
+
*/
|
|
10
|
+
export type ContextModules = { bindings: readonly Binding[]; useCases: UseCase[]; controllers: Class[]; map: Translation[] };
|
|
11
|
+
export type BackendModules = { shared: readonly Binding[]; contexts: Record<string, ContextModules> };
|
|
9
12
|
|
|
10
13
|
type Modules = Record<string, Record<string, unknown>>;
|
|
11
14
|
|
|
@@ -17,23 +20,59 @@ export function single<T>(found: T[], what: string): T {
|
|
|
17
20
|
return found[0];
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
/**
|
|
21
|
-
export
|
|
23
|
+
/** Where the plugin of the kernel writes the input DTO of a handler (see declareDependencies.js). */
|
|
24
|
+
export const INPUT = Symbol.for("kernel.input");
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The use case of a handler: the handler, and the input DTO that its execute takes. The plugin of the kernel reads the class from the
|
|
28
|
+
* type of the parameter, as it reads the ports from the types of the constructor.
|
|
29
|
+
*/
|
|
30
|
+
export function useCaseOf(path: string, module: Record<string, unknown>): UseCase {
|
|
31
|
+
const handler = single(classesOf(module), `${path} declares one handler`);
|
|
32
|
+
const input = (handler as { [INPUT]?: () => unknown })[INPUT]?.();
|
|
33
|
+
if (typeof input !== "function") {
|
|
34
|
+
throw new Error(`${path}: ${handler.name}.execute names no input DTO. Give its parameter the type of the input DTO, the class that the use case receives.`);
|
|
35
|
+
}
|
|
36
|
+
return { input: input as Class, handler };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The lines of a context map, as its file gives them: the kernel refuses, when it starts, a map that is not a list of lines, rather than
|
|
41
|
+
* lose what it holds without a word. `isLine` tells a line of the side.
|
|
42
|
+
*/
|
|
43
|
+
export function linesIn<L>(path: string, map: unknown, isLine: (line: Partial<Record<string, unknown>>) => boolean, kernel: string): L[] {
|
|
44
|
+
const refused = (what: string) => new Error(`${path} lists the lines of its context map, export default [when(…), share(…)], made with ${kernel}: ${what}`);
|
|
45
|
+
if (!Array.isArray(map)) throw refused(`${JSON.stringify(map) ?? typeof map} is not a list`);
|
|
46
|
+
for (const line of map as unknown[]) {
|
|
47
|
+
if (!isLine(line ?? {})) throw refused(`${JSON.stringify(line) ?? typeof line} is not a line`);
|
|
48
|
+
}
|
|
49
|
+
return map as L[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A line of a backend context map: one that hears gives inputs, one that shares gives events. */
|
|
53
|
+
const isTranslation = ({ shares, event, inputs, events }: Partial<Record<string, unknown>>) =>
|
|
54
|
+
typeof event === "function" && (shares === false ? typeof inputs === "function" : shares === true && typeof events === "function");
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Turns the modules found in the backend folder (src/backend by default) into the contexts of the backend: a handler serves the input
|
|
58
|
+
* DTO that its execute takes, and the context map of a context gives the events of shared that it hears and those of its own that it
|
|
59
|
+
* shares.
|
|
60
|
+
*/
|
|
61
|
+
export function discover(found: { bindings: Record<string, readonly Binding[]>; handlers: Modules; controllers: Modules; maps: Record<string, Translation[]> }): BackendModules {
|
|
22
62
|
const contexts: Record<string, ContextModules> = {};
|
|
23
|
-
const context = (path: string) => contexts[contextOf(path)] ??= { bindings: [], useCases: [], controllers: [] };
|
|
63
|
+
const context = (path: string) => contexts[contextOf(path)] ??= { bindings: [], useCases: [], controllers: [], map: [] };
|
|
24
64
|
let shared: readonly Binding[] = [];
|
|
25
65
|
|
|
26
66
|
for (const [path, bindings] of Object.entries(found.bindings)) {
|
|
27
67
|
if (contextOf(path) === "shared") shared = bindings;
|
|
28
68
|
else context(path).bindings = bindings;
|
|
29
69
|
}
|
|
30
|
-
for (const [path, module] of Object.entries(found.handlers))
|
|
31
|
-
const dto = path.replace(".handler.ts", ".dto.ts");
|
|
32
|
-
const input = single(classesOf(found.dtos[dto] ?? {}).filter(type => type.prototype instanceof Input), `${dto} declares one input DTO`);
|
|
33
|
-
const handler = single(classesOf(module), `${path} declares one handler`);
|
|
34
|
-
context(path).useCases.push({ input, handler });
|
|
35
|
-
}
|
|
70
|
+
for (const [path, module] of Object.entries(found.handlers)) context(path).useCases.push(useCaseOf(path, module));
|
|
36
71
|
for (const [path, module] of Object.entries(found.controllers)) context(path).controllers.push(...classesOf(module));
|
|
72
|
+
for (const [path, map] of Object.entries(found.maps)) {
|
|
73
|
+
if (contextOf(path) === "shared") throw new Error(`the backend context shared hears no other context: it has no context map (${path})`);
|
|
74
|
+
context(path).map = linesIn(path, map, isTranslation, "@ingenioz-it/caits/messaging/ContextMap");
|
|
75
|
+
}
|
|
37
76
|
|
|
38
|
-
return { shared, contexts
|
|
77
|
+
return { shared, contexts };
|
|
39
78
|
}
|
|
@@ -1,36 +1,55 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Line } from "../messaging/FrontendContextMap.js";
|
|
2
2
|
import type { StoreDefinition } from "../state/createStore.js";
|
|
3
3
|
import type { Binding } from "./Container.js";
|
|
4
|
-
import {
|
|
4
|
+
import { linesIn, useCaseOf, type UseCase } from "./discover.js";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* A context of the frontend: its bindings, its use cases, its store (none for a context without a use case: its views and its
|
|
8
|
+
* components publish events, and read no state), and its context map: the events of the context shared that it hears, and the events
|
|
9
|
+
* of its own that it shares.
|
|
10
|
+
*/
|
|
11
|
+
export type FrontendModules = { bindings: readonly Binding[]; useCases: UseCase[]; store?: StoreDefinition<unknown, unknown>; map: readonly Line[] };
|
|
7
12
|
|
|
8
13
|
type Modules = Record<string, Record<string, unknown>>;
|
|
9
14
|
|
|
10
15
|
const contextOf = (path: string) => /frontend\/([^/]+)\//.exec(path)![1];
|
|
11
16
|
|
|
17
|
+
/** A line of a frontend context map: one that hears and one that shares both give events. */
|
|
18
|
+
const isLine = ({ shares, event, events }: Partial<Record<string, unknown>>) => typeof shares === "boolean" && typeof event === "function" && typeof events === "function";
|
|
19
|
+
|
|
12
20
|
/**
|
|
13
|
-
* Turns the modules found in the frontend folder (src/frontend by default) into the contexts of the frontend: a context has a di.ts
|
|
14
|
-
* and a handler serves the input DTO
|
|
21
|
+
* Turns the modules found in the frontend folder (src/frontend by default) into the contexts of the frontend: a context has a di.ts,
|
|
22
|
+
* a store at most, and its context map if it hears the context shared or shares events with it; a handler serves the input DTO that
|
|
23
|
+
* its execute takes.
|
|
15
24
|
*/
|
|
16
25
|
export function discoverFrontends(found: {
|
|
17
26
|
bindings: Record<string, readonly Binding[]>;
|
|
18
|
-
dtos: Modules;
|
|
19
27
|
handlers: Modules;
|
|
20
28
|
stores: Record<string, StoreDefinition<unknown, unknown>>;
|
|
29
|
+
maps: Record<string, readonly Line[]>;
|
|
21
30
|
}): Record<string, FrontendModules> {
|
|
22
31
|
const contexts: Record<string, FrontendModules> = {};
|
|
23
32
|
for (const [path, bindings] of Object.entries(found.bindings)) {
|
|
24
33
|
const name = contextOf(path);
|
|
25
|
-
const
|
|
26
|
-
|
|
34
|
+
const stores = Object.entries(found.stores).filter(([storePath]) => contextOf(storePath) === name).map(([, definition]) => definition);
|
|
35
|
+
if (stores.length > 1) throw new Error(`the frontend ${name} declares one store at most in presentation/store/ (found ${stores.length})`);
|
|
36
|
+
contexts[name] = { bindings, useCases: [], store: stores[0], map: [] };
|
|
37
|
+
}
|
|
38
|
+
for (const [path, map] of Object.entries(found.maps)) {
|
|
39
|
+
const context = contexts[contextOf(path)];
|
|
40
|
+
if (!context) throw new Error(`${path} belongs to a frontend without a di.ts`);
|
|
41
|
+
if (contextOf(path) === "shared") throw new Error(`the frontend context shared hears no other context: it has no context map (${path})`);
|
|
42
|
+
context.map = linesIn(path, map, isLine, "@ingenioz-it/caits/messaging/FrontendContextMap");
|
|
27
43
|
}
|
|
28
44
|
for (const [path, module] of Object.entries(found.handlers)) {
|
|
29
45
|
const context = contexts[contextOf(path)];
|
|
30
46
|
if (!context) throw new Error(`${path} belongs to a frontend without a di.ts`);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
47
|
+
context.useCases.push(useCaseOf(path, module));
|
|
48
|
+
}
|
|
49
|
+
for (const [name, context] of Object.entries(contexts)) {
|
|
50
|
+
if (context.useCases.length > 0 && !context.store) {
|
|
51
|
+
throw new Error(`the frontend ${name} has use cases, but no store to send their input DTOs: it declares one in presentation/store/`);
|
|
52
|
+
}
|
|
34
53
|
}
|
|
35
54
|
return contexts;
|
|
36
55
|
}
|
package/src/di/discovered.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference path="../virtual-modules.d.ts" />
|
|
2
|
-
import { bindings,
|
|
2
|
+
import { bindings, handlers, controllers, maps } from "virtual:kernel/backend";
|
|
3
3
|
import { discover } from "./discover.js";
|
|
4
4
|
|
|
5
|
-
/** Every context of the backend: its di.ts, its use cases
|
|
6
|
-
export const discovered = discover({ bindings,
|
|
5
|
+
/** Every context of the backend: its di.ts, its use cases, its controllers and its context map. The plugin of the kernel finds them. */
|
|
6
|
+
export const discovered = discover({ bindings, handlers, controllers, maps });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference path="../virtual-modules.d.ts" />
|
|
2
|
-
import { bindings,
|
|
2
|
+
import { bindings, handlers, maps, stores } from "virtual:kernel/frontend";
|
|
3
3
|
import { discoverFrontends } from "./discoverFrontends.js";
|
|
4
4
|
|
|
5
|
-
/** Every context of the frontend: its di.ts, its use cases and its
|
|
6
|
-
export const discoveredFrontends = discoverFrontends({ bindings,
|
|
5
|
+
/** Every context of the frontend: its di.ts, its use cases, its store and its context map. The plugin of the kernel finds them. */
|
|
6
|
+
export const discoveredFrontends = discoverFrontends({ bindings, handlers, stores, maps });
|