@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.
Files changed (53) hide show
  1. package/bin/caits.js +17 -12
  2. package/bin/init.js +15 -6
  3. package/package.json +1 -1
  4. package/quality/architecture/adapters/marko.mjs +7 -4
  5. package/quality/architecture/adapters/scss.mjs +29 -8
  6. package/quality/architecture/adapters/typescript.mjs +2 -2
  7. package/quality/architecture/facts.mjs +158 -31
  8. package/quality/architecture/project.mjs +121 -27
  9. package/quality/architecture.mjs +68 -22
  10. package/quality/check.mjs +20 -14
  11. package/quality/dev.mjs +3 -2
  12. package/quality/mutation-suite.mjs +56 -5
  13. package/quality/mutation-summary.mjs +28 -11
  14. package/quality/mutation.mjs +5 -6
  15. package/quality/terminal.mjs +30 -0
  16. package/quality/test-docs/test-docs.mjs +1 -2
  17. package/src/DomainEvent.ts +4 -1
  18. package/src/di/Container.ts +5 -1
  19. package/src/di/createBackend.ts +60 -15
  20. package/src/di/createFrontend.ts +7 -4
  21. package/src/di/declareDependencies.js +25 -5
  22. package/src/di/discover.ts +52 -13
  23. package/src/di/discoverFrontends.ts +30 -11
  24. package/src/di/discovered.ts +3 -3
  25. package/src/di/discoveredFrontends.ts +3 -3
  26. package/src/di/frontend.ts +46 -4
  27. package/src/messaging/BackendEventBus.ts +6 -6
  28. package/src/messaging/ContextMap.ts +30 -7
  29. package/src/messaging/Controller.ts +2 -3
  30. package/src/messaging/EventPublisher.ts +2 -2
  31. package/src/messaging/FrontendContextMap.ts +33 -0
  32. package/src/messaging/InProcessMessageBus.ts +5 -7
  33. package/src/messaging/MessageBus.ts +1 -2
  34. package/src/state/createStore.ts +1 -2
  35. package/src/testing/backend/application.ts +1 -1
  36. package/src/testing/backend/applicationScenario.ts +6 -7
  37. package/src/testing/backend/applications.ts +7 -6
  38. package/src/testing/backend/context-map.ts +5 -2
  39. package/src/testing/backend/integration.ts +1 -1
  40. package/src/testing/frontend/applicationScenario.ts +2 -3
  41. package/src/testing/frontend/applications.ts +3 -3
  42. package/src/testing/frontend/context-map.ts +6 -0
  43. package/src/testing/frontend/frontends.ts +17 -11
  44. package/src/virtual-modules.d.ts +3 -3
  45. package/src/vite.js +18 -13
  46. package/template/caits/architecture.mjs +1040 -187
  47. package/template/caits/eslint.js +18 -0
  48. package/template/caits/imports.mjs +14 -0
  49. package/template/caits/tsconfig.json +1 -1
  50. package/template/caits/vite.ts +7 -3
  51. package/template/project/AGENTS +9 -5
  52. package/template/project/quality/imports.mjs +10 -0
  53. 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("\nThe installation failed. Correct the problem, then run the same command again: it keeps the files that it wrote.");
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("\nNext:");
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(`Warning: ${DEFAULTS_FOLDER}/ ${from}, but CAITS ${VERSION} is installed. Run \`npx caits update\`.\n`);
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
- rmSync(folder, { recursive: true, force: true });
213
+ const defaults = new Map(filesOf(DEFAULTS).map(file => [file, headerOf(VERSION) + readFileSync(join(DEFAULTS, file), "utf8")]));
213
214
  mkdirSync(folder, { recursive: true });
214
- return filesOf(DEFAULTS).map(file => {
215
- writeFileSync(join(folder, file), headerOf(VERSION) + readFileSync(join(DEFAULTS, file), "utf8"));
216
- return `${DEFAULTS_FOLDER}/${file}`;
217
- });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingenioz-it/caits",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "CAITS (Clean Architecture In TypeScript): the technical base of a Marko project with a backend and a frontend.",
5
5
  "keywords": [
6
6
  "clean-architecture",
@@ -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 of the project) is a dependency without an import: it counts as one.
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
- const specifier = relative(dirname(file), template).replaceAll("\\", "/");
20
- facts.imports.push(specifier.startsWith(".") ? specifier : `./${specifier}`);
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`. The comments do not count.
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 code = text.replace(STRING_OR_COMMENT, (/** @type {string} */ _match, /** @type {string | undefined} */ string) => string ?? "");
23
- const specifiers = [...code.matchAll(USE_OR_FORWARD)].map(match => match[2]);
24
- for (const [, list] of code.matchAll(IMPORT)) {
25
- for (const item of list.split(",")) {
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) specifiers.push(specifier);
43
+ if (specifier) found.push({ specifier, line: codeLineAt(offset + item.indexOf(specifier)) });
44
+ offset += item.length + 1;
28
45
  }
29
46
  }
30
- const imports = specifiers.map(specifier => {
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 { imports, exports: [], defaultArray: null, classes: [], constructed: [], calls: [], objectTypes: [], strings: [], tags: [] };
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
- /** @typedef {{ name: string | null; kind: string }} Export an exported name, and its kind */
11
- /** @typedef {{ name: string | null; readonly: boolean; value: string | null; asConst: boolean }} Property a property of a class */
12
- /** @typedef {{ name: string | null; exported: boolean; extends: string | null; implements: string[]; properties: Property[] }} Class */
13
- /** @typedef {{ chain: string[]; source: string }} Item an item of `export default [ … ]` */
14
- /** @typedef {{ name: string | null; args: (string | null)[] }} Call */
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)[]; calls: Call[];
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
- if (node.type === "ExportDefaultDeclaration") return [{ name: "default", kind: "default" }];
63
- if (node.type === "ExportAllDeclaration") return [{ name: node.exported ? nameOf(node.exported) ?? stringOf(node.exported) : "*", kind: "all" }];
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 {(node: Node, exported: boolean) => Class} */
71
- const classOf = (node, exported) => ({
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), or null;
88
- * - classes: the classes, with what they extend and implement, and their properties;
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 = { imports: [], exports: [], defaultArray: null, classes: [], constructed: [], calls: [], objectTypes: [], strings: [], tags: [] };
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) facts.imports.push(source);
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) facts.imports.push(source);
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) facts.imports.push(source);
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} */ item) => ({ chain: chainOf(item), source: item ? text.slice(item.start, item.end).replace(/\s+/g, " ") : "" }));
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") facts.constructed.push(nameOf(node.callee));
127
- if (node.type === "CallExpression") facts.calls.push({ name: nameOf(node.callee), args: node.arguments.map(stringOf) });
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) facts.tags.push(tag);
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} */