@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
package/bin/caits.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { relative, resolve } from "node:path";
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
|
+
import { commandOf, painterOf } from "../quality/terminal.mjs";
|
|
6
7
|
import { defaultsVersions, DEFAULTS_FOLDER, EXAMPLE_SOURCE, init, install, otherVersions, SOURCE, update, VERSION } from "./init.js";
|
|
7
8
|
|
|
8
9
|
const USAGE = `Usage: caits <command>
|
|
@@ -24,8 +25,8 @@ const USAGE = `Usage: caits <command>
|
|
|
24
25
|
architecture Checks the rules of quality/architecture.mjs.
|
|
25
26
|
e2e Runs the end-to-end tests on the production build.
|
|
26
27
|
mutation Runs the mutation tests (Stryker), then gives the score of each file and the mutants that survived. Its exit
|
|
27
|
-
code is that of Stryker.
|
|
28
|
-
mutation-summary Gives the mutation score of each file (--survivors: the mutants that survived).
|
|
28
|
+
code is that of Stryker, or 1 when a mutant was not tested (its unit tests did not run).
|
|
29
|
+
mutation-summary Gives the mutation score of each file (--survivors: the mutants that survived, and those not tested).
|
|
29
30
|
mutation-suite The unit tests, as Stryker runs them for each mutant.
|
|
30
31
|
|
|
31
32
|
--help, -h Shows this help, also after a command (caits init --help).
|
|
@@ -46,6 +47,10 @@ const count = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
|
46
47
|
/** A folder as the shell reads it: in quotes when it has a space or another special character. */
|
|
47
48
|
const shellQuote = (/** @type {string} */ text) => /^[\w@%+=:,./-]+$/.test(text) ? text : `'${text.replaceAll("'", "'\\''")}'`;
|
|
48
49
|
|
|
50
|
+
/** The painters of the output and of the errors: they color in a terminal only. */
|
|
51
|
+
const paint = painterOf(process.stdout);
|
|
52
|
+
const paintError = painterOf(process.stderr);
|
|
53
|
+
|
|
49
54
|
const [command, ...rest] = process.argv.slice(2);
|
|
50
55
|
|
|
51
56
|
try {
|
|
@@ -53,8 +58,8 @@ try {
|
|
|
53
58
|
if (typeof status === "number") process.exitCode = status;
|
|
54
59
|
} catch (error) {
|
|
55
60
|
// A known problem has a message that tells what to do: the stack of the code of CAITS does not help.
|
|
56
|
-
console.error(error instanceof Error ? error.message : error);
|
|
57
|
-
if (error instanceof UsageError) console.error("Run `caits --help`.");
|
|
61
|
+
console.error(paintError("red", String(error instanceof Error ? error.message : error)));
|
|
62
|
+
if (error instanceof UsageError) console.error(paintError("dim", "Run `caits --help`."));
|
|
58
63
|
process.exitCode = 1;
|
|
59
64
|
}
|
|
60
65
|
|
|
@@ -125,7 +130,7 @@ function runInit(args) {
|
|
|
125
130
|
const options = /** @type {{ caits?: string; example?: boolean; "example-source"?: string; "no-install"?: boolean }} */ (values);
|
|
126
131
|
if (positionals.length > 1) throw new UsageError(`init takes one folder, not ${positionals.length}: ${positionals.join(" ")}.`);
|
|
127
132
|
if (positionals.length === 0 && !existsSync("package.json")) {
|
|
128
|
-
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.");
|
|
133
|
+
console.error(paintError("red", "This folder has no package.json. Give the folder of the new project (`caits init my-app`), or `caits init .` to use this folder."));
|
|
129
134
|
return 1;
|
|
130
135
|
}
|
|
131
136
|
const root = resolve(positionals[0] ?? ".");
|
|
@@ -134,7 +139,7 @@ function runInit(args) {
|
|
|
134
139
|
console.log(`CAITS ${VERSION}: ${root}\n`);
|
|
135
140
|
if (example) console.log(`The example: ${example}\n`);
|
|
136
141
|
const { written, kept, ignored, values: keptValues } = init(root, options.caits, { example });
|
|
137
|
-
console.log(`Wrote ${count(written.length, "file")}. CAITS owns those of ${DEFAULTS_FOLDER}/: the others are yours.`);
|
|
142
|
+
console.log(paint("green", `Wrote ${count(written.length, "file")}. CAITS owns those of ${DEFAULTS_FOLDER}/: the others are yours.`));
|
|
138
143
|
if (kept.length > 0) {
|
|
139
144
|
console.log(`Kept ${count(kept.length, "file")} that the project has already: ${kept.join(", ")}.`);
|
|
140
145
|
console.log(" Compare them with those of node_modules/@ingenioz-it/caits/template/project/ (without the dot at the start of the name).");
|
|
@@ -148,11 +153,11 @@ function runInit(args) {
|
|
|
148
153
|
const cd = relative(process.cwd(), root);
|
|
149
154
|
const steps = [...(cd ? [`cd ${shellQuote(cd)}`] : []), ...(withInstall ? [] : ["npm install", "npx playwright install chromium"])];
|
|
150
155
|
if (withInstall && !install(root)) {
|
|
151
|
-
console.error("
|
|
156
|
+
console.error(`\n${paintError("red", "The installation failed. Correct the problem, then run the same command again: it keeps the files that it wrote.")}`);
|
|
152
157
|
return 1;
|
|
153
158
|
}
|
|
154
|
-
console.log("
|
|
155
|
-
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}`);
|
|
159
|
+
console.log(`\n${paint("bold", "Next:")}`);
|
|
160
|
+
for (const step of [...steps, "npm run dev starts the app on http://localhost:3000", "npm run check runs all the checks"]) console.log(` ${commandOf(step, paint)}`);
|
|
156
161
|
return 0;
|
|
157
162
|
}
|
|
158
163
|
|
|
@@ -164,7 +169,7 @@ function runInit(args) {
|
|
|
164
169
|
function runUpdate(args) {
|
|
165
170
|
if (args.length > 0) throw new UsageError(`update takes no argument: ${args.join(" ")}.`);
|
|
166
171
|
const written = update(process.cwd());
|
|
167
|
-
console.log(`CAITS ${VERSION}: wrote ${count(written.length, "file")} in ${DEFAULTS_FOLDER}/.`);
|
|
172
|
+
console.log(paint("green", `CAITS ${VERSION}: wrote ${count(written.length, "file")} in ${DEFAULTS_FOLDER}/.`));
|
|
168
173
|
printOtherVersions(process.cwd());
|
|
169
174
|
return 0;
|
|
170
175
|
}
|
|
@@ -173,7 +178,7 @@ function runUpdate(args) {
|
|
|
173
178
|
function printOtherVersions(root) {
|
|
174
179
|
const others = otherVersions(root);
|
|
175
180
|
if (others.length === 0) return;
|
|
176
|
-
console.log(`CAITS ${VERSION} suggests other versions of these packages. Change them in package.json if you agree:`);
|
|
181
|
+
console.log(paint("yellow", `CAITS ${VERSION} suggests other versions of these packages. Change them in package.json if you agree:`));
|
|
177
182
|
for (const { key, project, caits } of others) console.log(` ${key}: ${JSON.stringify(project)} (CAITS suggests ${JSON.stringify(caits)})`);
|
|
178
183
|
}
|
|
179
184
|
|
|
@@ -182,5 +187,5 @@ function warnOfOldDefaults(root) {
|
|
|
182
187
|
const versions = defaultsVersions(root);
|
|
183
188
|
if (versions.length === 1 && versions[0] === VERSION) return;
|
|
184
189
|
const from = versions.length === 0 ? "is missing" : `comes from CAITS ${versions.join(", ")}`;
|
|
185
|
-
console.warn(
|
|
190
|
+
console.warn(`${paintError("yellow", "Warning:")} ${DEFAULTS_FOLDER}/ ${from}, but CAITS ${VERSION} is installed. Run \`npx caits update\`.\n`);
|
|
186
191
|
}
|
package/bin/init.js
CHANGED
|
@@ -4,6 +4,7 @@ import { appendFileSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readd
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { basename, dirname, join, relative, sep } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { painterOf } from "../quality/terminal.mjs";
|
|
7
8
|
|
|
8
9
|
/** @typedef {{ [key: string]: unknown }} PackageJson */
|
|
9
10
|
/** @typedef {{ [name: string]: string }} Section */
|
|
@@ -209,12 +210,20 @@ function cloneExample(source) {
|
|
|
209
210
|
*/
|
|
210
211
|
export function update(root) {
|
|
211
212
|
const folder = join(root, DEFAULTS_FOLDER);
|
|
212
|
-
|
|
213
|
+
const defaults = new Map(filesOf(DEFAULTS).map(file => [file, headerOf(VERSION) + readFileSync(join(DEFAULTS, file), "utf8")]));
|
|
213
214
|
mkdirSync(folder, { recursive: true });
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
215
|
+
for (const entry of readdirSync(folder, { recursive: true, withFileTypes: true })) {
|
|
216
|
+
const file = relative(folder, join(entry.parentPath, entry.name)).split(sep).join("/");
|
|
217
|
+
if (!entry.isDirectory() && !(entry.isFile() && defaults.has(file))) rmSync(join(folder, file), { force: true });
|
|
218
|
+
}
|
|
219
|
+
// Only the files that change are written: a development server that watches the others (vite.ts, tsconfig.json) does not restart.
|
|
220
|
+
for (const [file, text] of defaults) {
|
|
221
|
+
const path = join(folder, file);
|
|
222
|
+
if (existsSync(path) && readFileSync(path, "utf8") === text) continue;
|
|
223
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
224
|
+
writeFileSync(path, text);
|
|
225
|
+
}
|
|
226
|
+
return [...defaults.keys()].map(file => `${DEFAULTS_FOLDER}/${file}`);
|
|
218
227
|
}
|
|
219
228
|
|
|
220
229
|
/**
|
|
@@ -261,7 +270,7 @@ export function install(root) {
|
|
|
261
270
|
function run(command, args, cwd) {
|
|
262
271
|
console.log(`\n> ${[command, ...args].join(" ")}`);
|
|
263
272
|
const result = spawnSync(command, args, { cwd, stdio: "inherit" });
|
|
264
|
-
if (result.error) console.error(`${notStarted(command, result.error)}.`);
|
|
273
|
+
if (result.error) console.error(painterOf(process.stderr)("red", `${notStarted(command, result.error)}.`));
|
|
265
274
|
return result.status === 0;
|
|
266
275
|
}
|
|
267
276
|
|
package/package.json
CHANGED
|
@@ -5,19 +5,22 @@ import { factsOf } from "../facts.mjs";
|
|
|
5
5
|
/**
|
|
6
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
7
|
* and translates nothing.
|
|
8
|
-
* A tag that Marko finds by discovery (in a tags/ folder
|
|
8
|
+
* A tag that Marko finds by discovery (in a tags/ folder, or in the folder of tags that a marko.json gives: the components of the
|
|
9
|
+
* frontend context shared) is a dependency without an import: it counts as one, from the root of the project (/src/…). The rules on
|
|
10
|
+
* the direction of the imports read it, at the line of its first tag; the rule on ../ does not, since nobody wrote it.
|
|
9
11
|
* @param {string} file
|
|
10
12
|
* @param {string} text
|
|
13
|
+
* @param {string} root the root of the project
|
|
11
14
|
*/
|
|
12
|
-
export function readMarko(file, text) {
|
|
15
|
+
export function readMarko(file, text, root) {
|
|
13
16
|
const { ast } = compiler.compileSync(text, file, { output: "source", ast: true, code: false, sourceMaps: false });
|
|
14
17
|
const facts = factsOf(ast.program, text);
|
|
15
18
|
const lookup = compiler.taglib.buildLookup(dirname(file));
|
|
16
19
|
for (const name of new Set(facts.tags)) {
|
|
17
20
|
const template = lookup.getTag(name)?.template;
|
|
18
21
|
if (!template || template === file || template.includes("/node_modules/")) continue;
|
|
19
|
-
|
|
20
|
-
facts.imports.push(
|
|
22
|
+
facts.imports.push(`/${relative(root, template).replaceAll("\\", "/")}`);
|
|
23
|
+
facts.lines.imports.push(facts.lines.tags[facts.tags.indexOf(name)]);
|
|
21
24
|
}
|
|
22
25
|
return facts;
|
|
23
26
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { dirname, posix } from "node:path";
|
|
2
|
+
import { linesIn } from "../facts.mjs";
|
|
2
3
|
|
|
3
4
|
/** A string, or a comment: the comments are removed, the strings stay (a string can hold "//", as a URL does). */
|
|
4
5
|
const STRING_OR_COMMENT = /("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*')|\/\*[\s\S]*?\*\/|\/\/[^\n]*/g;
|
|
@@ -10,7 +11,8 @@ const QUOTED = /^\s*(["'])(.+)\1\s*$/;
|
|
|
10
11
|
export const STYLE_FOLDER = "src/routes/_frontend/style";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
|
-
* Reads a stylesheet (SCSS): the files that it loads with `@use`, `@forward` and `@import
|
|
14
|
+
* Reads a stylesheet (SCSS): the files that it loads with `@use`, `@forward` and `@import`, in the order of their lines, and its comments.
|
|
15
|
+
* The comments load nothing.
|
|
14
16
|
* 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
17
|
* the stylesheet (`theme` for _theme.scss) becomes relative (`./theme`); a path that leads to a file of the load path becomes a path
|
|
16
18
|
* from the root of the project (`/src/routes/_frontend/style/values`); the others stay names of packages, as `sass:math` does.
|
|
@@ -19,19 +21,38 @@ export const STYLE_FOLDER = "src/routes/_frontend/style";
|
|
|
19
21
|
* @param {Set<string>} paths the paths of all the files of the project
|
|
20
22
|
*/
|
|
21
23
|
export function readScss(path, text, paths) {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
const lineAt = linesIn(text);
|
|
25
|
+
/** @type {string[]} */
|
|
26
|
+
const comments = [];
|
|
27
|
+
/** @type {number[]} */
|
|
28
|
+
const commentLines = [];
|
|
29
|
+
// A comment becomes the line breaks that it holds: the code keeps the lines of the text.
|
|
30
|
+
const code = text.replace(STRING_OR_COMMENT, (/** @type {string} */ match, /** @type {string | undefined} */ string, /** @type {number} */ offset) => {
|
|
31
|
+
if (string !== undefined) return string;
|
|
32
|
+
comments.push(match.startsWith("//") ? match.slice(2) : match.slice(2, -2));
|
|
33
|
+
commentLines.push(lineAt(offset));
|
|
34
|
+
return match.replace(/[^\n]/g, "");
|
|
35
|
+
});
|
|
36
|
+
const codeLineAt = linesIn(code);
|
|
37
|
+
/** @type {{ specifier: string; line: number }[]} */
|
|
38
|
+
const found = [...code.matchAll(USE_OR_FORWARD)].map(match => ({ specifier: match[2], line: codeLineAt(match.index ?? 0) }));
|
|
39
|
+
for (const match of code.matchAll(IMPORT)) {
|
|
40
|
+
let offset = (match.index ?? 0) + match[0].indexOf(match[1]);
|
|
41
|
+
for (const item of match[1].split(",")) {
|
|
26
42
|
const [, , specifier] = QUOTED.exec(item) ?? [];
|
|
27
|
-
if (specifier)
|
|
43
|
+
if (specifier) found.push({ specifier, line: codeLineAt(offset + item.indexOf(specifier)) });
|
|
44
|
+
offset += item.length + 1;
|
|
28
45
|
}
|
|
29
46
|
}
|
|
30
|
-
|
|
47
|
+
found.sort((first, second) => first.line - second.line);
|
|
48
|
+
const imports = found.map(({ specifier }) => {
|
|
31
49
|
if (isIn(dirname(path), specifier, paths)) return `./${specifier}`;
|
|
32
50
|
return isIn(STYLE_FOLDER, specifier, paths) ? `/${posix.join(STYLE_FOLDER, specifier)}` : specifier;
|
|
33
51
|
});
|
|
34
|
-
return {
|
|
52
|
+
return {
|
|
53
|
+
imports, imported: [], exports: [], defaultArray: null, classes: [], constructed: [], calls: [], objectTypes: [], strings: [], tags: [], comments,
|
|
54
|
+
lines: { imports: found.map(({ line }) => line), constructed: [], objectTypes: [], tags: [], comments: commentLines }
|
|
55
|
+
};
|
|
35
56
|
}
|
|
36
57
|
|
|
37
58
|
/**
|
|
@@ -7,7 +7,7 @@ import { factsOf } from "../facts.mjs";
|
|
|
7
7
|
* @param {string} text
|
|
8
8
|
*/
|
|
9
9
|
export function readTypeScript(path, text) {
|
|
10
|
-
const { program, errors } = parseSync(path, text);
|
|
10
|
+
const { program, errors, comments } = parseSync(path, text);
|
|
11
11
|
if (errors.length > 0) throw new Error(`${path}: ${errors[0].message}`);
|
|
12
|
-
return factsOf(program, text);
|
|
12
|
+
return factsOf(program, text, comments.map(({ value, start }) => ({ value, start })));
|
|
13
13
|
}
|
|
@@ -7,18 +7,60 @@
|
|
|
7
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
8
|
* @typedef {any} Node
|
|
9
9
|
*/
|
|
10
|
-
/**
|
|
11
|
-
/** @typedef {{ name: string | null;
|
|
12
|
-
/** @typedef {{ name: string | null;
|
|
13
|
-
/** @typedef {{
|
|
14
|
-
/**
|
|
10
|
+
/** Each fact that a rule points at has the number of its line in the file, from 1. */
|
|
11
|
+
/** @typedef {{ name: string | null; kind: string; line: number }} Export an exported name, its kind, and its line */
|
|
12
|
+
/** @typedef {{ name: string | null; readonly: boolean; value: string | null; asConst: boolean; line: number }} Property a property of a class */
|
|
13
|
+
/** @typedef {{ name: string | null; text: string }} TypeName a type that a signature names: its name when it is a name (`Note`), and its text */
|
|
14
|
+
/**
|
|
15
|
+
* A method of a class, and what it returns: each type of its return type, out of `Promise<…>` and of the unions (`Promise<A | B>` names A
|
|
16
|
+
* and B), or null when it has no return type.
|
|
17
|
+
* @typedef {{ name: string | null; returns: TypeName[] | null; line: number }} Method
|
|
18
|
+
*/
|
|
19
|
+
/** @typedef {{ name: string | null; exported: boolean; extends: string | null; implements: string[]; properties: Property[]; methods: Method[]; line: number }} Class */
|
|
20
|
+
/**
|
|
21
|
+
* An item of `export default [ … ]`: the names that its chain calls, its text, the name that the first call takes first (`when(Event, …)`
|
|
22
|
+
* takes Event), the class of each `new X(…)` in it, and its line.
|
|
23
|
+
* @typedef {{ chain: string[]; source: string; subject: string | null; constructed: (string | null)[]; line: number }} Item
|
|
24
|
+
*/
|
|
25
|
+
/** @typedef {{ name: string; from: string; line: number }} Imported a name that the file imports, the module it comes from, and its line */
|
|
26
|
+
/**
|
|
27
|
+
* A call: the name that it calls, the name that its chain starts from (`test` for `test.skip(…)`), its arguments that are strings, and its
|
|
28
|
+
* line.
|
|
29
|
+
* @typedef {{ name: string | null; root: string | null; args: (string | null)[]; line: number }} Call
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* The lines of the facts that are strings, in the same order: `lines.imports[2]` is the line of `imports[2]`. A tag that Marko finds
|
|
33
|
+
* without an import counts as an import, at the line of the tag.
|
|
34
|
+
* @typedef {{ imports: number[]; constructed: number[]; objectTypes: number[]; tags: number[]; comments: number[] }} Lines
|
|
35
|
+
*/
|
|
15
36
|
/**
|
|
16
37
|
* What the rules read in a file: see factsOf.
|
|
17
38
|
* @typedef {{
|
|
18
|
-
* imports: string[]; exports: Export[]; defaultArray: Item[] | null; classes: Class[]; constructed: (string | null)[];
|
|
19
|
-
* objectTypes: string[]; strings: string[]; tags: string[]
|
|
39
|
+
* imports: string[]; imported: Imported[]; exports: Export[]; defaultArray: Item[] | null; classes: Class[]; constructed: (string | null)[];
|
|
40
|
+
* calls: Call[]; objectTypes: string[]; strings: string[]; tags: string[]; comments: string[]; lines: Lines
|
|
20
41
|
* }} Facts
|
|
21
42
|
*/
|
|
43
|
+
/** A comment that a parser gives apart from the tree: its text, and where it starts in the file. @typedef {{ value: string; start: number }} LooseComment */
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The number of the line (from 1) of a place in a text, from where the text starts: the offset of a node, or of a match.
|
|
47
|
+
* @param {string} text
|
|
48
|
+
* @returns {(offset: number) => number}
|
|
49
|
+
*/
|
|
50
|
+
export function linesIn(text) {
|
|
51
|
+
/** @type {number[]} */
|
|
52
|
+
const starts = [0];
|
|
53
|
+
for (let index = text.indexOf("\n"); index !== -1; index = text.indexOf("\n", index + 1)) starts.push(index + 1);
|
|
54
|
+
return offset => {
|
|
55
|
+
let low = 0;
|
|
56
|
+
let high = starts.length - 1;
|
|
57
|
+
while (low < high) {
|
|
58
|
+
const middle = Math.ceil((low + high) / 2);
|
|
59
|
+
if (starts[middle] <= offset) low = middle; else high = middle - 1;
|
|
60
|
+
}
|
|
61
|
+
return low + 1;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
22
64
|
|
|
23
65
|
const SKIPPED_KEYS = new Set(["loc", "range", "start", "end", "extra", "leadingComments", "innerComments", "trailingComments", "comments"]);
|
|
24
66
|
const WITH_SOURCE = new Set(["ImportDeclaration", "ExportNamedDeclaration", "ExportAllDeclaration", "ImportExpression"]);
|
|
@@ -34,6 +76,11 @@ const stringOf = node => {
|
|
|
34
76
|
};
|
|
35
77
|
/** @type {(node: Node) => string | null} */
|
|
36
78
|
const nameOf = node => node?.type === "Identifier" ? node.name : node?.type === "MemberExpression" && !node.computed ? node.property.name : null;
|
|
79
|
+
/** The name that a callee starts from: `test` for `test.skip`, `test.skipIf(x)` and `test.describe.only`. @type {(node: Node) => string | null} */
|
|
80
|
+
const rootOf = node => {
|
|
81
|
+
while (node?.type === "MemberExpression" || node?.type === "CallExpression") node = node.type === "MemberExpression" ? node.object : node.callee;
|
|
82
|
+
return node?.type === "Identifier" ? node.name : null;
|
|
83
|
+
};
|
|
37
84
|
/** @type {(node: Node) => string | null} */
|
|
38
85
|
const keyOf = node => node.key?.name ?? stringOf(node.key);
|
|
39
86
|
|
|
@@ -55,20 +102,41 @@ function chainOf(node) {
|
|
|
55
102
|
}
|
|
56
103
|
|
|
57
104
|
/**
|
|
105
|
+
* The name that the first call of a chain takes first: `when(Event, …)` and `bind(Port).to(Adapter)` take Event and Port.
|
|
58
106
|
* @param {Node} node
|
|
107
|
+
* @returns {string | null}
|
|
108
|
+
*/
|
|
109
|
+
function subjectOf(node) {
|
|
110
|
+
while (node?.type === "CallExpression" && node.callee.type === "MemberExpression") node = node.callee.object;
|
|
111
|
+
return node?.type === "CallExpression" && node.arguments[0]?.type === "Identifier" ? node.arguments[0].name : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {Node} node
|
|
116
|
+
* @param {(node: Node) => number} lineOf
|
|
59
117
|
* @returns {Export[]}
|
|
60
118
|
*/
|
|
61
|
-
function exportsOf(node) {
|
|
62
|
-
|
|
63
|
-
if (node.type === "
|
|
119
|
+
function exportsOf(node, lineOf) {
|
|
120
|
+
const line = lineOf(node);
|
|
121
|
+
if (node.type === "ExportDefaultDeclaration") return [{ name: "default", kind: "default", line }];
|
|
122
|
+
if (node.type === "ExportAllDeclaration") return [{ name: node.exported ? nameOf(node.exported) ?? stringOf(node.exported) : "*", kind: "all", line }];
|
|
64
123
|
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 }];
|
|
124
|
+
if (!declaration) return node.specifiers.map((/** @type {Node} */ specifier) => ({ name: nameOf(specifier.exported) ?? stringOf(specifier.exported), kind: "specifier", line: lineOf(specifier) }));
|
|
125
|
+
if (declaration.type === "VariableDeclaration") return declaration.declarations.map((/** @type {Node} */ declarator) => ({ name: declarator.id.name, kind: declaration.kind, line: lineOf(declarator) }));
|
|
126
|
+
return [{ name: declaration.id?.name, kind: DECLARATION_KINDS[declaration.type] ?? declaration.type, line }];
|
|
68
127
|
}
|
|
69
128
|
|
|
70
|
-
/** @type {(
|
|
71
|
-
const
|
|
129
|
+
/** The types that a return type names: those of a union, and the type of a Promise. @type {(type: Node, text: string) => TypeName[]} */
|
|
130
|
+
const typeNamesOf = (type, text) => {
|
|
131
|
+
if (type.type === "TSUnionType") return type.types.flatMap((/** @type {Node} */ member) => typeNamesOf(member, text));
|
|
132
|
+
const reference = type.type === "TSTypeReference" && type.typeName.type === "Identifier" ? type.typeName.name : null;
|
|
133
|
+
const [promised, ...others] = (type.typeArguments ?? type.typeParameters)?.params ?? [];
|
|
134
|
+
if (reference === "Promise" && promised && others.length === 0) return typeNamesOf(promised, text);
|
|
135
|
+
return [{ name: reference, text: text.slice(type.start, type.end) }];
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** @type {(node: Node, exported: boolean, lineOf: (node: Node) => number, text: string) => Class} */
|
|
139
|
+
const classOf = (node, exported, lineOf, text) => ({
|
|
72
140
|
name: node.id?.name ?? null,
|
|
73
141
|
exported,
|
|
74
142
|
extends: nameOf(node.superClass),
|
|
@@ -77,66 +145,125 @@ const classOf = (node, exported) => ({
|
|
|
77
145
|
name: keyOf(member),
|
|
78
146
|
readonly: Boolean(member.readonly),
|
|
79
147
|
value: stringOf(member.value),
|
|
80
|
-
asConst: member.value?.type === "TSAsExpression" && member.value.typeAnnotation?.typeName?.name === "const"
|
|
81
|
-
|
|
148
|
+
asConst: member.value?.type === "TSAsExpression" && member.value.typeAnnotation?.typeName?.name === "const",
|
|
149
|
+
line: lineOf(member)
|
|
150
|
+
})),
|
|
151
|
+
methods: node.body.body.filter((/** @type {Node} */ member) => (member.type === "MethodDefinition" || member.type === "ClassMethod") && member.kind === "method" && !member.static && !member.computed)
|
|
152
|
+
.map((/** @type {Node} */ member) => {
|
|
153
|
+
const returnType = (member.value ?? member).returnType?.typeAnnotation;
|
|
154
|
+
return { name: keyOf(member), returns: returnType ? typeNamesOf(returnType, text) : null, line: lineOf(member) };
|
|
155
|
+
}),
|
|
156
|
+
line: lineOf(node)
|
|
82
157
|
});
|
|
83
158
|
|
|
84
159
|
/**
|
|
85
160
|
* - imports: every module the file imports or re-exports, dynamic imports included;
|
|
161
|
+
* - imported: each name that the file imports, with its module;
|
|
86
162
|
* - 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),
|
|
88
|
-
*
|
|
163
|
+
* - defaultArray: the items of `export default [ … ]`, with the calls of each item (`bind(X).to(Y)` calls bind, to), the name that the first
|
|
164
|
+
* call takes first (X), and the classes that the item builds, or null;
|
|
165
|
+
* - classes: the classes, with what they extend and implement, their properties, and the return types of their methods;
|
|
89
166
|
* - constructed: the class of each `new X(…)`;
|
|
90
167
|
* - calls: each call, with the value of its arguments that are strings (null for the others);
|
|
91
168
|
* - objectTypes: the value of each `type: "…"` of an object literal;
|
|
92
169
|
* - strings: every string of the code (comments and import paths aside);
|
|
93
|
-
* - tags: the name of each Marko tag (Marko only)
|
|
170
|
+
* - tags: the name of each Marko tag (Marko only);
|
|
171
|
+
* - comments: the text of each comment: those that the parser gives, those that the tree holds, and the comments of a Marko template;
|
|
172
|
+
* - lines: the line of each import, `new`, `type: "…"`, tag and comment. The facts that are objects have their own line.
|
|
173
|
+
* A node of oxc has its offset in the text (start); a node of Babel has its line too (loc), which counts from the first line of the file.
|
|
94
174
|
* @param {Node} program
|
|
95
175
|
* @param {string} text
|
|
176
|
+
* @param {LooseComment[]} [comments] the comments that the parser gives apart from the tree (oxc)
|
|
96
177
|
* @returns {Facts}
|
|
97
178
|
*/
|
|
98
|
-
export function factsOf(program, text) {
|
|
179
|
+
export function factsOf(program, text, comments = []) {
|
|
180
|
+
const lineAt = linesIn(text);
|
|
181
|
+
/** @type {(node: Node) => number} */
|
|
182
|
+
const lineOf = node => node.loc?.start?.line ?? lineAt(node.start ?? 0);
|
|
99
183
|
/** @type {Facts} */
|
|
100
|
-
const facts = {
|
|
184
|
+
const facts = {
|
|
185
|
+
imports: [], imported: [], exports: [], defaultArray: null, classes: [], constructed: [], calls: [], objectTypes: [], strings: [], tags: [],
|
|
186
|
+
comments: comments.map(({ value }) => value),
|
|
187
|
+
lines: { imports: [], constructed: [], objectTypes: [], tags: [], comments: comments.map(({ start }) => lineAt(start)) }
|
|
188
|
+
};
|
|
189
|
+
/** @type {(source: string, node: Node) => void} */
|
|
190
|
+
const addImport = (source, node) => {
|
|
191
|
+
facts.imports.push(source);
|
|
192
|
+
facts.lines.imports.push(lineOf(node));
|
|
193
|
+
};
|
|
194
|
+
const seenComments = new WeakSet();
|
|
101
195
|
const exportedClasses = new WeakSet();
|
|
102
196
|
const seen = new WeakSet();
|
|
197
|
+
/** @type {{ node: Node; item: Item }[]} */
|
|
198
|
+
const items = [];
|
|
103
199
|
|
|
104
200
|
/** @param {Node} node */
|
|
105
201
|
const visit = node => {
|
|
106
202
|
if (WITH_SOURCE.has(node.type)) {
|
|
107
203
|
const source = stringOf(node.source);
|
|
108
|
-
if (source !== null)
|
|
204
|
+
if (source !== null) addImport(source, node);
|
|
205
|
+
if (source !== null && node.type === "ImportDeclaration") {
|
|
206
|
+
for (const specifier of node.specifiers) facts.imported.push({ name: specifier.local.name, from: source, line: lineOf(specifier) });
|
|
207
|
+
}
|
|
109
208
|
}
|
|
110
209
|
if (node.type === "TSImportType") {
|
|
111
210
|
const source = stringOf(node.source ?? node.argument);
|
|
112
|
-
if (source !== null)
|
|
211
|
+
if (source !== null) addImport(source, node);
|
|
113
212
|
}
|
|
114
213
|
if (node.type === "CallExpression" && node.callee.type === "Import") {
|
|
115
214
|
const source = stringOf(node.arguments[0]);
|
|
116
|
-
if (source !== null)
|
|
215
|
+
if (source !== null) addImport(source, node);
|
|
117
216
|
}
|
|
118
217
|
if (node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration" || node.type === "ExportAllDeclaration") {
|
|
119
|
-
facts.exports.push(...exportsOf(node));
|
|
218
|
+
facts.exports.push(...exportsOf(node, lineOf));
|
|
120
219
|
if (node.declaration?.type === "ClassDeclaration") exportedClasses.add(node.declaration);
|
|
121
220
|
if (node.type === "ExportDefaultDeclaration" && node.declaration.type === "ArrayExpression") {
|
|
122
|
-
facts.defaultArray = node.declaration.elements.map((/** @type {Node} */
|
|
221
|
+
facts.defaultArray = node.declaration.elements.map((/** @type {Node} */ element) => {
|
|
222
|
+
/** @type {Item} */
|
|
223
|
+
const item = {
|
|
224
|
+
chain: chainOf(element), source: element ? text.slice(element.start, element.end).replace(/\s+/g, " ") : "", subject: subjectOf(element), constructed: [],
|
|
225
|
+
line: lineOf(element ?? node)
|
|
226
|
+
};
|
|
227
|
+
if (element) items.push({ node: element, item });
|
|
228
|
+
return item;
|
|
229
|
+
});
|
|
123
230
|
}
|
|
124
231
|
}
|
|
125
|
-
if (node.type === "ClassDeclaration") facts.classes.push(classOf(node, exportedClasses.has(node)));
|
|
126
|
-
if (node.type === "NewExpression")
|
|
127
|
-
|
|
232
|
+
if (node.type === "ClassDeclaration") facts.classes.push(classOf(node, exportedClasses.has(node), lineOf, text));
|
|
233
|
+
if (node.type === "NewExpression") {
|
|
234
|
+
facts.constructed.push(nameOf(node.callee));
|
|
235
|
+
facts.lines.constructed.push(lineOf(node));
|
|
236
|
+
for (const { node: element, item } of items) if (element.start <= node.start && node.end <= element.end) item.constructed.push(nameOf(node.callee));
|
|
237
|
+
}
|
|
238
|
+
if (node.type === "CallExpression") facts.calls.push({ name: nameOf(node.callee), root: rootOf(node.callee), args: node.arguments.map(stringOf), line: lineOf(node) });
|
|
128
239
|
if (node.type === "ObjectExpression") {
|
|
129
240
|
for (const property of node.properties) {
|
|
130
241
|
const type = stringOf(property.value);
|
|
131
242
|
if ((property.type === "Property" || property.type === "ObjectProperty") && !property.computed && keyOf(property) === "type" && type !== null) {
|
|
132
243
|
facts.objectTypes.push(type);
|
|
244
|
+
facts.lines.objectTypes.push(lineOf(node));
|
|
133
245
|
}
|
|
134
246
|
}
|
|
135
247
|
}
|
|
136
248
|
if ((node.type === "Literal" || node.type === "StringLiteral") && typeof node.value === "string") facts.strings.push(node.value);
|
|
137
249
|
if (node.type === "TemplateElement") facts.strings.push(node.value.cooked);
|
|
138
250
|
const tag = node.type === "MarkoTag" ? stringOf(node.name) : null;
|
|
139
|
-
if (tag !== null)
|
|
251
|
+
if (tag !== null) {
|
|
252
|
+
facts.tags.push(tag);
|
|
253
|
+
facts.lines.tags.push(lineOf(node));
|
|
254
|
+
}
|
|
255
|
+
// Babel hangs the comments on the nodes next to them, a comment on two nodes at times; a comment of a Marko template is a node.
|
|
256
|
+
for (const comment of [node.leadingComments, node.innerComments, node.trailingComments].flat()) {
|
|
257
|
+
if (comment && !seenComments.has(comment)) {
|
|
258
|
+
seenComments.add(comment);
|
|
259
|
+
facts.comments.push(comment.value);
|
|
260
|
+
facts.lines.comments.push(lineOf(comment));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (node.type === "MarkoComment") {
|
|
264
|
+
facts.comments.push(node.value);
|
|
265
|
+
facts.lines.comments.push(lineOf(node));
|
|
266
|
+
}
|
|
140
267
|
};
|
|
141
268
|
|
|
142
269
|
/** @type {(node: Node, parentType?: string, key?: string) => void} */
|