@graphorin/eslint-plugin 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +12 -4
- package/dist/index.d.ts +30 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +239 -22
- package/dist/index.js.map +1 -1
- package/package.json +6 -3
- package/src/index.ts +113 -0
- package/src/rules/_comment-utils.ts +37 -0
- package/src/rules/no-bare-tool-exec.ts +120 -0
- package/src/rules/no-implicit-network-call.ts +236 -0
- package/src/rules/no-secret-in-deps.ts +113 -0
- package/src/rules/no-secret-unwrap.ts +118 -0
- package/src/rules/no-third-party-workflow-aliases.ts +100 -0
- package/src/rules/provider-middleware-order.ts +113 -0
- package/src/rules/tool-description-required.ts +75 -0
- package/src/rules/tool-examples-recommended.ts +61 -0
- package/src/rules/tool-parameter-naming.ts +75 -0
- package/src/tool-discovery.ts +804 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule: `@graphorin/no-third-party-workflow-aliases` (DEC-019 /
|
|
3
|
+
* ADR-029). Flags any identifier in `@graphorin/workflow` source whose
|
|
4
|
+
* name matches a known third-party workflow primitive (e.g. the
|
|
5
|
+
* library-specific names that Graphorin deliberately renamed). The
|
|
6
|
+
* intent is legal hygiene: the framework's primitives are
|
|
7
|
+
* `Directive`, `Dispatch`, `pause`, `LatestValue`, `Reducer`,
|
|
8
|
+
* `Stream`, `Barrier`, `Ephemeral`, `AnyValue` - we never reuse
|
|
9
|
+
* external library identifiers in the public API.
|
|
10
|
+
*
|
|
11
|
+
* The forbidden list is intentionally narrow - we only flag the
|
|
12
|
+
* canonical proper-noun primitives third-party workflow engines use.
|
|
13
|
+
* Rare false positives (e.g. an internal helper named `Send` for an
|
|
14
|
+
* unrelated reason) are mitigated by the per-occurrence opt-out
|
|
15
|
+
* comment `// graphorin-workflow-naming-allow: <reason>`.
|
|
16
|
+
*
|
|
17
|
+
* The rule activates only on files inside the
|
|
18
|
+
* `@graphorin/workflow` package source tree (path matcher
|
|
19
|
+
* `packages/workflow/src`).
|
|
20
|
+
*
|
|
21
|
+
* @packageDocumentation
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { Rule } from 'eslint';
|
|
25
|
+
import type { Identifier, Node } from 'estree';
|
|
26
|
+
|
|
27
|
+
import { nodeHasNearbyComment } from './_comment-utils.js';
|
|
28
|
+
|
|
29
|
+
const ALLOW_TAG = /graphorin-workflow-naming-allow/;
|
|
30
|
+
const WORKFLOW_PATH_RE = /\bpackages\/workflow\/src\b/;
|
|
31
|
+
|
|
32
|
+
const FORBIDDEN_NAMES: ReadonlyMap<string, string> = new Map([
|
|
33
|
+
['Send', 'Dispatch'],
|
|
34
|
+
['Command', 'Directive'],
|
|
35
|
+
['interrupt', 'pause'],
|
|
36
|
+
['LastValue', 'LatestValue'],
|
|
37
|
+
['BinaryOperatorAggregate', 'Reducer'],
|
|
38
|
+
['BinaryAggregate', 'Reducer'],
|
|
39
|
+
['Topic', 'Stream'],
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const rule: Rule.RuleModule = {
|
|
43
|
+
meta: {
|
|
44
|
+
type: 'problem',
|
|
45
|
+
docs: {
|
|
46
|
+
description:
|
|
47
|
+
'Disallow third-party workflow primitive identifiers in `@graphorin/workflow` source. Graphorin owns its primitive names per DEC-019.',
|
|
48
|
+
recommended: true,
|
|
49
|
+
},
|
|
50
|
+
schema: [],
|
|
51
|
+
messages: {
|
|
52
|
+
forbidden:
|
|
53
|
+
"identifier '{{forbidden}}' is reserved for the third-party workflow library it originates from; use '{{replacement}}' (DEC-019).",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
create(context: Rule.RuleContext): Rule.RuleListener {
|
|
57
|
+
if (!WORKFLOW_PATH_RE.test(context.filename.replace(/\\/g, '/'))) {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
Identifier(node: Identifier): void {
|
|
62
|
+
const replacement = FORBIDDEN_NAMES.get(node.name);
|
|
63
|
+
if (replacement === undefined) return;
|
|
64
|
+
if (isInImportSpecifier(node)) return;
|
|
65
|
+
if (hasAllowComment(context, node)) return;
|
|
66
|
+
context.report({
|
|
67
|
+
node,
|
|
68
|
+
messageId: 'forbidden',
|
|
69
|
+
data: {
|
|
70
|
+
forbidden: node.name,
|
|
71
|
+
replacement,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
function isInImportSpecifier(node: Identifier): boolean {
|
|
80
|
+
// Skip identifiers used as keys / property names - those are
|
|
81
|
+
// typically referencing external library exports the user is
|
|
82
|
+
// intentionally importing into a renamed local symbol.
|
|
83
|
+
const parent = (node as Identifier & { parent?: Node }).parent;
|
|
84
|
+
if (parent === undefined) return false;
|
|
85
|
+
switch (parent.type) {
|
|
86
|
+
case 'ImportSpecifier':
|
|
87
|
+
case 'ImportNamespaceSpecifier':
|
|
88
|
+
case 'ImportDefaultSpecifier':
|
|
89
|
+
case 'ExportSpecifier':
|
|
90
|
+
return true;
|
|
91
|
+
default:
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function hasAllowComment(context: Rule.RuleContext, node: Identifier): boolean {
|
|
97
|
+
return nodeHasNearbyComment(context, node, ALLOW_TAG, 1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default rule;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule: `@graphorin/provider-middleware-order` (DEC-145 / ADR-039).
|
|
3
|
+
* Lint-time enforcement of the canonical middleware ordering for
|
|
4
|
+
* `composeProviderMiddleware([...])`. Catches the same class of error
|
|
5
|
+
* the runtime composer raises (`MiddlewareOrderingError`) without
|
|
6
|
+
* requiring the runtime to actually wire the chain.
|
|
7
|
+
*
|
|
8
|
+
* The canonical order, outermost → innermost, mirrors the runtime
|
|
9
|
+
* constant `CANONICAL_MIDDLEWARE_ORDER` exported by
|
|
10
|
+
* `@graphorin/provider/middleware`. Built-ins not in the canonical
|
|
11
|
+
* list are tolerated at any position.
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Rule } from 'eslint';
|
|
17
|
+
import type { ArrayExpression, CallExpression, Identifier, Node } from 'estree';
|
|
18
|
+
|
|
19
|
+
const CANONICAL_ORDER: ReadonlyArray<string> = [
|
|
20
|
+
'withTracing',
|
|
21
|
+
'withRetry',
|
|
22
|
+
'withRateLimit',
|
|
23
|
+
'withCostLimit',
|
|
24
|
+
'withCostTracking',
|
|
25
|
+
'withFallback',
|
|
26
|
+
'withRedaction',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const CANONICAL_INDEX: ReadonlyMap<string, number> = new Map(
|
|
30
|
+
CANONICAL_ORDER.map((name, idx) => [name, idx]),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const rule: Rule.RuleModule = {
|
|
34
|
+
meta: {
|
|
35
|
+
type: 'problem',
|
|
36
|
+
docs: {
|
|
37
|
+
description:
|
|
38
|
+
'Enforce the canonical provider-middleware ordering at lint time. Mirrors the runtime `MiddlewareOrderingError` from `@graphorin/provider/middleware`.',
|
|
39
|
+
recommended: true,
|
|
40
|
+
},
|
|
41
|
+
schema: [],
|
|
42
|
+
messages: {
|
|
43
|
+
orderingViolation: "'{{outer}}' must appear before '{{inner}}' (canonical order: {{order}}).",
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
create(context: Rule.RuleContext): Rule.RuleListener {
|
|
47
|
+
return {
|
|
48
|
+
CallExpression(node: CallExpression): void {
|
|
49
|
+
if (!isComposeCall(node)) return;
|
|
50
|
+
const arg = node.arguments[0];
|
|
51
|
+
if (arg === undefined || arg.type !== 'ArrayExpression') return;
|
|
52
|
+
const factories = extractFactoryNames(arg as ArrayExpression);
|
|
53
|
+
const recognised: { name: string; index: number; node: Node }[] = [];
|
|
54
|
+
for (const f of factories) {
|
|
55
|
+
const idx = CANONICAL_INDEX.get(f.name);
|
|
56
|
+
if (idx !== undefined) recognised.push({ name: f.name, index: idx, node: f.node });
|
|
57
|
+
}
|
|
58
|
+
for (let i = 1; i < recognised.length; i++) {
|
|
59
|
+
const prev = recognised[i - 1];
|
|
60
|
+
const cur = recognised[i];
|
|
61
|
+
if (prev !== undefined && cur !== undefined && prev.index > cur.index) {
|
|
62
|
+
context.report({
|
|
63
|
+
node: cur.node,
|
|
64
|
+
messageId: 'orderingViolation',
|
|
65
|
+
data: {
|
|
66
|
+
outer: cur.name,
|
|
67
|
+
inner: prev.name,
|
|
68
|
+
order: CANONICAL_ORDER.join(' → '),
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function isComposeCall(node: CallExpression): boolean {
|
|
79
|
+
if (node.callee.type === 'Identifier') {
|
|
80
|
+
return (node.callee as Identifier).name === 'composeProviderMiddleware';
|
|
81
|
+
}
|
|
82
|
+
if (node.callee.type === 'MemberExpression') {
|
|
83
|
+
const prop = node.callee.property;
|
|
84
|
+
if (prop.type === 'Identifier' && prop.name === 'composeProviderMiddleware') return true;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function extractFactoryNames(arr: ArrayExpression): { name: string; node: Node }[] {
|
|
90
|
+
const out: { name: string; node: Node }[] = [];
|
|
91
|
+
for (const elt of arr.elements) {
|
|
92
|
+
if (elt === null) continue;
|
|
93
|
+
// Each element is one of:
|
|
94
|
+
// - `withTracing()` / `withRetry({...})` - CallExpression with Identifier callee
|
|
95
|
+
// - `withTracing` (factory ref) - Identifier
|
|
96
|
+
// - `provider.withTracing()` - CallExpression with MemberExpression callee
|
|
97
|
+
if (elt.type === 'Identifier') {
|
|
98
|
+
out.push({ name: (elt as Identifier).name, node: elt });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (elt.type === 'CallExpression') {
|
|
102
|
+
const callee = elt.callee;
|
|
103
|
+
if (callee.type === 'Identifier') {
|
|
104
|
+
out.push({ name: (callee as Identifier).name, node: elt });
|
|
105
|
+
} else if (callee.type === 'MemberExpression' && callee.property.type === 'Identifier') {
|
|
106
|
+
out.push({ name: callee.property.name, node: elt });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export default rule;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule: `@graphorin/tool-description-required` (RB-49 / suggested
|
|
3
|
+
* DEC-165). Flags `tool({...})` invocations whose `description`
|
|
4
|
+
* field is missing, too short (< 20 characters), or a placeholder
|
|
5
|
+
* value (`'TODO'` / `'FIXME'` / `'tbd'` / `'description'` /
|
|
6
|
+
* `'placeholder'`, case-insensitive).
|
|
7
|
+
*
|
|
8
|
+
* The rule shares its discovery + scoring code with the
|
|
9
|
+
* `graphorin tools lint` CLI subcommand (Phase 15) so the rule
|
|
10
|
+
* logic has a single source of truth.
|
|
11
|
+
*
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Rule } from 'eslint';
|
|
16
|
+
|
|
17
|
+
import { discoverToolCallsInSource, PLACEHOLDER_DESCRIPTIONS } from '../tool-discovery.js';
|
|
18
|
+
|
|
19
|
+
const MIN_DESCRIPTION_LENGTH = 20;
|
|
20
|
+
|
|
21
|
+
const rule: Rule.RuleModule = {
|
|
22
|
+
meta: {
|
|
23
|
+
type: 'problem',
|
|
24
|
+
docs: {
|
|
25
|
+
description:
|
|
26
|
+
'Require a meaningful `description` on every `tool({...})` registration. RB-49 (Anthropic 2026 advanced tool use guidance).',
|
|
27
|
+
recommended: true,
|
|
28
|
+
},
|
|
29
|
+
schema: [],
|
|
30
|
+
messages: {
|
|
31
|
+
missing:
|
|
32
|
+
"tool '{{name}}' has no description; add a description that explains what the tool does and when to use it.",
|
|
33
|
+
tooShort:
|
|
34
|
+
"tool '{{name}}' description is shorter than {{min}} characters (currently {{len}}).",
|
|
35
|
+
placeholder: "tool '{{name}}' description is a placeholder ('{{value}}').",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
create(context: Rule.RuleContext): Rule.RuleListener {
|
|
39
|
+
return {
|
|
40
|
+
'Program:exit'(): void {
|
|
41
|
+
const filename = context.filename;
|
|
42
|
+
const source = context.sourceCode.text;
|
|
43
|
+
const tools = discoverToolCallsInSource(filename, source);
|
|
44
|
+
for (const tool of tools) {
|
|
45
|
+
const desc = tool.description?.trim();
|
|
46
|
+
if (desc === undefined || desc.length === 0) {
|
|
47
|
+
context.report({
|
|
48
|
+
loc: { line: tool.line, column: 0 },
|
|
49
|
+
messageId: 'missing',
|
|
50
|
+
data: { name: tool.name },
|
|
51
|
+
});
|
|
52
|
+
} else if (desc.length < MIN_DESCRIPTION_LENGTH) {
|
|
53
|
+
context.report({
|
|
54
|
+
loc: { line: tool.line, column: 0 },
|
|
55
|
+
messageId: 'tooShort',
|
|
56
|
+
data: {
|
|
57
|
+
name: tool.name,
|
|
58
|
+
min: String(MIN_DESCRIPTION_LENGTH),
|
|
59
|
+
len: String(desc.length),
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
} else if (PLACEHOLDER_DESCRIPTIONS.includes(desc.toLowerCase())) {
|
|
63
|
+
context.report({
|
|
64
|
+
loc: { line: tool.line, column: 0 },
|
|
65
|
+
messageId: 'placeholder',
|
|
66
|
+
data: { name: tool.name, value: desc },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export default rule;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule: `@graphorin/tool-examples-recommended` (RB-49). Flags
|
|
3
|
+
* `tool({...})` invocations whose `examples` field is missing,
|
|
4
|
+
* empty, or longer than the documented upper bound (5).
|
|
5
|
+
*
|
|
6
|
+
* @packageDocumentation
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Rule } from 'eslint';
|
|
10
|
+
|
|
11
|
+
import { discoverToolCallsInSource } from '../tool-discovery.js';
|
|
12
|
+
|
|
13
|
+
const MAX_EXAMPLES = 5;
|
|
14
|
+
|
|
15
|
+
const rule: Rule.RuleModule = {
|
|
16
|
+
meta: {
|
|
17
|
+
type: 'suggestion',
|
|
18
|
+
docs: {
|
|
19
|
+
description:
|
|
20
|
+
"Recommend 1-5 `examples` entries on every `tool({...})` registration (Anthropic 2026 'Writing effective tools' guidance).",
|
|
21
|
+
recommended: true,
|
|
22
|
+
},
|
|
23
|
+
schema: [],
|
|
24
|
+
messages: {
|
|
25
|
+
missing:
|
|
26
|
+
"tool '{{name}}' has no examples; add 1-5 worked examples per Anthropic 2026 guidance.",
|
|
27
|
+
tooMany:
|
|
28
|
+
"tool '{{name}}' declares {{count}} examples; the documented upper bound is {{max}}.",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
create(context: Rule.RuleContext): Rule.RuleListener {
|
|
32
|
+
return {
|
|
33
|
+
'Program:exit'(): void {
|
|
34
|
+
const filename = context.filename;
|
|
35
|
+
const source = context.sourceCode.text;
|
|
36
|
+
const tools = discoverToolCallsInSource(filename, source);
|
|
37
|
+
for (const tool of tools) {
|
|
38
|
+
if (!tool.hasExamples || tool.examplesCount === 0) {
|
|
39
|
+
context.report({
|
|
40
|
+
loc: { line: tool.line, column: 0 },
|
|
41
|
+
messageId: 'missing',
|
|
42
|
+
data: { name: tool.name },
|
|
43
|
+
});
|
|
44
|
+
} else if (tool.examplesCount > MAX_EXAMPLES) {
|
|
45
|
+
context.report({
|
|
46
|
+
loc: { line: tool.line, column: 0 },
|
|
47
|
+
messageId: 'tooMany',
|
|
48
|
+
data: {
|
|
49
|
+
name: tool.name,
|
|
50
|
+
count: String(tool.examplesCount),
|
|
51
|
+
max: String(MAX_EXAMPLES),
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export default rule;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule: `@graphorin/tool-parameter-naming` (RB-49). Inspects the
|
|
3
|
+
* `inputSchema: z.object({...})` declaration of every `tool({...})`
|
|
4
|
+
* invocation and flags two anti-patterns:
|
|
5
|
+
*
|
|
6
|
+
* - **Ambiguous single-word identifiers** (`user`, `id`, `name`,
|
|
7
|
+
* `value`, `data`, `input`, `output`, `result`, `to`, `from`,
|
|
8
|
+
* `key`, `field`). Suggest a self-documenting alternative.
|
|
9
|
+
* - **Numeric-suffix identifiers** (`arg1`, `arg2`, `param3`).
|
|
10
|
+
* Suggest a semantic name.
|
|
11
|
+
*
|
|
12
|
+
* Per-tool opt-out: when the tool declares `tags: ['experimental']`
|
|
13
|
+
* or `tags: ['legacy']` the rule is suppressed for that registration.
|
|
14
|
+
* This lets operators defer the rename for a long tail of pre-RB-49
|
|
15
|
+
* tools while the framework migrates without breaking calling code.
|
|
16
|
+
*
|
|
17
|
+
* @packageDocumentation
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Rule } from 'eslint';
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
AMBIGUOUS_PARAMETER_NAMES,
|
|
24
|
+
discoverToolCallsInSource,
|
|
25
|
+
PARAMETER_NAMING_OPT_OUT_TAGS,
|
|
26
|
+
} from '../tool-discovery.js';
|
|
27
|
+
|
|
28
|
+
const NUMERIC_SUFFIX_PATTERN = /^[A-Za-z]+\d+$/;
|
|
29
|
+
|
|
30
|
+
const rule: Rule.RuleModule = {
|
|
31
|
+
meta: {
|
|
32
|
+
type: 'suggestion',
|
|
33
|
+
docs: {
|
|
34
|
+
description:
|
|
35
|
+
'Flag ambiguous or numeric-suffix parameter names on `tool({...})` `inputSchema` declarations (RB-49 - write self-documenting parameter names).',
|
|
36
|
+
recommended: true,
|
|
37
|
+
},
|
|
38
|
+
schema: [],
|
|
39
|
+
messages: {
|
|
40
|
+
ambiguous:
|
|
41
|
+
"tool '{{name}}' uses ambiguous parameter name '{{param}}'; prefer a self-documenting name (e.g. '{{param}}Id', '{{param}}Email').",
|
|
42
|
+
numericSuffix:
|
|
43
|
+
"tool '{{name}}' uses numeric-suffix parameter name '{{param}}'; prefer a semantic name (e.g. 'queryText', 'userId').",
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
create(context: Rule.RuleContext): Rule.RuleListener {
|
|
47
|
+
return {
|
|
48
|
+
'Program:exit'(): void {
|
|
49
|
+
const filename = context.filename;
|
|
50
|
+
const source = context.sourceCode.text;
|
|
51
|
+
const tools = discoverToolCallsInSource(filename, source);
|
|
52
|
+
for (const tool of tools) {
|
|
53
|
+
if (tool.tags.some((t) => PARAMETER_NAMING_OPT_OUT_TAGS.includes(t))) continue;
|
|
54
|
+
for (const param of tool.parameterNames) {
|
|
55
|
+
if (AMBIGUOUS_PARAMETER_NAMES.includes(param)) {
|
|
56
|
+
context.report({
|
|
57
|
+
loc: { line: tool.line, column: 0 },
|
|
58
|
+
messageId: 'ambiguous',
|
|
59
|
+
data: { name: tool.name, param },
|
|
60
|
+
});
|
|
61
|
+
} else if (NUMERIC_SUFFIX_PATTERN.test(param)) {
|
|
62
|
+
context.report({
|
|
63
|
+
loc: { line: tool.line, column: 0 },
|
|
64
|
+
messageId: 'numericSuffix',
|
|
65
|
+
data: { name: tool.name, param },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export default rule;
|