@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.
Files changed (42) hide show
  1. package/bin/caits.js +19 -12
  2. package/bin/init.js +16 -7
  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 +137 -30
  8. package/quality/architecture/project.mjs +114 -22
  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 +29 -12
  14. package/quality/mutation.mjs +29 -0
  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 +51 -8
  20. package/src/di/createFrontend.ts +7 -4
  21. package/src/di/discover.ts +35 -6
  22. package/src/di/discoverFrontends.ts +29 -6
  23. package/src/di/discovered.ts +3 -3
  24. package/src/di/discoveredFrontends.ts +3 -3
  25. package/src/di/frontend.ts +46 -4
  26. package/src/messaging/BackendEventBus.ts +4 -3
  27. package/src/messaging/ContextMap.ts +28 -4
  28. package/src/messaging/EventPublisher.ts +2 -2
  29. package/src/messaging/FrontendContextMap.ts +33 -0
  30. package/src/testing/backend/applications.ts +7 -6
  31. package/src/testing/backend/context-map.ts +4 -1
  32. package/src/testing/frontend/context-map.ts +6 -0
  33. package/src/testing/frontend/frontends.ts +16 -9
  34. package/src/virtual-modules.d.ts +3 -1
  35. package/src/vite.js +20 -13
  36. package/template/caits/architecture.mjs +1000 -177
  37. package/template/caits/eslint.js +18 -0
  38. package/template/caits/imports.mjs +14 -0
  39. package/template/caits/tsconfig.json +1 -1
  40. package/template/caits/vite.ts +4 -2
  41. package/template/project/AGENTS +9 -5
  42. 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-summary Gives the mutation score of each file (--survivors: the mutants that survived).
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("\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.")}`);
150
157
  return 1;
151
158
  }
152
- console.log("\nNext:");
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(`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`);
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": "stryker run quality/stryker.config.mjs && caits mutation-summary",
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
- 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.0",
3
+ "version": "0.6.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,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
- /** @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; 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)[]; calls: Call[];
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
- 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" }];
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), or null;
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 = { imports: [], exports: [], defaultArray: null, classes: [], constructed: [], calls: [], objectTypes: [], strings: [], tags: [] };
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) facts.imports.push(source);
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) facts.imports.push(source);
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) facts.imports.push(source);
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} */ item) => ({ chain: chainOf(item), source: item ? text.slice(item.start, item.end).replace(/\s+/g, " ") : "" }));
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") facts.constructed.push(nameOf(node.callee));
127
- if (node.type === "CallExpression") facts.calls.push({ name: nameOf(node.callee), args: node.arguments.map(stringOf) });
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) facts.tags.push(tag);
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} */