@ingenioz-it/caits 0.5.0 → 0.6.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 +19 -12
- package/bin/init.js +16 -7
- 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 +137 -30
- package/quality/architecture/project.mjs +114 -22
- 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 +29 -12
- package/quality/mutation.mjs +29 -0
- 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 +51 -8
- package/src/di/createFrontend.ts +7 -4
- package/src/di/discover.ts +35 -6
- package/src/di/discoverFrontends.ts +29 -6
- 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 +4 -3
- package/src/messaging/ContextMap.ts +28 -4
- package/src/messaging/EventPublisher.ts +2 -2
- package/src/messaging/FrontendContextMap.ts +33 -0
- package/src/testing/backend/applications.ts +7 -6
- package/src/testing/backend/context-map.ts +4 -1
- package/src/testing/frontend/context-map.ts +6 -0
- package/src/testing/frontend/frontends.ts +16 -9
- package/src/virtual-modules.d.ts +3 -1
- package/src/vite.js +20 -13
- package/template/caits/architecture.mjs +1000 -177
- 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 +4 -2
- package/template/project/AGENTS +9 -5
- package/template/project/quality/imports.mjs +10 -0
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>
|
|
@@ -23,14 +24,16 @@ const USAGE = `Usage: caits <command>
|
|
|
23
24
|
check <script>… Runs the npm scripts of the checks at once. Shows the output of the checks that fail, and only theirs.
|
|
24
25
|
architecture Checks the rules of quality/architecture.mjs.
|
|
25
26
|
e2e Runs the end-to-end tests on the production build.
|
|
26
|
-
mutation
|
|
27
|
+
mutation Runs the mutation tests (Stryker), then gives the score of each file and the mutants that survived. Its exit
|
|
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).
|
|
27
30
|
mutation-suite The unit tests, as Stryker runs them for each mutant.
|
|
28
31
|
|
|
29
32
|
--help, -h Shows this help, also after a command (caits init --help).
|
|
30
33
|
--version Shows the version of CAITS.`;
|
|
31
34
|
|
|
32
35
|
/** The commands that run a tool of quality/: their code is in the folder quality/ of CAITS. */
|
|
33
|
-
const TOOLS = new Set(["dev", "check", "architecture", "e2e", "mutation-summary", "mutation-suite"]);
|
|
36
|
+
const TOOLS = new Set(["dev", "check", "architecture", "e2e", "mutation", "mutation-summary", "mutation-suite"]);
|
|
34
37
|
|
|
35
38
|
/** The options of `caits init`. */
|
|
36
39
|
const INIT_OPTIONS = /** @type {const} */ ({ caits: { type: "string" }, example: { type: "boolean" }, "example-source": { type: "string" }, "no-install": { type: "boolean" } });
|
|
@@ -44,6 +47,10 @@ const count = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
|
44
47
|
/** A folder as the shell reads it: in quotes when it has a space or another special character. */
|
|
45
48
|
const shellQuote = (/** @type {string} */ text) => /^[\w@%+=:,./-]+$/.test(text) ? text : `'${text.replaceAll("'", "'\\''")}'`;
|
|
46
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
|
+
|
|
47
54
|
const [command, ...rest] = process.argv.slice(2);
|
|
48
55
|
|
|
49
56
|
try {
|
|
@@ -51,8 +58,8 @@ try {
|
|
|
51
58
|
if (typeof status === "number") process.exitCode = status;
|
|
52
59
|
} catch (error) {
|
|
53
60
|
// A known problem has a message that tells what to do: the stack of the code of CAITS does not help.
|
|
54
|
-
console.error(error instanceof Error ? error.message : error);
|
|
55
|
-
if (error instanceof UsageError) console.error("Run `caits --help`.");
|
|
61
|
+
console.error(paintError("red", String(error instanceof Error ? error.message : error)));
|
|
62
|
+
if (error instanceof UsageError) console.error(paintError("dim", "Run `caits --help`."));
|
|
56
63
|
process.exitCode = 1;
|
|
57
64
|
}
|
|
58
65
|
|
|
@@ -123,7 +130,7 @@ function runInit(args) {
|
|
|
123
130
|
const options = /** @type {{ caits?: string; example?: boolean; "example-source"?: string; "no-install"?: boolean }} */ (values);
|
|
124
131
|
if (positionals.length > 1) throw new UsageError(`init takes one folder, not ${positionals.length}: ${positionals.join(" ")}.`);
|
|
125
132
|
if (positionals.length === 0 && !existsSync("package.json")) {
|
|
126
|
-
console.error("This folder has no package.json. Give the folder of the new project (`caits init my-app`), or `caits init .` to use this folder.");
|
|
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."));
|
|
127
134
|
return 1;
|
|
128
135
|
}
|
|
129
136
|
const root = resolve(positionals[0] ?? ".");
|
|
@@ -132,7 +139,7 @@ function runInit(args) {
|
|
|
132
139
|
console.log(`CAITS ${VERSION}: ${root}\n`);
|
|
133
140
|
if (example) console.log(`The example: ${example}\n`);
|
|
134
141
|
const { written, kept, ignored, values: keptValues } = init(root, options.caits, { example });
|
|
135
|
-
console.log(`Wrote ${count(written.length, "file")}. CAITS owns those of ${DEFAULTS_FOLDER}/: the others are yours.`);
|
|
142
|
+
console.log(paint("green", `Wrote ${count(written.length, "file")}. CAITS owns those of ${DEFAULTS_FOLDER}/: the others are yours.`));
|
|
136
143
|
if (kept.length > 0) {
|
|
137
144
|
console.log(`Kept ${count(kept.length, "file")} that the project has already: ${kept.join(", ")}.`);
|
|
138
145
|
console.log(" Compare them with those of node_modules/@ingenioz-it/caits/template/project/ (without the dot at the start of the name).");
|
|
@@ -146,11 +153,11 @@ function runInit(args) {
|
|
|
146
153
|
const cd = relative(process.cwd(), root);
|
|
147
154
|
const steps = [...(cd ? [`cd ${shellQuote(cd)}`] : []), ...(withInstall ? [] : ["npm install", "npx playwright install chromium"])];
|
|
148
155
|
if (withInstall && !install(root)) {
|
|
149
|
-
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.")}`);
|
|
150
157
|
return 1;
|
|
151
158
|
}
|
|
152
|
-
console.log("
|
|
153
|
-
for (const step of [...steps, "npm run dev starts the app on http://localhost:3000", "npm run check runs all the checks"]) console.log(` ${step}`);
|
|
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)}`);
|
|
154
161
|
return 0;
|
|
155
162
|
}
|
|
156
163
|
|
|
@@ -162,7 +169,7 @@ function runInit(args) {
|
|
|
162
169
|
function runUpdate(args) {
|
|
163
170
|
if (args.length > 0) throw new UsageError(`update takes no argument: ${args.join(" ")}.`);
|
|
164
171
|
const written = update(process.cwd());
|
|
165
|
-
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}/.`));
|
|
166
173
|
printOtherVersions(process.cwd());
|
|
167
174
|
return 0;
|
|
168
175
|
}
|
|
@@ -171,7 +178,7 @@ function runUpdate(args) {
|
|
|
171
178
|
function printOtherVersions(root) {
|
|
172
179
|
const others = otherVersions(root);
|
|
173
180
|
if (others.length === 0) return;
|
|
174
|
-
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:`));
|
|
175
182
|
for (const { key, project, caits } of others) console.log(` ${key}: ${JSON.stringify(project)} (CAITS suggests ${JSON.stringify(caits)})`);
|
|
176
183
|
}
|
|
177
184
|
|
|
@@ -180,5 +187,5 @@ function warnOfOldDefaults(root) {
|
|
|
180
187
|
const versions = defaultsVersions(root);
|
|
181
188
|
if (versions.length === 1 && versions[0] === VERSION) return;
|
|
182
189
|
const from = versions.length === 0 ? "is missing" : `comes from CAITS ${versions.join(", ")}`;
|
|
183
|
-
console.warn(
|
|
190
|
+
console.warn(`${paintError("yellow", "Warning:")} ${DEFAULTS_FOLDER}/ ${from}, but CAITS ${VERSION} is installed. Run \`npx caits update\`.\n`);
|
|
184
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 */
|
|
@@ -99,7 +100,7 @@ const PACKAGE = {
|
|
|
99
100
|
"test:integration": "vitest run --config quality/vite.config.ts .integration.test.ts",
|
|
100
101
|
"test:e2e": "caits e2e",
|
|
101
102
|
"test:coverage": "vitest run --config quality/vite.config.ts --coverage --reporter=default --reporter=@ingenioz-it/caits/quality/test-docs/vitest-reporter",
|
|
102
|
-
"test:mutation": "
|
|
103
|
+
"test:mutation": "caits mutation",
|
|
103
104
|
tdd: "vitest --config quality/vite.config.ts",
|
|
104
105
|
check: "caits check typecheck lint:code lint:architecture test:coverage test:mutation test:e2e"
|
|
105
106
|
}
|
|
@@ -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,54 @@
|
|
|
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; exported: boolean; extends: string | null; implements: string[]; properties: Property[]; line: number }} Class */
|
|
14
|
+
/**
|
|
15
|
+
* An item of `export default [ … ]`: the names that its chain calls, its text, the name that the first call takes first (`when(Event, …)`
|
|
16
|
+
* takes Event), the class of each `new X(…)` in it, and its line.
|
|
17
|
+
* @typedef {{ chain: string[]; source: string; subject: string | null; constructed: (string | null)[]; line: number }} Item
|
|
18
|
+
*/
|
|
19
|
+
/** @typedef {{ name: string; from: string; line: number }} Imported a name that the file imports, the module it comes from, and its line */
|
|
20
|
+
/**
|
|
21
|
+
* 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
|
|
22
|
+
* line.
|
|
23
|
+
* @typedef {{ name: string | null; root: string | null; args: (string | null)[]; line: number }} Call
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* 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
|
|
27
|
+
* without an import counts as an import, at the line of the tag.
|
|
28
|
+
* @typedef {{ imports: number[]; constructed: number[]; objectTypes: number[]; tags: number[]; comments: number[] }} Lines
|
|
29
|
+
*/
|
|
15
30
|
/**
|
|
16
31
|
* What the rules read in a file: see factsOf.
|
|
17
32
|
* @typedef {{
|
|
18
|
-
* imports: string[]; exports: Export[]; defaultArray: Item[] | null; classes: Class[]; constructed: (string | null)[];
|
|
19
|
-
* objectTypes: string[]; strings: string[]; tags: string[]
|
|
33
|
+
* imports: string[]; imported: Imported[]; exports: Export[]; defaultArray: Item[] | null; classes: Class[]; constructed: (string | null)[];
|
|
34
|
+
* calls: Call[]; objectTypes: string[]; strings: string[]; tags: string[]; comments: string[]; lines: Lines
|
|
20
35
|
* }} Facts
|
|
21
36
|
*/
|
|
37
|
+
/** 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 */
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 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.
|
|
41
|
+
* @param {string} text
|
|
42
|
+
* @returns {(offset: number) => number}
|
|
43
|
+
*/
|
|
44
|
+
export function linesIn(text) {
|
|
45
|
+
/** @type {number[]} */
|
|
46
|
+
const starts = [0];
|
|
47
|
+
for (let index = text.indexOf("\n"); index !== -1; index = text.indexOf("\n", index + 1)) starts.push(index + 1);
|
|
48
|
+
return offset => {
|
|
49
|
+
let low = 0;
|
|
50
|
+
let high = starts.length - 1;
|
|
51
|
+
while (low < high) {
|
|
52
|
+
const middle = Math.ceil((low + high) / 2);
|
|
53
|
+
if (starts[middle] <= offset) low = middle; else high = middle - 1;
|
|
54
|
+
}
|
|
55
|
+
return low + 1;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
22
58
|
|
|
23
59
|
const SKIPPED_KEYS = new Set(["loc", "range", "start", "end", "extra", "leadingComments", "innerComments", "trailingComments", "comments"]);
|
|
24
60
|
const WITH_SOURCE = new Set(["ImportDeclaration", "ExportNamedDeclaration", "ExportAllDeclaration", "ImportExpression"]);
|
|
@@ -34,6 +70,11 @@ const stringOf = node => {
|
|
|
34
70
|
};
|
|
35
71
|
/** @type {(node: Node) => string | null} */
|
|
36
72
|
const nameOf = node => node?.type === "Identifier" ? node.name : node?.type === "MemberExpression" && !node.computed ? node.property.name : null;
|
|
73
|
+
/** The name that a callee starts from: `test` for `test.skip`, `test.skipIf(x)` and `test.describe.only`. @type {(node: Node) => string | null} */
|
|
74
|
+
const rootOf = node => {
|
|
75
|
+
while (node?.type === "MemberExpression" || node?.type === "CallExpression") node = node.type === "MemberExpression" ? node.object : node.callee;
|
|
76
|
+
return node?.type === "Identifier" ? node.name : null;
|
|
77
|
+
};
|
|
37
78
|
/** @type {(node: Node) => string | null} */
|
|
38
79
|
const keyOf = node => node.key?.name ?? stringOf(node.key);
|
|
39
80
|
|
|
@@ -55,20 +96,32 @@ function chainOf(node) {
|
|
|
55
96
|
}
|
|
56
97
|
|
|
57
98
|
/**
|
|
99
|
+
* The name that the first call of a chain takes first: `when(Event, …)` and `bind(Port).to(Adapter)` take Event and Port.
|
|
58
100
|
* @param {Node} node
|
|
101
|
+
* @returns {string | null}
|
|
102
|
+
*/
|
|
103
|
+
function subjectOf(node) {
|
|
104
|
+
while (node?.type === "CallExpression" && node.callee.type === "MemberExpression") node = node.callee.object;
|
|
105
|
+
return node?.type === "CallExpression" && node.arguments[0]?.type === "Identifier" ? node.arguments[0].name : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {Node} node
|
|
110
|
+
* @param {(node: Node) => number} lineOf
|
|
59
111
|
* @returns {Export[]}
|
|
60
112
|
*/
|
|
61
|
-
function exportsOf(node) {
|
|
62
|
-
|
|
63
|
-
if (node.type === "
|
|
113
|
+
function exportsOf(node, lineOf) {
|
|
114
|
+
const line = lineOf(node);
|
|
115
|
+
if (node.type === "ExportDefaultDeclaration") return [{ name: "default", kind: "default", line }];
|
|
116
|
+
if (node.type === "ExportAllDeclaration") return [{ name: node.exported ? nameOf(node.exported) ?? stringOf(node.exported) : "*", kind: "all", line }];
|
|
64
117
|
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 }];
|
|
118
|
+
if (!declaration) return node.specifiers.map((/** @type {Node} */ specifier) => ({ name: nameOf(specifier.exported) ?? stringOf(specifier.exported), kind: "specifier", line: lineOf(specifier) }));
|
|
119
|
+
if (declaration.type === "VariableDeclaration") return declaration.declarations.map((/** @type {Node} */ declarator) => ({ name: declarator.id.name, kind: declaration.kind, line: lineOf(declarator) }));
|
|
120
|
+
return [{ name: declaration.id?.name, kind: DECLARATION_KINDS[declaration.type] ?? declaration.type, line }];
|
|
68
121
|
}
|
|
69
122
|
|
|
70
|
-
/** @type {(node: Node, exported: boolean) => Class} */
|
|
71
|
-
const classOf = (node, exported) => ({
|
|
123
|
+
/** @type {(node: Node, exported: boolean, lineOf: (node: Node) => number) => Class} */
|
|
124
|
+
const classOf = (node, exported, lineOf) => ({
|
|
72
125
|
name: node.id?.name ?? null,
|
|
73
126
|
exported,
|
|
74
127
|
extends: nameOf(node.superClass),
|
|
@@ -77,66 +130,120 @@ const classOf = (node, exported) => ({
|
|
|
77
130
|
name: keyOf(member),
|
|
78
131
|
readonly: Boolean(member.readonly),
|
|
79
132
|
value: stringOf(member.value),
|
|
80
|
-
asConst: member.value?.type === "TSAsExpression" && member.value.typeAnnotation?.typeName?.name === "const"
|
|
81
|
-
|
|
133
|
+
asConst: member.value?.type === "TSAsExpression" && member.value.typeAnnotation?.typeName?.name === "const",
|
|
134
|
+
line: lineOf(member)
|
|
135
|
+
})),
|
|
136
|
+
line: lineOf(node)
|
|
82
137
|
});
|
|
83
138
|
|
|
84
139
|
/**
|
|
85
140
|
* - imports: every module the file imports or re-exports, dynamic imports included;
|
|
141
|
+
* - imported: each name that the file imports, with its module;
|
|
86
142
|
* - 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),
|
|
143
|
+
* - defaultArray: the items of `export default [ … ]`, with the calls of each item (`bind(X).to(Y)` calls bind, to), the name that the first
|
|
144
|
+
* call takes first (X), and the classes that the item builds, or null;
|
|
88
145
|
* - classes: the classes, with what they extend and implement, and their properties;
|
|
89
146
|
* - constructed: the class of each `new X(…)`;
|
|
90
147
|
* - calls: each call, with the value of its arguments that are strings (null for the others);
|
|
91
148
|
* - objectTypes: the value of each `type: "…"` of an object literal;
|
|
92
149
|
* - strings: every string of the code (comments and import paths aside);
|
|
93
|
-
* - tags: the name of each Marko tag (Marko only)
|
|
150
|
+
* - tags: the name of each Marko tag (Marko only);
|
|
151
|
+
* - comments: the text of each comment: those that the parser gives, those that the tree holds, and the comments of a Marko template;
|
|
152
|
+
* - lines: the line of each import, `new`, `type: "…"`, tag and comment. The facts that are objects have their own line.
|
|
153
|
+
* 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
154
|
* @param {Node} program
|
|
95
155
|
* @param {string} text
|
|
156
|
+
* @param {LooseComment[]} [comments] the comments that the parser gives apart from the tree (oxc)
|
|
96
157
|
* @returns {Facts}
|
|
97
158
|
*/
|
|
98
|
-
export function factsOf(program, text) {
|
|
159
|
+
export function factsOf(program, text, comments = []) {
|
|
160
|
+
const lineAt = linesIn(text);
|
|
161
|
+
/** @type {(node: Node) => number} */
|
|
162
|
+
const lineOf = node => node.loc?.start?.line ?? lineAt(node.start ?? 0);
|
|
99
163
|
/** @type {Facts} */
|
|
100
|
-
const facts = {
|
|
164
|
+
const facts = {
|
|
165
|
+
imports: [], imported: [], exports: [], defaultArray: null, classes: [], constructed: [], calls: [], objectTypes: [], strings: [], tags: [],
|
|
166
|
+
comments: comments.map(({ value }) => value),
|
|
167
|
+
lines: { imports: [], constructed: [], objectTypes: [], tags: [], comments: comments.map(({ start }) => lineAt(start)) }
|
|
168
|
+
};
|
|
169
|
+
/** @type {(source: string, node: Node) => void} */
|
|
170
|
+
const addImport = (source, node) => {
|
|
171
|
+
facts.imports.push(source);
|
|
172
|
+
facts.lines.imports.push(lineOf(node));
|
|
173
|
+
};
|
|
174
|
+
const seenComments = new WeakSet();
|
|
101
175
|
const exportedClasses = new WeakSet();
|
|
102
176
|
const seen = new WeakSet();
|
|
177
|
+
/** @type {{ node: Node; item: Item }[]} */
|
|
178
|
+
const items = [];
|
|
103
179
|
|
|
104
180
|
/** @param {Node} node */
|
|
105
181
|
const visit = node => {
|
|
106
182
|
if (WITH_SOURCE.has(node.type)) {
|
|
107
183
|
const source = stringOf(node.source);
|
|
108
|
-
if (source !== null)
|
|
184
|
+
if (source !== null) addImport(source, node);
|
|
185
|
+
if (source !== null && node.type === "ImportDeclaration") {
|
|
186
|
+
for (const specifier of node.specifiers) facts.imported.push({ name: specifier.local.name, from: source, line: lineOf(specifier) });
|
|
187
|
+
}
|
|
109
188
|
}
|
|
110
189
|
if (node.type === "TSImportType") {
|
|
111
190
|
const source = stringOf(node.source ?? node.argument);
|
|
112
|
-
if (source !== null)
|
|
191
|
+
if (source !== null) addImport(source, node);
|
|
113
192
|
}
|
|
114
193
|
if (node.type === "CallExpression" && node.callee.type === "Import") {
|
|
115
194
|
const source = stringOf(node.arguments[0]);
|
|
116
|
-
if (source !== null)
|
|
195
|
+
if (source !== null) addImport(source, node);
|
|
117
196
|
}
|
|
118
197
|
if (node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration" || node.type === "ExportAllDeclaration") {
|
|
119
|
-
facts.exports.push(...exportsOf(node));
|
|
198
|
+
facts.exports.push(...exportsOf(node, lineOf));
|
|
120
199
|
if (node.declaration?.type === "ClassDeclaration") exportedClasses.add(node.declaration);
|
|
121
200
|
if (node.type === "ExportDefaultDeclaration" && node.declaration.type === "ArrayExpression") {
|
|
122
|
-
facts.defaultArray = node.declaration.elements.map((/** @type {Node} */
|
|
201
|
+
facts.defaultArray = node.declaration.elements.map((/** @type {Node} */ element) => {
|
|
202
|
+
/** @type {Item} */
|
|
203
|
+
const item = {
|
|
204
|
+
chain: chainOf(element), source: element ? text.slice(element.start, element.end).replace(/\s+/g, " ") : "", subject: subjectOf(element), constructed: [],
|
|
205
|
+
line: lineOf(element ?? node)
|
|
206
|
+
};
|
|
207
|
+
if (element) items.push({ node: element, item });
|
|
208
|
+
return item;
|
|
209
|
+
});
|
|
123
210
|
}
|
|
124
211
|
}
|
|
125
|
-
if (node.type === "ClassDeclaration") facts.classes.push(classOf(node, exportedClasses.has(node)));
|
|
126
|
-
if (node.type === "NewExpression")
|
|
127
|
-
|
|
212
|
+
if (node.type === "ClassDeclaration") facts.classes.push(classOf(node, exportedClasses.has(node), lineOf));
|
|
213
|
+
if (node.type === "NewExpression") {
|
|
214
|
+
facts.constructed.push(nameOf(node.callee));
|
|
215
|
+
facts.lines.constructed.push(lineOf(node));
|
|
216
|
+
for (const { node: element, item } of items) if (element.start <= node.start && node.end <= element.end) item.constructed.push(nameOf(node.callee));
|
|
217
|
+
}
|
|
218
|
+
if (node.type === "CallExpression") facts.calls.push({ name: nameOf(node.callee), root: rootOf(node.callee), args: node.arguments.map(stringOf), line: lineOf(node) });
|
|
128
219
|
if (node.type === "ObjectExpression") {
|
|
129
220
|
for (const property of node.properties) {
|
|
130
221
|
const type = stringOf(property.value);
|
|
131
222
|
if ((property.type === "Property" || property.type === "ObjectProperty") && !property.computed && keyOf(property) === "type" && type !== null) {
|
|
132
223
|
facts.objectTypes.push(type);
|
|
224
|
+
facts.lines.objectTypes.push(lineOf(node));
|
|
133
225
|
}
|
|
134
226
|
}
|
|
135
227
|
}
|
|
136
228
|
if ((node.type === "Literal" || node.type === "StringLiteral") && typeof node.value === "string") facts.strings.push(node.value);
|
|
137
229
|
if (node.type === "TemplateElement") facts.strings.push(node.value.cooked);
|
|
138
230
|
const tag = node.type === "MarkoTag" ? stringOf(node.name) : null;
|
|
139
|
-
if (tag !== null)
|
|
231
|
+
if (tag !== null) {
|
|
232
|
+
facts.tags.push(tag);
|
|
233
|
+
facts.lines.tags.push(lineOf(node));
|
|
234
|
+
}
|
|
235
|
+
// 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.
|
|
236
|
+
for (const comment of [node.leadingComments, node.innerComments, node.trailingComments].flat()) {
|
|
237
|
+
if (comment && !seenComments.has(comment)) {
|
|
238
|
+
seenComments.add(comment);
|
|
239
|
+
facts.comments.push(comment.value);
|
|
240
|
+
facts.lines.comments.push(lineOf(comment));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (node.type === "MarkoComment") {
|
|
244
|
+
facts.comments.push(node.value);
|
|
245
|
+
facts.lines.comments.push(lineOf(node));
|
|
246
|
+
}
|
|
140
247
|
};
|
|
141
248
|
|
|
142
249
|
/** @type {(node: Node, parentType?: string, key?: string) => void} */
|