@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.
- package/README.md +135 -0
- package/base.js +5 -1
- package/clientFeatures.js +87 -0
- package/formatter.js +60 -0
- package/index.js +4 -0
- package/package.json +8 -1
- package/plugin/factoryResults.js +162 -0
- package/plugin/index.js +64 -0
- package/plugin/packageSources.js +133 -0
- package/plugin/rules/actionCreator.js +55 -0
- package/plugin/rules/actionExecuteShape.js +75 -0
- package/plugin/rules/allowCommentForm.js +145 -0
- package/plugin/rules/appErrorCode.js +169 -0
- package/plugin/rules/collaboratorProps.js +130 -0
- package/plugin/rules/fixedResultShape.js +96 -0
- package/plugin/rules/frozenFactoryResult.js +44 -0
- package/plugin/rules/jsdocDescriptionInside.js +45 -0
- package/plugin/rules/namedRepeatedType.js +178 -0
- package/plugin/rules/noDualShape.js +61 -0
- package/plugin/rules/noDuplicateHelper.js +146 -0
- package/plugin/rules/noEnvDefault.js +33 -0
- package/plugin/rules/noFactorySpread.js +99 -0
- package/plugin/rules/noFrozenVueState.js +48 -0
- package/plugin/rules/noIdempotency.js +26 -0
- package/plugin/rules/noModuleRegistry.js +61 -0
- package/plugin/rules/noModuleState.js +50 -0
- package/plugin/rules/noOptionalAfterDefault.js +91 -0
- package/plugin/rules/noPatchedError.js +50 -0
- package/plugin/rules/noSlotSetter.js +40 -0
- package/plugin/rules/noTestOnlyExport.js +38 -0
- package/plugin/rules/oneLineAction.js +98 -0
- package/plugin/rules/onePropertyPerLine.js +86 -0
- package/plugin/rules/releasedListeners.js +132 -0
- package/plugin/rules/requiredCollaborators.js +150 -0
- package/plugin/rules/typedDependencies.js +46 -0
- package/plugin/rules/typedInject.js +96 -0
- package/plugin/support.js +377 -0
- package/plugin/vueSources.js +59 -0
- 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 };
|