@jskit-ai/config-eslint 0.1.241 → 0.1.243

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 (39) hide show
  1. package/README.md +135 -0
  2. package/base.js +5 -1
  3. package/clientFeatures.js +87 -0
  4. package/formatter.js +60 -0
  5. package/index.js +4 -0
  6. package/package.json +8 -1
  7. package/plugin/factoryResults.js +162 -0
  8. package/plugin/index.js +64 -0
  9. package/plugin/packageSources.js +133 -0
  10. package/plugin/rules/actionCreator.js +55 -0
  11. package/plugin/rules/actionExecuteShape.js +75 -0
  12. package/plugin/rules/allowCommentForm.js +145 -0
  13. package/plugin/rules/appErrorCode.js +169 -0
  14. package/plugin/rules/collaboratorProps.js +130 -0
  15. package/plugin/rules/fixedResultShape.js +96 -0
  16. package/plugin/rules/frozenFactoryResult.js +44 -0
  17. package/plugin/rules/jsdocDescriptionInside.js +45 -0
  18. package/plugin/rules/namedRepeatedType.js +178 -0
  19. package/plugin/rules/noDualShape.js +61 -0
  20. package/plugin/rules/noDuplicateHelper.js +146 -0
  21. package/plugin/rules/noEnvDefault.js +33 -0
  22. package/plugin/rules/noFactorySpread.js +99 -0
  23. package/plugin/rules/noFrozenVueState.js +48 -0
  24. package/plugin/rules/noIdempotency.js +26 -0
  25. package/plugin/rules/noModuleRegistry.js +61 -0
  26. package/plugin/rules/noModuleState.js +50 -0
  27. package/plugin/rules/noOptionalAfterDefault.js +91 -0
  28. package/plugin/rules/noPatchedError.js +50 -0
  29. package/plugin/rules/noSlotSetter.js +40 -0
  30. package/plugin/rules/noTestOnlyExport.js +38 -0
  31. package/plugin/rules/oneLineAction.js +98 -0
  32. package/plugin/rules/onePropertyPerLine.js +86 -0
  33. package/plugin/rules/releasedListeners.js +132 -0
  34. package/plugin/rules/requiredCollaborators.js +150 -0
  35. package/plugin/rules/typedDependencies.js +46 -0
  36. package/plugin/rules/typedInject.js +96 -0
  37. package/plugin/support.js +377 -0
  38. package/plugin/vueSources.js +59 -0
  39. package/typedFeatures.js +104 -0
@@ -0,0 +1,133 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ // The source files of one package, for the rules that compare a file with its
5
+ // package: the nearest directory with a package.json, or a pattern's example
6
+ // directory (an `example` directory beside a PATTERN.md), which is an
7
+ // application of its own.
8
+
9
+ // Directories that hold no package source: dependencies, builds, tests, client
10
+ // code, scaffolding templates, and pattern examples with their generated copies.
11
+ const SKIPPED_DIRECTORIES = new Set([
12
+ "node_modules", ".git", "dist", "coverage", "test", "tests", "client", "components", "pages", "templates", "patterns"
13
+ ]);
14
+ const SOURCE_FILE = /\.m?js$/u;
15
+ const TEST_FILE = /\.test\./u;
16
+ // Client code: modules and Vue components, in every directory of the package but its dependencies, builds, tests,
17
+ // scaffolding templates and pattern examples.
18
+ const CLIENT_SKIPPED_DIRECTORIES = new Set([
19
+ "node_modules", ".git", ".jskit", ".vite", "dist", "coverage", "test", "tests", "test-results", "templates", "patterns"
20
+ ]);
21
+ const CLIENT_SOURCE_FILE = /\.(m?js|vue)$/u;
22
+ // The agent skill bundle's generated copies of package-owned patterns; their
23
+ // sources are checked.
24
+ const GENERATED_MIRROR = /(^|\/)skills\/jskit\/references\/patterns(\/|$)/u;
25
+
26
+ function toPosixPath(file) {
27
+ return file.split(path.sep).join("/");
28
+ }
29
+
30
+ function isGeneratedMirror(file) {
31
+ return GENERATED_MIRROR.test(toPosixPath(file));
32
+ }
33
+
34
+ function isPackageDirectory(directory) {
35
+ return existsSync(path.join(directory, "package.json")) ||
36
+ (path.basename(directory) === "example" && existsSync(path.join(path.dirname(directory), "PATTERN.md")));
37
+ }
38
+
39
+ function packageRootOf(file) {
40
+ const start = path.dirname(file);
41
+ for (let directory = start; ; directory = path.dirname(directory)) {
42
+ if (isPackageDirectory(directory)) {
43
+ return directory;
44
+ }
45
+ if (directory === path.dirname(directory)) {
46
+ return start;
47
+ }
48
+ }
49
+ }
50
+
51
+ // The source files under a directory of a package, without entering another
52
+ // package.
53
+ function packageSourceFiles(directory, files = []) {
54
+ let entries;
55
+ try {
56
+ entries = readdirSync(directory, { withFileTypes: true });
57
+ } catch {
58
+ return files;
59
+ }
60
+ for (const entry of entries) {
61
+ const next = path.join(directory, entry.name);
62
+ if (entry.isDirectory()) {
63
+ if (!SKIPPED_DIRECTORIES.has(entry.name) && !isPackageDirectory(next)) {
64
+ packageSourceFiles(next, files);
65
+ }
66
+ } else if (entry.isFile() && SOURCE_FILE.test(entry.name) && !TEST_FILE.test(entry.name)) {
67
+ files.push(next);
68
+ }
69
+ }
70
+ return files;
71
+ }
72
+
73
+ // Every source file of a package, client modules and Vue components included, without entering another package: what a
74
+ // client file is compared with, since a client helper may repeat a server or shared one.
75
+ function clientSourceFiles(directory, files = []) {
76
+ let entries;
77
+ try {
78
+ entries = readdirSync(directory, { withFileTypes: true });
79
+ } catch {
80
+ return files;
81
+ }
82
+ for (const entry of entries) {
83
+ const next = path.join(directory, entry.name);
84
+ if (entry.isDirectory()) {
85
+ if (!CLIENT_SKIPPED_DIRECTORIES.has(entry.name) && !isPackageDirectory(next)) {
86
+ clientSourceFiles(next, files);
87
+ }
88
+ } else if (entry.isFile() && CLIENT_SOURCE_FILE.test(entry.name) && !TEST_FILE.test(entry.name)) {
89
+ files.push(next);
90
+ }
91
+ }
92
+ return files;
93
+ }
94
+
95
+ // Whether a file of a package is server or shared code, which typedFeaturesConfig reads, as opposed to client code.
96
+ function isServerOrSharedFile(file, packageDirectory) {
97
+ return /(^|\/)(server|shared)\//u.test(toPosixPath(path.relative(packageDirectory, file)));
98
+ }
99
+
100
+ /**
101
+ * What a rule reads from each other file of a lint run, by path, read again
102
+ * when the file changes. A file that does not read or parse gives `unreadable`;
103
+ * its own lint run reports it.
104
+ * @template T
105
+ * @param {(text: string, file: string, context: import("eslint").Rule.RuleContext) => T} read
106
+ * @param {T} unreadable
107
+ * @returns {(file: string, context: import("eslint").Rule.RuleContext) => T}
108
+ */
109
+ function createSourceMemo(read, unreadable) {
110
+ const byFile = new Map();
111
+ return function readSource(file, context) {
112
+ let stats;
113
+ try {
114
+ stats = statSync(file);
115
+ } catch {
116
+ return unreadable;
117
+ }
118
+ const cached = byFile.get(file);
119
+ if (cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) {
120
+ return cached.value;
121
+ }
122
+ let value;
123
+ try {
124
+ value = read(readFileSync(file, "utf8"), file, context);
125
+ } catch {
126
+ value = unreadable;
127
+ }
128
+ byFile.set(file, { mtimeMs: stats.mtimeMs, size: stats.size, value });
129
+ return value;
130
+ };
131
+ }
132
+
133
+ export { clientSourceFiles, createSourceMemo, isGeneratedMirror, isServerOrSharedFile, packageRootOf, packageSourceFiles, toPosixPath };
@@ -0,0 +1,55 @@
1
+ import { createReporter, functionName, isWrittenActionDefinition, keyName, ruleMeta } from "../support.js";
2
+
3
+ const ACTIONS_CREATOR = /^create[A-Z]\w*Actions$/u;
4
+ const FUNCTION_TYPES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
5
+ // The members of a CRUD custom action's one argument; its definitions belong to the CRUD declaration.
6
+ const CRUD_ACTION_MEMBERS = new Set(["input", "service", "repository"]);
7
+
8
+ function isCrudCustomAction(definition) {
9
+ const execute = definition.properties.find((property) => keyName(property) === "execute");
10
+ const first = execute?.value?.params?.[0];
11
+ return first?.type === "ObjectPattern" && first.properties.some((property) => CRUD_ACTION_MEMBERS.has(keyName(property)));
12
+ }
13
+
14
+ // A feature's actions come from one create<Name>Actions({ … }) that receives the capabilities they call, destructured,
15
+ // and returns each definition as a literal: a reader finds every action, and the service it reaches, in one place.
16
+ const actionCreator = Object.freeze({
17
+ meta: ruleMeta("Require action definitions to be written inside a create<Name>Actions({ … }) creator.", {
18
+ outsideCreator: "Action {{action}} is defined outside a create<Name>Actions creator. Write it as a literal inside create<Name>Actions({ <capability> }).",
19
+ positionalCreator: "{{name}} receives its capabilities positionally. Destructure them: {{name}}({ <capability> })."
20
+ }, "action-creators"),
21
+ create(context) {
22
+ const sourceCode = context.sourceCode;
23
+ const report = createReporter(context, "action-creator");
24
+ function checkCreator(fn) {
25
+ const name = functionName(fn);
26
+ const first = fn.params[0];
27
+ if (!ACTIONS_CREATOR.test(name) || !first) {
28
+ return;
29
+ }
30
+ const parameter = first.type === "AssignmentPattern" ? first.left : first;
31
+ if (parameter.type !== "ObjectPattern") {
32
+ report(first, "positional-creator", "positionalCreator", { name });
33
+ }
34
+ }
35
+ return {
36
+ FunctionDeclaration: checkCreator,
37
+ FunctionExpression: checkCreator,
38
+ ArrowFunctionExpression: checkCreator,
39
+ ObjectExpression(node) {
40
+ if (!isWrittenActionDefinition(node) || isCrudCustomAction(node)) {
41
+ return;
42
+ }
43
+ const insideCreator = sourceCode.getAncestors(node).some((ancestor) => FUNCTION_TYPES.has(ancestor.type) &&
44
+ ACTIONS_CREATOR.test(functionName(ancestor)));
45
+ if (!insideCreator) {
46
+ const id = node.properties.find((property) => keyName(property) === "id");
47
+ const text = id.value.type === "Literal" ? `"${id.value.value}"` : sourceCode.getText(id.value);
48
+ report(node, "outside-creator", "outsideCreator", { action: text.length > 60 ? `${text.slice(0, 57)}...` : text });
49
+ }
50
+ }
51
+ };
52
+ }
53
+ });
54
+
55
+ export { actionCreator };
@@ -0,0 +1,75 @@
1
+ import { createReporter, isActionExecute, keyName, ruleMeta } from "../support.js";
2
+
3
+ // A callee a reader follows in one step: a name or a member chain, with no
4
+ // call inside it.
5
+ function isPlainCallee(node) {
6
+ if (node.type === "Identifier" || node.type === "ThisExpression") {
7
+ return true;
8
+ }
9
+ return node.type === "MemberExpression" && isPlainCallee(node.object);
10
+ }
11
+
12
+ function unwrapCall(node) {
13
+ const value = node.type === "AwaitExpression" ? node.argument : node;
14
+ return value.type === "ChainExpression" ? value.expression : value;
15
+ }
16
+
17
+ // The shape problem of an action's execute, or null for
18
+ // `execute: (input, context) => service.method(…)`. Its parameters may
19
+ // destructure what must not reach the service (`({ workspaceSlug, ...payload },
20
+ // context)`), and a CRUD custom action destructures its declaration's
21
+ // `({ input, context, service })`. Async and unfrozen definitions are
22
+ // one-line-action's to report.
23
+ function shapeProblem(value) {
24
+ // A catalogue that normalizes definitions hands each one's execute on as it is.
25
+ if (value.type === "MemberExpression" && !value.computed && value.property.name === "execute") {
26
+ return null;
27
+ }
28
+ if (value.type === "CallExpression") {
29
+ return "wrappedExecute";
30
+ }
31
+ if (value.type === "FunctionExpression" || (value.type === "ArrowFunctionExpression" && value.body.type === "BlockStatement")) {
32
+ return "blockBody";
33
+ }
34
+ if (value.type !== "ArrowFunctionExpression") {
35
+ return "notArrow";
36
+ }
37
+ const body = unwrapCall(value.body);
38
+ if (body.type !== "CallExpression" || !isPlainCallee(body.callee)) {
39
+ return "notOneCall";
40
+ }
41
+ return null;
42
+ }
43
+
44
+ // An action's execute is one expression-bodied arrow that makes one call, so
45
+ // each action shows the one service method it reaches.
46
+ const actionExecuteShape = Object.freeze({
47
+ meta: ruleMeta("Require an action's execute to be one arrow whose body is one call.", {
48
+ wrappedExecute: "The execute of action {{action}} is built by {{helper}}(). Write the arrow literally: execute: (input, context) => service.method(input, { context }).",
49
+ blockBody: "The execute of action {{action}} has a body. Move the work into the service and make execute one call: (input, context) => service.method(input, { context }).",
50
+ notArrow: "The execute of action {{action}} is not an arrow. Write execute: (input, context) => service.method(input, { context }).",
51
+ notOneCall: "The execute of action {{action}} does not make exactly one call. Return what one service method returns: (input, context) => service.method(input, { context })."
52
+ }, "one-line-execute"),
53
+ create(context) {
54
+ const sourceCode = context.sourceCode;
55
+ const report = createReporter(context, "action-execute-shape");
56
+ return {
57
+ Property(node) {
58
+ if (!isActionExecute(node, node.parent)) {
59
+ return;
60
+ }
61
+ const messageId = shapeProblem(node.value);
62
+ if (!messageId) {
63
+ return;
64
+ }
65
+ const id = node.parent.properties.find((property) => keyName(property) === "id");
66
+ const idText = id.value.type === "Literal" ? `"${id.value.value}"` : sourceCode.getText(id.value);
67
+ const action = idText.length > 60 ? `${idText.slice(0, 57)}...` : idText;
68
+ const helper = node.value.type === "CallExpression" ? sourceCode.getText(node.value.callee) : "";
69
+ report(node, "execute-shape", messageId, { action, helper });
70
+ }
71
+ };
72
+ }
73
+ });
74
+
75
+ export { actionExecuteShape };
@@ -0,0 +1,145 @@
1
+ import { ruleMeta } from "../support.js";
2
+
3
+ const ALLOW_MARK = "readable-check: allow";
4
+ const ALLOW_FORM = /readable-check: allow (\S+) - (.+?)\s*$/u;
5
+
6
+ // A comment that describes the vocabulary itself names its rule as a
7
+ // placeholder: "readable-check: allow <rule> - <reason>".
8
+ const PLACEHOLDER = /readable-check: allow <[^>]+>/u;
9
+
10
+ // The fixed reason prefixes, each followed by what is true of the code.
11
+ const REASON_PREFIXES = Object.freeze([
12
+ /^input validation: \S/u,
13
+ /^data: \S/u,
14
+ /^platform object: \S/u,
15
+ /^optional operation: \S/u,
16
+ /^safety invariant: \S/u,
17
+ /^not a slot: \S/u,
18
+ /^cycle: \S.* requires \S/u,
19
+ /^deferred until boot: \S/u,
20
+ /^filled by \S.*; convert with its supplier/u,
21
+ /^cross-repository slot \S/u,
22
+ /^shared resource: \S/u
23
+ ]);
24
+ // A guard once kept because a test asserted its error now says what goes wrong
25
+ // without it, under safety invariant:.
26
+ const RETIRED_PREFIX = /^test asserts error:/u;
27
+
28
+ // A reason describes the code, never its tests or the state of a project.
29
+ const CITES_TESTS = /\b(tests?|testing|test doubles?|mock(?:s|ed)?|stub(?:s|bed)?|fixtures?|spies|spy)\b|\.test\./iu;
30
+ const PROJECT_WORK = "(?:converted|ported|migrated|typed|moved|split|removed|fixed)";
31
+ const CITES_STATUS = new RegExp(`\\b(?:not (?:yet )?${PROJECT_WORK}|un(?:converted|ported|migrated)|stays? unchanged|left as is|for now|later pass|` +
32
+ `until (?:\\S+ ){1,3}(?:is|are) ${PROJECT_WORK}|pending (?:conversion|review|migration)|still pending|is pending|project status)\\b`, "iu");
33
+ const MARKER = /\b(?:TODO|FIXME|XXX)\b/u;
34
+ // data: excuses a field or value that is data, never a function. A parameter default is excused at its function,
35
+ // so the default rules' data: reasons may stand on one.
36
+ const DATA_PREFIX = /^data: /u;
37
+ const PARAMETER_RULES = new Set(["dependency-default", "required-collaborators"]);
38
+ const FUNCTION_TYPES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
39
+
40
+ /**
41
+ * Whether a node is a function, or declares or holds one: a function or arrow, an exported function, a variable
42
+ * initialised with one, or a property, class field or method whose value is one.
43
+ * @param {any} node
44
+ */
45
+ function holdsFunction(node) {
46
+ if (FUNCTION_TYPES.has(node.type)) {
47
+ return true;
48
+ }
49
+ if (node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration") {
50
+ return Boolean(node.declaration) && holdsFunction(node.declaration);
51
+ }
52
+ if (node.type === "VariableDeclaration") {
53
+ return node.declarations.some((declarator) => declarator.init && FUNCTION_TYPES.has(declarator.init.type));
54
+ }
55
+ if (node.type === "Property" || node.type === "PropertyDefinition" || node.type === "MethodDefinition") {
56
+ return Boolean(node.value) && FUNCTION_TYPES.has(node.value.type);
57
+ }
58
+ return false;
59
+ }
60
+
61
+ /**
62
+ * The construct a comment on its own line excuses: the outermost node that starts at the next token. Null for a
63
+ * comment that trails code on its line or ends the file.
64
+ * @param {any} sourceCode
65
+ * @param {any} comment
66
+ */
67
+ function excusedNode(sourceCode, comment) {
68
+ const before = sourceCode.getTokenBefore(comment, { includeComments: true });
69
+ if (before && before.loc.end.line === comment.loc.start.line) {
70
+ return null;
71
+ }
72
+ const next = sourceCode.getTokenAfter(comment, { includeComments: false });
73
+ if (!next) {
74
+ return null;
75
+ }
76
+ let node = sourceCode.getNodeByRangeIndex(next.range[0]);
77
+ while (node?.parent && node.parent.type !== "Program" && node.parent.range[0] === next.range[0]) {
78
+ node = node.parent;
79
+ }
80
+ return node;
81
+ }
82
+
83
+ // Each line of a comment that carries an allow mark, with its position; `opening` is the length of the comment's
84
+ // opening mark (`//` or `/*`, or a template's `<!--`).
85
+ function allowLines(comment, opening = 2) {
86
+ return comment.value.split(/\r?\n/u).flatMap((text, index) => {
87
+ const column = text.indexOf(ALLOW_MARK);
88
+ if (column < 0 || PLACEHOLDER.test(text)) {
89
+ return [];
90
+ }
91
+ const line = comment.loc.start.line + index;
92
+ const startColumn = index === 0 ? comment.loc.start.column + opening + column : column;
93
+ const body = text.slice(column).trimEnd();
94
+ return [{ body, loc: { start: { line, column: startColumn }, end: { line, column: startColumn + body.length } } }];
95
+ });
96
+ }
97
+
98
+ // Every "readable-check: allow <rule> - <reason>" comment names a rule or
99
+ // finding, then a reason that starts with one of the fixed prefixes and says
100
+ // what is true of the code; a component template's HTML comments are read too.
101
+ const allowCommentForm = Object.freeze({
102
+ meta: ruleMeta("Require readable-check allow comments to give a reason with a fixed prefix that describes the code.", {
103
+ allowForm: "Write the exception as \"readable-check: allow <rule> - <reason>\".",
104
+ allowPrefix: "\"{{reason}}\" starts with no reason prefix. Start it with input validation:, data:, platform object:, optional operation:, safety invariant:, not a slot:, cycle: <supplier> requires <owner>, deferred until boot:, filled by <repository>; convert with its supplier, cross-repository slot <token>, or shared resource:.",
105
+ retiredPrefix: "\"{{reason}}\" uses the retired test asserts error:. Say what goes wrong without the guard under safety invariant: <what goes wrong>, or remove the guard.",
106
+ citesTests: "\"{{reason}}\" cites tests. Describe the code; a guard that protects an internal invariant says what goes wrong without it under safety invariant:.",
107
+ citesStatus: "\"{{reason}}\" cites the state of a project. Describe what is true of the code.",
108
+ dataOnFunction: "\"{{reason}}\" excuses a function with data:, which is only for data. Give the function's real reason, or remove its duplicate."
109
+ }, "exceptions"),
110
+ create(context) {
111
+ return {
112
+ Program() {
113
+ const templateComments = (context.sourceCode.ast.templateBody?.comments || [])
114
+ .filter((comment) => comment.type === "HTMLComment")
115
+ .map((comment) => ({ comment, opening: 4 }));
116
+ const scriptComments = context.sourceCode.getAllComments().map((comment) => ({ comment, opening: 2 }));
117
+ for (const { comment, opening } of [...scriptComments, ...templateComments]) {
118
+ for (const { body, loc } of allowLines(comment, opening)) {
119
+ const match = ALLOW_FORM.exec(body);
120
+ const reason = match?.[2] || "";
121
+ const data = { reason: reason.length > 60 ? `${reason.slice(0, 57)}...` : reason };
122
+ if (!match) {
123
+ context.report({ loc, messageId: "allowForm" });
124
+ } else if (RETIRED_PREFIX.test(reason)) {
125
+ context.report({ loc, messageId: "retiredPrefix", data });
126
+ } else if (!REASON_PREFIXES.some((prefix) => prefix.test(reason))) {
127
+ context.report({ loc, messageId: "allowPrefix", data });
128
+ } else if (CITES_TESTS.test(reason)) {
129
+ context.report({ loc, messageId: "citesTests", data });
130
+ } else if (CITES_STATUS.test(reason) || MARKER.test(reason)) {
131
+ context.report({ loc, messageId: "citesStatus", data });
132
+ } else if (DATA_PREFIX.test(reason) && !PARAMETER_RULES.has(match[1]) && opening === 2) {
133
+ const node = excusedNode(context.sourceCode, comment);
134
+ if (node && holdsFunction(node)) {
135
+ context.report({ loc, messageId: "dataOnFunction", data });
136
+ }
137
+ }
138
+ }
139
+ }
140
+ }
141
+ };
142
+ }
143
+ });
144
+
145
+ export { allowCommentForm };
@@ -0,0 +1,169 @@
1
+ import { createReporter, keyName, ruleMeta } from "../support.js";
2
+
3
+ // The kernel's error classes and where each takes its options: AppError and DomainError take (status, message,
4
+ // options), ConflictError and NotFoundError (message, options). Their default codes ("APP_ERROR", "domain_error",
5
+ // "conflict", "not_found") would be shared by every sentence that leaves its code out.
6
+ const KERNEL_ERRORS = Object.freeze({
7
+ AppError: { message: 1, options: 2 },
8
+ DomainError: { message: 1, options: 2 },
9
+ ConflictError: { message: 0, options: 1 },
10
+ NotFoundError: { message: 0, options: 1 }
11
+ });
12
+
13
+ function objectHasKey(node, name) {
14
+ return node?.type === "ObjectExpression" && node.properties.some((property) => property.type === "Property" && keyName(property) === name);
15
+ }
16
+
17
+ function hasSpread(node) {
18
+ return node?.type === "ObjectExpression" && node.properties.some((property) => property.type === "SpreadElement");
19
+ }
20
+
21
+ // Whether a class body fixes its own code: a `code:` property whose value is a literal, or `this.code = …`. A code
22
+ // that only falls back to a default (`code: options.code || "x"`) is not its own.
23
+ function setsOwnCode(classBody, sourceCode) {
24
+ const text = sourceCode.getText(classBody);
25
+ return /\bcode\s*:\s*["'`]/u.test(text) || /this\.code\s*=/u.test(text);
26
+ }
27
+
28
+ function parameterIndex(parameters, name) {
29
+ return parameters.findIndex((parameter) => (parameter.type === "AssignmentPattern" ? parameter.left : parameter).name === name);
30
+ }
31
+
32
+ // The constructor's `super(…)` call, made as a statement of its own.
33
+ function superCall(constructor) {
34
+ const statement = constructor?.value.body.body.find((member) => member.type === "ExpressionStatement" &&
35
+ member.expression.type === "CallExpression" && member.expression.callee.type === "Super");
36
+ return statement?.expression ?? null;
37
+ }
38
+
39
+ // Whether a destructured parameter's rest element (`{ approval, ...options }`) reaches `super` as the options it passes
40
+ // on, as they are (`options`) or spread (`{ ...options, details }`). The caller's code is then among them.
41
+ function forwardsRest(target, superOptions) {
42
+ const rest = target.type === "ObjectPattern" ? target.properties.find((property) => property.type === "RestElement") : null;
43
+ const name = rest?.argument.type === "Identifier" ? rest.argument.name : "";
44
+ if (!name || !superOptions) {
45
+ return false;
46
+ }
47
+ if (superOptions.type === "Identifier") {
48
+ return superOptions.name === name;
49
+ }
50
+ return superOptions.type === "ObjectExpression" && superOptions.properties.some((property) => property.type === "SpreadElement" &&
51
+ property.argument.type === "Identifier" && property.argument.name === name);
52
+ }
53
+
54
+ // The parameter that holds a call's options: one named `options`, one that destructures `code` (`{ code }`), or one
55
+ // whose rest element the constructor forwards to `super` as its options (`superOptions`, the argument `super` takes
56
+ // them in).
57
+ function optionsIndex(parameters, superOptions) {
58
+ return parameters.findIndex((parameter) => {
59
+ const target = parameter.type === "AssignmentPattern" ? parameter.left : parameter;
60
+ return target.name === "options" ||
61
+ (target.type === "ObjectPattern" && target.properties.some((property) => property.type === "Property" && keyName(property) === "code")) ||
62
+ forwardsRest(target, superOptions);
63
+ });
64
+ }
65
+
66
+ // The error classes this module declares over a kernel error class, by name: whether each fixes its own code, and
67
+ // where a call passes a code (a `code` parameter) or the options that hold one. A class without a constructor takes
68
+ // its parent's arguments.
69
+ function localErrorClasses(program, sourceCode) {
70
+ const classes = new Map();
71
+ const declarations = program.body.map((statement) => (statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration")
72
+ ? statement.declaration
73
+ : statement);
74
+ for (let grew = true; grew;) {
75
+ grew = false;
76
+ for (const declaration of declarations) {
77
+ const name = declaration?.type === "ClassDeclaration" ? declaration.id?.name : "";
78
+ const parentName = declaration?.superClass?.type === "Identifier" ? declaration.superClass.name : "";
79
+ const parent = KERNEL_ERRORS[parentName] ? { ownCode: false, code: -1, options: KERNEL_ERRORS[parentName].options } : classes.get(parentName);
80
+ if (!name || classes.has(name) || !parent) {
81
+ continue;
82
+ }
83
+ const constructor = declaration.body.body.find((member) => member.type === "MethodDefinition" && member.kind === "constructor");
84
+ const parameters = constructor?.value.params;
85
+ classes.set(name, {
86
+ ownCode: setsOwnCode(declaration.body, sourceCode) || parent.ownCode,
87
+ code: parameters ? parameterIndex(parameters, "code") : parent.code,
88
+ options: parameters ? optionsIndex(parameters, superCall(constructor)?.arguments[parent.options] ?? null) : parent.options
89
+ });
90
+ grew = true;
91
+ }
92
+ }
93
+ return classes;
94
+ }
95
+
96
+ // Whether a call passes a code where the class takes one: the options argument holds `code` (or may, as a variable
97
+ // or a spread), or a `code` parameter receives an argument.
98
+ function passesCode(node, { code, options }) {
99
+ if (code >= 0 && node.arguments.length > code) {
100
+ return true;
101
+ }
102
+ if (options < 0) {
103
+ return false;
104
+ }
105
+ const argument = node.arguments[options];
106
+ return Boolean(argument) && (argument.type !== "ObjectExpression" || objectHasKey(argument, "code") || hasSpread(argument));
107
+ }
108
+
109
+ function literalText(node) {
110
+ if (node?.type === "Literal" && typeof node.value === "string") {
111
+ return node.value;
112
+ }
113
+ if (node?.type === "TemplateLiteral" && node.expressions.length === 0) {
114
+ return node.quasis[0].value.cooked;
115
+ }
116
+ return null;
117
+ }
118
+
119
+ // A refusal meant for the caller is an AppError with its own code: one code per distinct sentence, so a client can
120
+ // tell refusals apart and show each in the user's language. The kernel's classes, and this module's subclasses of
121
+ // them, are checked; a subclass that fixes its own code needs none at the call.
122
+ const appErrorCode = Object.freeze({
123
+ meta: ruleMeta("Require each AppError refusal to carry its own code.", {
124
+ missingCode: "new {{name}}(…) has no code, so it shares {{name}}'s default code with every other refusal. Give it its own code: { code: \"<package>.<area>.<refusal>\" }.",
125
+ sharedCode: "Code \"{{code}}\" already names another sentence on line {{line}}. Give each refusal its own code, or keep the shared code and add messageCode."
126
+ }, "refusals-carry-their-own-code"),
127
+ create(context) {
128
+ const sourceCode = context.sourceCode;
129
+ const report = createReporter(context, "app-error-code");
130
+ const localClasses = localErrorClasses(sourceCode.ast, sourceCode);
131
+ const sentences = new Map();
132
+ function checkSharedCode(node, options, message) {
133
+ const codeProperty = options?.type === "ObjectExpression"
134
+ ? options.properties.find((property) => property.type === "Property" && keyName(property) === "code")
135
+ : null;
136
+ const code = literalText(codeProperty?.value);
137
+ const sentence = literalText(message);
138
+ if (code === null || sentence === null || objectHasKey(options, "messageCode")) {
139
+ return;
140
+ }
141
+ const first = sentences.get(code);
142
+ if (!first) {
143
+ sentences.set(code, { sentence, line: node.loc.start.line });
144
+ } else if (first.sentence !== sentence) {
145
+ report(node, "shared-code", "sharedCode", { code, line: first.line });
146
+ }
147
+ }
148
+ return {
149
+ NewExpression(node) {
150
+ const name = node.callee.type === "Identifier" ? node.callee.name : "";
151
+ const kernel = KERNEL_ERRORS[name];
152
+ const local = localClasses.get(name);
153
+ if (kernel) {
154
+ if (!passesCode(node, { code: -1, options: kernel.options })) {
155
+ report(node, "missing-code", "missingCode", { name });
156
+ return;
157
+ }
158
+ checkSharedCode(node, node.arguments[kernel.options], node.arguments[kernel.message]);
159
+ return;
160
+ }
161
+ if (local && !local.ownCode && !passesCode(node, local)) {
162
+ report(node, "missing-code", "missingCode", { name });
163
+ }
164
+ }
165
+ };
166
+ }
167
+ });
168
+
169
+ export { appErrorCode };