@graphorin/eslint-plugin 0.6.1 → 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.
@@ -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;