@barnum/eslint-plugin 0.0.0-main-9c142eea
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/dist/index.d.ts +8 -0
- package/dist/index.js +11 -0
- package/dist/rules/exported-handler.d.ts +3 -0
- package/dist/rules/exported-handler.js +147 -0
- package/dist/rules/require-callback-params.d.ts +3 -0
- package/dist/rules/require-callback-params.js +57 -0
- package/dist/rules/require-type-params.d.ts +3 -0
- package/dist/rules/require-type-params.js +92 -0
- package/package.json +36 -0
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import exportedHandler from "./rules/exported-handler.js";
|
|
2
|
+
import requireCallbackParams from "./rules/require-callback-params.js";
|
|
3
|
+
import requireTypeParams from "./rules/require-type-params.js";
|
|
4
|
+
const plugin = {
|
|
5
|
+
rules: {
|
|
6
|
+
"exported-handler": exportedHandler,
|
|
7
|
+
"require-callback-params": requireCallbackParams,
|
|
8
|
+
"require-type-params": requireTypeParams,
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
export default plugin;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
const HANDLER_FACTORIES = new Set([
|
|
2
|
+
"createHandler",
|
|
3
|
+
"createHandlerWithConfig",
|
|
4
|
+
]);
|
|
5
|
+
const rule = {
|
|
6
|
+
meta: {
|
|
7
|
+
type: "problem",
|
|
8
|
+
docs: {
|
|
9
|
+
description: "Require createHandler/createHandlerWithConfig calls to be exported with a name matching the exportName argument",
|
|
10
|
+
},
|
|
11
|
+
messages: {
|
|
12
|
+
unexported: "createHandler/createHandlerWithConfig must be exported. The runtime resolves handlers by module path + export name.",
|
|
13
|
+
nameMismatch: "Export name '{{ exportName }}' does not match the exportName argument '{{ argName }}'. The runtime uses the exportName argument to locate the handler at {{ module }}:{{ argName }}.",
|
|
14
|
+
defaultMissingArg: "Default exports should omit the exportName argument or pass 'default'. Got '{{ argName }}'.",
|
|
15
|
+
moduleExports: "module.exports assignment is not supported. Use ES module export syntax.",
|
|
16
|
+
},
|
|
17
|
+
schema: [],
|
|
18
|
+
},
|
|
19
|
+
create(context) {
|
|
20
|
+
function getCalleeHandlerName(node) {
|
|
21
|
+
if (node.type === "CallExpression" &&
|
|
22
|
+
node.callee?.type === "Identifier" &&
|
|
23
|
+
node.callee.name &&
|
|
24
|
+
HANDLER_FACTORIES.has(node.callee.name)) {
|
|
25
|
+
return node.callee.name;
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
function getExportNameArg(node) {
|
|
30
|
+
// Second argument is the exportName string
|
|
31
|
+
const arg = node.arguments[1];
|
|
32
|
+
if (!arg)
|
|
33
|
+
return null;
|
|
34
|
+
if (arg.type === "Literal" && typeof arg.value === "string") {
|
|
35
|
+
return arg.value;
|
|
36
|
+
}
|
|
37
|
+
// Non-string-literal second arg — can't statically verify
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
// Catch module.exports.foo = createHandler(...)
|
|
42
|
+
AssignmentExpression(node) {
|
|
43
|
+
if (node.right.type === "CallExpression" &&
|
|
44
|
+
getCalleeHandlerName(node.right)) {
|
|
45
|
+
if (node.left.type === "MemberExpression" &&
|
|
46
|
+
node.left.object.type === "MemberExpression" &&
|
|
47
|
+
node.left.object.object.type === "Identifier" &&
|
|
48
|
+
node.left.object.object.name === "module" &&
|
|
49
|
+
node.left.object.property.type === "Identifier" &&
|
|
50
|
+
node.left.object.property.name === "exports") {
|
|
51
|
+
context.report({ node, messageId: "moduleExports" });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (node.left.type === "MemberExpression" &&
|
|
55
|
+
node.left.object.type === "Identifier" &&
|
|
56
|
+
node.left.object.name === "exports") {
|
|
57
|
+
context.report({ node, messageId: "moduleExports" });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
// Catch all CallExpressions to createHandler/createHandlerWithConfig
|
|
63
|
+
CallExpression(node) {
|
|
64
|
+
if (!getCalleeHandlerName(node))
|
|
65
|
+
return;
|
|
66
|
+
// Walk up to find if this is in an export context
|
|
67
|
+
const parent = node.parent;
|
|
68
|
+
if (!parent) {
|
|
69
|
+
context.report({ node, messageId: "unexported" });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
// Already reported by AssignmentExpression handler
|
|
73
|
+
if (parent.type === "AssignmentExpression") {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// Case 1: export default createHandler(...)
|
|
77
|
+
if (parent.type === "ExportDefaultDeclaration") {
|
|
78
|
+
const argName = getExportNameArg(node);
|
|
79
|
+
if (argName !== null && argName !== "default") {
|
|
80
|
+
context.report({
|
|
81
|
+
node,
|
|
82
|
+
messageId: "defaultMissingArg",
|
|
83
|
+
data: { argName },
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Case 2: export const foo = createHandler({...}, "foo")
|
|
89
|
+
// The CallExpression is inside a VariableDeclarator
|
|
90
|
+
if (parent.type === "VariableDeclarator") {
|
|
91
|
+
const declaration = parent.parent;
|
|
92
|
+
if (!declaration || declaration.type !== "VariableDeclaration") {
|
|
93
|
+
context.report({ node, messageId: "unexported" });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const exportNode = declaration.parent;
|
|
97
|
+
if (!exportNode ||
|
|
98
|
+
exportNode.type !== "ExportNamedDeclaration") {
|
|
99
|
+
context.report({ node, messageId: "unexported" });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// It's exported — now check name match
|
|
103
|
+
if (parent.id.type !== "Identifier") {
|
|
104
|
+
// Destructuring pattern — weird but report
|
|
105
|
+
context.report({ node, messageId: "unexported" });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const exportName = parent.id.name;
|
|
109
|
+
const argName = getExportNameArg(node);
|
|
110
|
+
// If no second arg, the runtime uses "default" — that won't match
|
|
111
|
+
// a named export unless the export is literally named "default"
|
|
112
|
+
if (argName === null && node.arguments.length < 2) {
|
|
113
|
+
// No exportName arg provided — runtime defaults to "default"
|
|
114
|
+
// but this is a named export, so it won't resolve
|
|
115
|
+
if (exportName !== "default") {
|
|
116
|
+
context.report({
|
|
117
|
+
node,
|
|
118
|
+
messageId: "nameMismatch",
|
|
119
|
+
data: {
|
|
120
|
+
exportName,
|
|
121
|
+
argName: "default",
|
|
122
|
+
module: "<this file>",
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (argName !== null && argName !== exportName) {
|
|
129
|
+
context.report({
|
|
130
|
+
node,
|
|
131
|
+
messageId: "nameMismatch",
|
|
132
|
+
data: {
|
|
133
|
+
exportName,
|
|
134
|
+
argName,
|
|
135
|
+
module: "<this file>",
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// Any other position — not exported
|
|
142
|
+
context.report({ node, messageId: "unexported" });
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
export default rule;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Combinator name → minimum required callback params (first argument).
|
|
3
|
+
* Applies to both standalone calls and postfix method calls by name.
|
|
4
|
+
* Add/remove entries here to adjust what's tracked.
|
|
5
|
+
*/
|
|
6
|
+
const MIN_CALLBACK_PARAMS = new Map([
|
|
7
|
+
["bindInput", 1],
|
|
8
|
+
["loop", 1],
|
|
9
|
+
["earlyReturn", 1],
|
|
10
|
+
]);
|
|
11
|
+
const rule = {
|
|
12
|
+
meta: {
|
|
13
|
+
type: "problem",
|
|
14
|
+
docs: {
|
|
15
|
+
description: "Require combinator callbacks to declare their parameters. A zero-arity callback ignores values the combinator provides.",
|
|
16
|
+
},
|
|
17
|
+
messages: {
|
|
18
|
+
missingParams: "'{{ name }}' callback must declare at least {{ min }} parameter(s). The callback receives values from the combinator — ignoring them is likely a bug.",
|
|
19
|
+
},
|
|
20
|
+
schema: [],
|
|
21
|
+
},
|
|
22
|
+
create(context) {
|
|
23
|
+
return {
|
|
24
|
+
CallExpression(node) {
|
|
25
|
+
let name;
|
|
26
|
+
if (node.callee.type === "Identifier") {
|
|
27
|
+
name = node.callee.name;
|
|
28
|
+
}
|
|
29
|
+
else if (node.callee.type === "MemberExpression" &&
|
|
30
|
+
node.callee
|
|
31
|
+
.property.type === "Identifier") {
|
|
32
|
+
name = node.callee.property.name;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const minParams = MIN_CALLBACK_PARAMS.get(name);
|
|
38
|
+
if (minParams === undefined)
|
|
39
|
+
return;
|
|
40
|
+
const bodyArg = node.arguments?.[0];
|
|
41
|
+
if (!bodyArg)
|
|
42
|
+
return;
|
|
43
|
+
if (bodyArg.type === "ArrowFunctionExpression" ||
|
|
44
|
+
bodyArg.type === "FunctionExpression") {
|
|
45
|
+
if (bodyArg.params.length < minParams) {
|
|
46
|
+
context.report({
|
|
47
|
+
node: bodyArg,
|
|
48
|
+
messageId: "missingParams",
|
|
49
|
+
data: { name, min: String(minParams) },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
export default rule;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** Standalone call configs: loop(...), bindInput(...), earlyReturn(...) */
|
|
2
|
+
const STANDALONE_CONFIG = new Map([
|
|
3
|
+
["loop", { minParams: 1, params: ["input", "output"] }], // TIn, TOut
|
|
4
|
+
["earlyReturn", { minParams: 1, params: ["output", "input", "output"] }], // TEarlyReturn, TIn, TOut
|
|
5
|
+
["bindInput", { minParams: 2, params: ["input", "output"] }], // TIn, TOut
|
|
6
|
+
]);
|
|
7
|
+
/** Postfix method call configs: x.bindInput<TOut>(...) */
|
|
8
|
+
const POSTFIX_CONFIG = new Map([
|
|
9
|
+
["bindInput", { minParams: 1, params: ["output"] }], // TOut only
|
|
10
|
+
]);
|
|
11
|
+
function getTypeName(node) {
|
|
12
|
+
if (node.type === "TSAnyKeyword")
|
|
13
|
+
return "any";
|
|
14
|
+
if (node.type === "TSUnknownKeyword")
|
|
15
|
+
return "unknown";
|
|
16
|
+
if (node.type === "TSNeverKeyword")
|
|
17
|
+
return "never";
|
|
18
|
+
if (node.type === "TSTypeReference" && node.typeName?.name) {
|
|
19
|
+
return node.typeName.name;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const rule = {
|
|
24
|
+
meta: {
|
|
25
|
+
type: "problem",
|
|
26
|
+
docs: {
|
|
27
|
+
description: "Require explicit type parameters on loop, earlyReturn, and bindInput. Disallow `any` in output positions and `unknown` in input positions.",
|
|
28
|
+
},
|
|
29
|
+
messages: {
|
|
30
|
+
missingTypeParams: "'{{ name }}' requires at least {{ min }} explicit type parameter(s). TypeScript defaults mask type errors (e.g., `any` silently satisfies `never` constraints in loop bodies).",
|
|
31
|
+
anyInOutput: "Type parameter {{ position }} of '{{ name }}' is an output type — `any` defeats type checking. Use a concrete type or `never` for loop/earlyReturn bodies.",
|
|
32
|
+
unknownInInput: "Type parameter {{ position }} of '{{ name }}' is an input type — `unknown` is too wide. Use the concrete input type.",
|
|
33
|
+
},
|
|
34
|
+
schema: [],
|
|
35
|
+
},
|
|
36
|
+
create(context) {
|
|
37
|
+
return {
|
|
38
|
+
CallExpression(node) {
|
|
39
|
+
let name;
|
|
40
|
+
let config;
|
|
41
|
+
if (node.callee.type === "Identifier") {
|
|
42
|
+
name = node.callee.name;
|
|
43
|
+
config = STANDALONE_CONFIG.get(name);
|
|
44
|
+
}
|
|
45
|
+
else if (node.callee.type === "MemberExpression" &&
|
|
46
|
+
node.callee.property.type === "Identifier") {
|
|
47
|
+
name = node.callee.property.name;
|
|
48
|
+
config = POSTFIX_CONFIG.get(name);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!config)
|
|
54
|
+
return;
|
|
55
|
+
// Check for type parameters (TSTypeParameterInstantiation)
|
|
56
|
+
// typescript-eslint uses "typeArguments" on CallExpression nodes
|
|
57
|
+
const typeParams = node.typeArguments;
|
|
58
|
+
if (!typeParams || typeParams.params.length < config.minParams) {
|
|
59
|
+
context.report({
|
|
60
|
+
node,
|
|
61
|
+
messageId: "missingTypeParams",
|
|
62
|
+
data: { name, min: String(config.minParams) },
|
|
63
|
+
});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// Check each provided type parameter
|
|
67
|
+
for (let i = 0; i < typeParams.params.length; i++) {
|
|
68
|
+
const param = typeParams.params[i];
|
|
69
|
+
const position = config.params[i];
|
|
70
|
+
if (!position)
|
|
71
|
+
break;
|
|
72
|
+
const typeName = getTypeName(param);
|
|
73
|
+
if (position === "output" && typeName === "any") {
|
|
74
|
+
context.report({
|
|
75
|
+
node: param,
|
|
76
|
+
messageId: "anyInOutput",
|
|
77
|
+
data: { name, position: String(i + 1) },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if (position === "input" && typeName === "unknown") {
|
|
81
|
+
context.report({
|
|
82
|
+
node: param,
|
|
83
|
+
messageId: "unknownInInput",
|
|
84
|
+
data: { name, position: String(i + 1) },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
export default rule;
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@barnum/eslint-plugin",
|
|
3
|
+
"version": "0.0.0-main-9c142eea",
|
|
4
|
+
"description": "ESLint rules for Barnum workflows",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc -p tsconfig.build.json",
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"typecheck": "tsc --noEmit"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"eslint": ">=9.0.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^25.5.0",
|
|
24
|
+
"eslint": "^9.27.0",
|
|
25
|
+
"typescript": "^6.0.3",
|
|
26
|
+
"typescript-eslint": "^8.32.1",
|
|
27
|
+
"vitest": "^3.0.0"
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/barnum-circus/barnum.git"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist/**/*"
|
|
35
|
+
]
|
|
36
|
+
}
|