@graphorin/eslint-plugin 0.5.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 +10 -0
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/dist/index.d.ts +249 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1011 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
//#region src/rules/_comment-utils.ts
|
|
2
|
+
/**
|
|
3
|
+
* Returns `true` when the source contains a comment whose `value`
|
|
4
|
+
* matches `tag` and which is positioned within `1` line of `node`'s
|
|
5
|
+
* start, or anywhere inside `node`'s span.
|
|
6
|
+
*
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
function nodeHasNearbyComment(context, node, tagPattern, maxGap = 1) {
|
|
10
|
+
const startLine = node.loc?.start.line ?? 0;
|
|
11
|
+
const endLine = node.loc?.end.line ?? Number.POSITIVE_INFINITY;
|
|
12
|
+
const allComments = context.sourceCode.getAllComments();
|
|
13
|
+
for (const c of allComments) {
|
|
14
|
+
if (!tagPattern.test(c.value)) continue;
|
|
15
|
+
const cStart = c.loc?.start.line ?? 0;
|
|
16
|
+
const cEnd = c.loc?.end.line ?? 0;
|
|
17
|
+
if (cEnd <= startLine && startLine - cEnd <= maxGap) return true;
|
|
18
|
+
if (cStart >= startLine && cStart <= endLine) return true;
|
|
19
|
+
if (cStart >= endLine && cStart - endLine <= maxGap) return true;
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/rules/no-bare-tool-exec.ts
|
|
26
|
+
const ALLOW_TAG$3 = /graphorin-allow-bare-exec/;
|
|
27
|
+
const SIGNAL_REFERENCE_RE = /\bsignal\b/;
|
|
28
|
+
const rule$8 = {
|
|
29
|
+
meta: {
|
|
30
|
+
type: "suggestion",
|
|
31
|
+
docs: {
|
|
32
|
+
description: "Require every `tool({...})` `execute` function to reference `ctx.signal` so cancellation propagates to the underlying I/O (principle 3, DEC-143).",
|
|
33
|
+
recommended: true
|
|
34
|
+
},
|
|
35
|
+
schema: [],
|
|
36
|
+
messages: { missingSignal: "tool '{{name}}' `execute` function does not reference `ctx.signal`; long-running tools MUST propagate the abort signal. Add `// graphorin-allow-bare-exec: <reason>` to opt out." }
|
|
37
|
+
},
|
|
38
|
+
create(context) {
|
|
39
|
+
return { CallExpression(node) {
|
|
40
|
+
if (!isToolBuilderCall(node)) return;
|
|
41
|
+
const arg = node.arguments[0];
|
|
42
|
+
if (arg === void 0 || arg.type !== "ObjectExpression") return;
|
|
43
|
+
const exec = findExecuteProperty(arg);
|
|
44
|
+
if (exec === null) return;
|
|
45
|
+
if (!isFunctionLike(exec)) return;
|
|
46
|
+
const fn = exec;
|
|
47
|
+
if (!fn.body) return;
|
|
48
|
+
const fnSource = context.sourceCode.getText(fn.body);
|
|
49
|
+
if (SIGNAL_REFERENCE_RE.test(fnSource)) return;
|
|
50
|
+
if (hasAllowComment$3(context, node)) return;
|
|
51
|
+
const name = extractToolName(arg) ?? "<unknown>";
|
|
52
|
+
context.report({
|
|
53
|
+
node: fn,
|
|
54
|
+
messageId: "missingSignal",
|
|
55
|
+
data: { name }
|
|
56
|
+
});
|
|
57
|
+
} };
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
function isToolBuilderCall(node) {
|
|
61
|
+
if (node.callee.type !== "Identifier") return false;
|
|
62
|
+
return node.callee.name === "tool";
|
|
63
|
+
}
|
|
64
|
+
function findExecuteProperty(obj) {
|
|
65
|
+
for (const prop of obj.properties) {
|
|
66
|
+
if (prop.type !== "Property") continue;
|
|
67
|
+
const property = prop;
|
|
68
|
+
if (property.computed) continue;
|
|
69
|
+
const key = property.key;
|
|
70
|
+
if (key.type === "Identifier" && key.name === "execute") return property.value;
|
|
71
|
+
if (key.type === "Literal" && key.value === "execute") return property.value;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
function isFunctionLike(value) {
|
|
76
|
+
return value.type === "ArrowFunctionExpression" || value.type === "FunctionExpression";
|
|
77
|
+
}
|
|
78
|
+
function extractToolName(obj) {
|
|
79
|
+
for (const prop of obj.properties) {
|
|
80
|
+
if (prop.type !== "Property") continue;
|
|
81
|
+
const property = prop;
|
|
82
|
+
if (property.computed) continue;
|
|
83
|
+
const key = property.key;
|
|
84
|
+
if (!(key.type === "Identifier" && key.name === "name" || key.type === "Literal" && key.value === "name")) continue;
|
|
85
|
+
if (property.value.type === "Literal" && typeof property.value.value === "string") return property.value.value;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
function hasAllowComment$3(context, node) {
|
|
90
|
+
return nodeHasNearbyComment(context, node, ALLOW_TAG$3, 1);
|
|
91
|
+
}
|
|
92
|
+
var no_bare_tool_exec_default = rule$8;
|
|
93
|
+
|
|
94
|
+
//#endregion
|
|
95
|
+
//#region src/rules/no-implicit-network-call.ts
|
|
96
|
+
const ALLOW_TAG$2 = /graphorin-allow-network/;
|
|
97
|
+
const FRAMEWORK_PATH_RE = /\bpackages\/[a-z0-9_-]+\/src\b/;
|
|
98
|
+
const NETWORK_CALLEES = new Set(["fetch", "XMLHttpRequest"]);
|
|
99
|
+
const HTTP_VERBS = new Set([
|
|
100
|
+
"get",
|
|
101
|
+
"post",
|
|
102
|
+
"put",
|
|
103
|
+
"patch",
|
|
104
|
+
"delete",
|
|
105
|
+
"head",
|
|
106
|
+
"options",
|
|
107
|
+
"request"
|
|
108
|
+
]);
|
|
109
|
+
const rule$7 = {
|
|
110
|
+
meta: {
|
|
111
|
+
type: "problem",
|
|
112
|
+
docs: {
|
|
113
|
+
description: "Disallow direct network primitives (`fetch`, `axios`, `http.request`, `XMLHttpRequest`) in `@graphorin/*` framework code without an explicit opt-out comment (DEC-154).",
|
|
114
|
+
recommended: true
|
|
115
|
+
},
|
|
116
|
+
schema: [],
|
|
117
|
+
messages: { forbidden: "direct network call '{{callee}}' in framework code; user actions must initiate network I/O. Add `// graphorin-allow-network: <reason>` to opt out." }
|
|
118
|
+
},
|
|
119
|
+
create(context) {
|
|
120
|
+
if (!FRAMEWORK_PATH_RE.test(context.filename.replace(/\\/g, "/"))) return {};
|
|
121
|
+
return {
|
|
122
|
+
CallExpression(node) {
|
|
123
|
+
const name = describeCallee(node);
|
|
124
|
+
if (name === null) return;
|
|
125
|
+
if (hasAllowComment$2(context, node)) return;
|
|
126
|
+
context.report({
|
|
127
|
+
node,
|
|
128
|
+
messageId: "forbidden",
|
|
129
|
+
data: { callee: name }
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
NewExpression(node) {
|
|
133
|
+
if (node.callee.type !== "Identifier") return;
|
|
134
|
+
if (node.callee.name !== "XMLHttpRequest") return;
|
|
135
|
+
if (hasAllowComment$2(context, node)) return;
|
|
136
|
+
context.report({
|
|
137
|
+
node,
|
|
138
|
+
messageId: "forbidden",
|
|
139
|
+
data: { callee: "new XMLHttpRequest" }
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
function describeCallee(node) {
|
|
146
|
+
if (node.callee.type === "Identifier") {
|
|
147
|
+
const name = node.callee.name;
|
|
148
|
+
if (NETWORK_CALLEES.has(name) || name === "axios") return name;
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
if (node.callee.type === "MemberExpression") {
|
|
152
|
+
const me = node.callee;
|
|
153
|
+
if (me.computed) return null;
|
|
154
|
+
const objName = me.object.type === "Identifier" ? me.object.name : null;
|
|
155
|
+
if (objName === null) return null;
|
|
156
|
+
const propName = me.property.type === "Identifier" ? me.property.name : null;
|
|
157
|
+
if (propName === null) return null;
|
|
158
|
+
if ((objName === "http" || objName === "https") && (propName === "request" || propName === "get")) return `${objName}.${propName}`;
|
|
159
|
+
if (objName === "axios" && HTTP_VERBS.has(propName)) return `axios.${propName}`;
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
function hasAllowComment$2(context, node) {
|
|
165
|
+
return nodeHasNearbyComment(context, node, ALLOW_TAG$2, 1);
|
|
166
|
+
}
|
|
167
|
+
var no_implicit_network_call_default = rule$7;
|
|
168
|
+
|
|
169
|
+
//#endregion
|
|
170
|
+
//#region src/rules/no-secret-in-deps.ts
|
|
171
|
+
const JUSTIFICATION_TAG = /\brb-24-justification\s*:/i;
|
|
172
|
+
const rule$6 = {
|
|
173
|
+
meta: {
|
|
174
|
+
type: "suggestion",
|
|
175
|
+
docs: {
|
|
176
|
+
description: "Require an `// rb-24-justification:` comment when `Agent.toTool({ inheritSecrets: [...] })` carries a non-empty allowlist (DEC-137).",
|
|
177
|
+
recommended: true
|
|
178
|
+
},
|
|
179
|
+
schema: [],
|
|
180
|
+
messages: { missingJustification: "`inheritSecrets` is non-empty but the call site lacks an `// rb-24-justification: <reason>` comment. Document why this sub-agent inherits parent secrets per DEC-137." }
|
|
181
|
+
},
|
|
182
|
+
create(context) {
|
|
183
|
+
return { CallExpression(node) {
|
|
184
|
+
if (!isToToolCall(node)) return;
|
|
185
|
+
const arg = node.arguments[0];
|
|
186
|
+
if (arg === void 0 || arg.type !== "ObjectExpression") return;
|
|
187
|
+
const inheritSecrets = findInheritSecretsProperty(arg);
|
|
188
|
+
if (inheritSecrets === null) return;
|
|
189
|
+
if (!isNonEmptyArray(inheritSecrets)) return;
|
|
190
|
+
if (hasJustificationComment(context, node)) return;
|
|
191
|
+
context.report({
|
|
192
|
+
node: inheritSecrets,
|
|
193
|
+
messageId: "missingJustification"
|
|
194
|
+
});
|
|
195
|
+
} };
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
function isToToolCall(node) {
|
|
199
|
+
if (node.callee.type !== "MemberExpression") return false;
|
|
200
|
+
const callee = node.callee;
|
|
201
|
+
if (callee.computed) return false;
|
|
202
|
+
if (callee.property.type !== "Identifier") return false;
|
|
203
|
+
return callee.property.name === "toTool";
|
|
204
|
+
}
|
|
205
|
+
function findInheritSecretsProperty(obj) {
|
|
206
|
+
for (const prop of obj.properties) {
|
|
207
|
+
if (prop.type !== "Property") continue;
|
|
208
|
+
const property = prop;
|
|
209
|
+
const key = property.key;
|
|
210
|
+
if (property.computed) continue;
|
|
211
|
+
if (key.type === "Identifier" && key.name === "inheritSecrets") return property.value;
|
|
212
|
+
if (key.type === "Literal" && key.value === "inheritSecrets") return property.value;
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
function isNonEmptyArray(value) {
|
|
217
|
+
if (value.type !== "ArrayExpression") return false;
|
|
218
|
+
return value.elements.some((e) => e !== null);
|
|
219
|
+
}
|
|
220
|
+
function hasJustificationComment(context, node) {
|
|
221
|
+
return nodeHasNearbyComment(context, node, JUSTIFICATION_TAG, 1);
|
|
222
|
+
}
|
|
223
|
+
var no_secret_in_deps_default = rule$6;
|
|
224
|
+
|
|
225
|
+
//#endregion
|
|
226
|
+
//#region src/rules/no-secret-unwrap.ts
|
|
227
|
+
const ALLOW_TAG$1 = /graphorin-allow-secret-unwrap/;
|
|
228
|
+
const rule$5 = {
|
|
229
|
+
meta: {
|
|
230
|
+
type: "problem",
|
|
231
|
+
docs: {
|
|
232
|
+
description: "Disallow `.unwrap()` / `.reveal()` calls on `SecretValue` instances. Prefer `.use(fn)` (scoped) or attach a `// graphorin-allow-secret-unwrap: <reason>` opt-out comment for `.reveal()`.",
|
|
233
|
+
recommended: true
|
|
234
|
+
},
|
|
235
|
+
schema: [],
|
|
236
|
+
messages: {
|
|
237
|
+
avoidReveal: "`.reveal()` returns the unwrapped secret as a V8 string. Prefer `.use(fn)` so the value is scoped to a single callback. Add `// graphorin-allow-secret-unwrap: <reason>` to opt out.",
|
|
238
|
+
avoidUnwrap: "`.unwrap()` is deprecated — call `.reveal()` (audited) or `.use(fn)` (scoped). The opt-out comment is intentionally NOT honoured for `.unwrap()` so the deprecation stays sharp."
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
create(context) {
|
|
242
|
+
return { CallExpression(node) {
|
|
243
|
+
const callee = node.callee;
|
|
244
|
+
if (callee.type !== "MemberExpression") return;
|
|
245
|
+
const me = callee;
|
|
246
|
+
if (me.computed) return;
|
|
247
|
+
if (me.property.type !== "Identifier") return;
|
|
248
|
+
const propName = me.property.name;
|
|
249
|
+
if (propName === "unwrap") {
|
|
250
|
+
context.report({
|
|
251
|
+
node,
|
|
252
|
+
messageId: "avoidUnwrap"
|
|
253
|
+
});
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (propName === "reveal") {
|
|
257
|
+
if (hasAllowComment$1(context, node)) return;
|
|
258
|
+
context.report({
|
|
259
|
+
node,
|
|
260
|
+
messageId: "avoidReveal"
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
} };
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
function hasAllowComment$1(context, node) {
|
|
267
|
+
return nodeHasNearbyComment(context, node, ALLOW_TAG$1, 1);
|
|
268
|
+
}
|
|
269
|
+
var no_secret_unwrap_default = rule$5;
|
|
270
|
+
|
|
271
|
+
//#endregion
|
|
272
|
+
//#region src/rules/no-third-party-workflow-aliases.ts
|
|
273
|
+
const ALLOW_TAG = /graphorin-workflow-naming-allow/;
|
|
274
|
+
const WORKFLOW_PATH_RE = /\bpackages\/workflow\/src\b/;
|
|
275
|
+
const FORBIDDEN_NAMES = new Map([
|
|
276
|
+
["Send", "Dispatch"],
|
|
277
|
+
["Command", "Directive"],
|
|
278
|
+
["interrupt", "pause"],
|
|
279
|
+
["LastValue", "LatestValue"],
|
|
280
|
+
["BinaryOperatorAggregate", "Reducer"],
|
|
281
|
+
["BinaryAggregate", "Reducer"],
|
|
282
|
+
["Topic", "Stream"]
|
|
283
|
+
]);
|
|
284
|
+
const rule$4 = {
|
|
285
|
+
meta: {
|
|
286
|
+
type: "problem",
|
|
287
|
+
docs: {
|
|
288
|
+
description: "Disallow third-party workflow primitive identifiers in `@graphorin/workflow` source. Graphorin owns its primitive names per DEC-019.",
|
|
289
|
+
recommended: true
|
|
290
|
+
},
|
|
291
|
+
schema: [],
|
|
292
|
+
messages: { forbidden: "identifier '{{forbidden}}' is reserved for the third-party workflow library it originates from; use '{{replacement}}' (DEC-019)." }
|
|
293
|
+
},
|
|
294
|
+
create(context) {
|
|
295
|
+
if (!WORKFLOW_PATH_RE.test(context.filename.replace(/\\/g, "/"))) return {};
|
|
296
|
+
return { Identifier(node) {
|
|
297
|
+
const replacement = FORBIDDEN_NAMES.get(node.name);
|
|
298
|
+
if (replacement === void 0) return;
|
|
299
|
+
if (isInImportSpecifier(node)) return;
|
|
300
|
+
if (hasAllowComment(context, node)) return;
|
|
301
|
+
context.report({
|
|
302
|
+
node,
|
|
303
|
+
messageId: "forbidden",
|
|
304
|
+
data: {
|
|
305
|
+
forbidden: node.name,
|
|
306
|
+
replacement
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
} };
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
function isInImportSpecifier(node) {
|
|
313
|
+
const parent = node.parent;
|
|
314
|
+
if (parent === void 0) return false;
|
|
315
|
+
switch (parent.type) {
|
|
316
|
+
case "ImportSpecifier":
|
|
317
|
+
case "ImportNamespaceSpecifier":
|
|
318
|
+
case "ImportDefaultSpecifier":
|
|
319
|
+
case "ExportSpecifier": return true;
|
|
320
|
+
default: return false;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
function hasAllowComment(context, node) {
|
|
324
|
+
return nodeHasNearbyComment(context, node, ALLOW_TAG, 1);
|
|
325
|
+
}
|
|
326
|
+
var no_third_party_workflow_aliases_default = rule$4;
|
|
327
|
+
|
|
328
|
+
//#endregion
|
|
329
|
+
//#region src/rules/provider-middleware-order.ts
|
|
330
|
+
const CANONICAL_ORDER = [
|
|
331
|
+
"withTracing",
|
|
332
|
+
"withRetry",
|
|
333
|
+
"withRateLimit",
|
|
334
|
+
"withCostLimit",
|
|
335
|
+
"withCostTracking",
|
|
336
|
+
"withFallback",
|
|
337
|
+
"withRedaction"
|
|
338
|
+
];
|
|
339
|
+
const CANONICAL_INDEX = new Map(CANONICAL_ORDER.map((name, idx) => [name, idx]));
|
|
340
|
+
const rule$3 = {
|
|
341
|
+
meta: {
|
|
342
|
+
type: "problem",
|
|
343
|
+
docs: {
|
|
344
|
+
description: "Enforce the canonical provider-middleware ordering at lint time. Mirrors the runtime `MiddlewareOrderingError` from `@graphorin/provider/middleware`.",
|
|
345
|
+
recommended: true
|
|
346
|
+
},
|
|
347
|
+
schema: [],
|
|
348
|
+
messages: { orderingViolation: "'{{outer}}' must appear before '{{inner}}' (canonical order: {{order}})." }
|
|
349
|
+
},
|
|
350
|
+
create(context) {
|
|
351
|
+
return { CallExpression(node) {
|
|
352
|
+
if (!isComposeCall(node)) return;
|
|
353
|
+
const arg = node.arguments[0];
|
|
354
|
+
if (arg === void 0 || arg.type !== "ArrayExpression") return;
|
|
355
|
+
const factories = extractFactoryNames(arg);
|
|
356
|
+
const recognised = [];
|
|
357
|
+
for (const f of factories) {
|
|
358
|
+
const idx = CANONICAL_INDEX.get(f.name);
|
|
359
|
+
if (idx !== void 0) recognised.push({
|
|
360
|
+
name: f.name,
|
|
361
|
+
index: idx,
|
|
362
|
+
node: f.node
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
for (let i = 1; i < recognised.length; i++) {
|
|
366
|
+
const prev = recognised[i - 1];
|
|
367
|
+
const cur = recognised[i];
|
|
368
|
+
if (prev !== void 0 && cur !== void 0 && prev.index > cur.index) context.report({
|
|
369
|
+
node: cur.node,
|
|
370
|
+
messageId: "orderingViolation",
|
|
371
|
+
data: {
|
|
372
|
+
outer: cur.name,
|
|
373
|
+
inner: prev.name,
|
|
374
|
+
order: CANONICAL_ORDER.join(" → ")
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
} };
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
function isComposeCall(node) {
|
|
382
|
+
if (node.callee.type === "Identifier") return node.callee.name === "composeProviderMiddleware";
|
|
383
|
+
if (node.callee.type === "MemberExpression") {
|
|
384
|
+
const prop = node.callee.property;
|
|
385
|
+
if (prop.type === "Identifier" && prop.name === "composeProviderMiddleware") return true;
|
|
386
|
+
}
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
function extractFactoryNames(arr) {
|
|
390
|
+
const out = [];
|
|
391
|
+
for (const elt of arr.elements) {
|
|
392
|
+
if (elt === null) continue;
|
|
393
|
+
if (elt.type === "Identifier") {
|
|
394
|
+
out.push({
|
|
395
|
+
name: elt.name,
|
|
396
|
+
node: elt
|
|
397
|
+
});
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (elt.type === "CallExpression") {
|
|
401
|
+
const callee = elt.callee;
|
|
402
|
+
if (callee.type === "Identifier") out.push({
|
|
403
|
+
name: callee.name,
|
|
404
|
+
node: elt
|
|
405
|
+
});
|
|
406
|
+
else if (callee.type === "MemberExpression" && callee.property.type === "Identifier") out.push({
|
|
407
|
+
name: callee.property.name,
|
|
408
|
+
node: elt
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
var provider_middleware_order_default = rule$3;
|
|
415
|
+
|
|
416
|
+
//#endregion
|
|
417
|
+
//#region src/tool-discovery.ts
|
|
418
|
+
/**
|
|
419
|
+
* Generic identifiers the parameter-naming rule flags as ambiguous.
|
|
420
|
+
* Tools whose `inputSchema` references only specific identifiers
|
|
421
|
+
* (e.g. `userId`, `recipientEmail`, `apiKey`) get full credit on
|
|
422
|
+
* the naming axis.
|
|
423
|
+
*
|
|
424
|
+
* @stable
|
|
425
|
+
*/
|
|
426
|
+
const AMBIGUOUS_PARAMETER_NAMES = Object.freeze([
|
|
427
|
+
"user",
|
|
428
|
+
"id",
|
|
429
|
+
"name",
|
|
430
|
+
"value",
|
|
431
|
+
"data",
|
|
432
|
+
"input",
|
|
433
|
+
"output",
|
|
434
|
+
"result",
|
|
435
|
+
"to",
|
|
436
|
+
"from",
|
|
437
|
+
"key",
|
|
438
|
+
"field"
|
|
439
|
+
]);
|
|
440
|
+
/**
|
|
441
|
+
* Tag values that, when present in a tool's `tags: [...]` literal,
|
|
442
|
+
* suppress the parameter-naming rule for that tool. The opt-out
|
|
443
|
+
* exists so operators can defer the rename for a long tail of
|
|
444
|
+
* pre-RB-49 tools while the framework migrates without breaking
|
|
445
|
+
* calling code.
|
|
446
|
+
*
|
|
447
|
+
* @stable
|
|
448
|
+
*/
|
|
449
|
+
const PARAMETER_NAMING_OPT_OUT_TAGS = Object.freeze(["experimental", "legacy"]);
|
|
450
|
+
/**
|
|
451
|
+
* Placeholder values the description-required rule treats as
|
|
452
|
+
* non-descriptions.
|
|
453
|
+
*
|
|
454
|
+
* @stable
|
|
455
|
+
*/
|
|
456
|
+
const PLACEHOLDER_DESCRIPTIONS = Object.freeze([
|
|
457
|
+
"todo",
|
|
458
|
+
"fixme",
|
|
459
|
+
"tbd",
|
|
460
|
+
"description",
|
|
461
|
+
"placeholder"
|
|
462
|
+
]);
|
|
463
|
+
const MIN_DESCRIPTION_LENGTH$1 = 20;
|
|
464
|
+
const MAX_EXAMPLES$1 = 5;
|
|
465
|
+
/**
|
|
466
|
+
* Discover every `tool({...})` invocation in a source string. The
|
|
467
|
+
* returned findings are stable + frozen so callers can pass them
|
|
468
|
+
* straight into a JSON report.
|
|
469
|
+
*
|
|
470
|
+
* @stable
|
|
471
|
+
*/
|
|
472
|
+
function discoverToolCallsInSource(file, source) {
|
|
473
|
+
const out = [];
|
|
474
|
+
const regex = /\btool\s*\(\s*\{/g;
|
|
475
|
+
let match = regex.exec(source);
|
|
476
|
+
while (match !== null) {
|
|
477
|
+
const objectStart = match.index + match[0].length - 1;
|
|
478
|
+
const objectEnd = matchBrace(source, objectStart);
|
|
479
|
+
if (objectEnd > 0) {
|
|
480
|
+
const literal = source.slice(objectStart, objectEnd + 1);
|
|
481
|
+
const tool = parseToolLiteral(file, countLines(source, match.index), literal);
|
|
482
|
+
if (tool !== null) out.push(tool);
|
|
483
|
+
}
|
|
484
|
+
regex.lastIndex = objectEnd + 1;
|
|
485
|
+
match = regex.exec(source);
|
|
486
|
+
}
|
|
487
|
+
return out;
|
|
488
|
+
}
|
|
489
|
+
/** Email PII pattern used by the examples-pii sub-check. */
|
|
490
|
+
const EMAIL_PATTERN = /[\w.+%-]+@[\w.-]+\.[A-Za-z]{2,}/;
|
|
491
|
+
/** Numeric-suffix pattern used by the parameter-naming sub-check. */
|
|
492
|
+
const NUMERIC_SUFFIX_PATTERN$1 = /^[A-Za-z]+\d+$/;
|
|
493
|
+
/**
|
|
494
|
+
* Run the three RB-49 rules against a discovered tool and return the
|
|
495
|
+
* findings. The CLI grader maps these findings into per-axis scores;
|
|
496
|
+
* the ESLint rules forward them to `context.report(...)`.
|
|
497
|
+
*
|
|
498
|
+
* @stable
|
|
499
|
+
*/
|
|
500
|
+
function runToolRules(tool, severityOverrides) {
|
|
501
|
+
const findings = [];
|
|
502
|
+
const descSeverity = severityOverrides?.toolDescription ?? "error";
|
|
503
|
+
if (descSeverity !== "off") {
|
|
504
|
+
const desc = tool.description?.trim() ?? "";
|
|
505
|
+
if (desc.length === 0) findings.push(finding("graphorin/tool-description-required", "description-missing", descSeverity, tool, `tool '${tool.name}' has no description; add a description that explains what the tool does and when to use it.`));
|
|
506
|
+
else if (PLACEHOLDER_DESCRIPTIONS.includes(desc.toLowerCase())) findings.push(finding("graphorin/tool-description-required", "description-placeholder", descSeverity, tool, `tool '${tool.name}' description is a placeholder ('${tool.description}').`));
|
|
507
|
+
else if (desc.length < MIN_DESCRIPTION_LENGTH$1) findings.push(finding("graphorin/tool-description-required", "description-too-short", descSeverity, tool, `tool '${tool.name}' description is shorter than ${MIN_DESCRIPTION_LENGTH$1} characters.`));
|
|
508
|
+
}
|
|
509
|
+
const examplesSeverity = severityOverrides?.toolExamples ?? "warn";
|
|
510
|
+
if (examplesSeverity !== "off") {
|
|
511
|
+
if (!tool.hasExamples || tool.examplesCount === 0) findings.push(finding("graphorin/tool-examples-recommended", "examples-missing", examplesSeverity, tool, `tool '${tool.name}' has no examples; add 1-5 worked examples per Anthropic 2026 guidance.`));
|
|
512
|
+
else if (tool.examplesCount > MAX_EXAMPLES$1) findings.push(finding("graphorin/tool-examples-recommended", "examples-too-many", "error", tool, `tool '${tool.name}' declares ${tool.examplesCount} examples; the upper bound is ${MAX_EXAMPLES$1}.`));
|
|
513
|
+
const emailMatch = EMAIL_PATTERN.exec(extractExamplesBlock(tool.source));
|
|
514
|
+
if (emailMatch !== null) {
|
|
515
|
+
const matched = emailMatch[0];
|
|
516
|
+
findings.push(finding("graphorin/tool-examples-recommended", "examples-pii-detected", "error", tool, `tool '${tool.name}' example contains a real-looking email '${matched}'; replace with synthetic data (e.g. 'user@example.com').`, {
|
|
517
|
+
matchedPattern: matched,
|
|
518
|
+
hint: "RB-49: examples must use synthetic test data so the rendered tool catalogue does not leak PII into provider context."
|
|
519
|
+
}));
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
const namingSeverity = severityOverrides?.toolParameterNaming ?? "warn";
|
|
523
|
+
const namingOptedOut = tool.tags.some((t) => PARAMETER_NAMING_OPT_OUT_TAGS.includes(t));
|
|
524
|
+
if (namingSeverity !== "off" && !namingOptedOut) {
|
|
525
|
+
for (const param of tool.parameterNames) if (AMBIGUOUS_PARAMETER_NAMES.includes(param)) findings.push(finding("graphorin/tool-parameter-naming", "parameter-ambiguous", namingSeverity, tool, `tool '${tool.name}' uses ambiguous parameter name '${param}'; prefer a self-documenting name (e.g. '${param}Id', '${param}Email').`));
|
|
526
|
+
else if (NUMERIC_SUFFIX_PATTERN$1.test(param)) findings.push(finding("graphorin/tool-parameter-naming", "parameter-numeric-suffix", namingSeverity, tool, `tool '${tool.name}' uses numeric-suffix parameter name '${param}'; prefer a semantic name (e.g. 'queryText', 'userId').`));
|
|
527
|
+
}
|
|
528
|
+
return findings;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Compute the per-tool grader score (0..100). Each axis is gated by
|
|
532
|
+
* the findings produced for that axis. The rubric is calibrated
|
|
533
|
+
* against the RB-49 fixture catalog (`wellDescribedTool` -> 82,
|
|
534
|
+
* `placeholderDescriptionTool` -> 20, `examplesPiiTool` -> 61).
|
|
535
|
+
*
|
|
536
|
+
* @stable
|
|
537
|
+
*/
|
|
538
|
+
function gradeTool(tool, findings) {
|
|
539
|
+
const descFindings = findings.filter((f) => f.rule === "graphorin/tool-description-required");
|
|
540
|
+
const exampleFindings = findings.filter((f) => f.rule === "graphorin/tool-examples-recommended");
|
|
541
|
+
const namingFindings = findings.filter((f) => f.rule === "graphorin/tool-parameter-naming");
|
|
542
|
+
const description = scoreDescription(tool, descFindings);
|
|
543
|
+
const examples = scoreExamples(tool, exampleFindings);
|
|
544
|
+
const parameterNaming = scoreParameterNaming(tool, namingFindings);
|
|
545
|
+
return Object.freeze({
|
|
546
|
+
toolName: tool.name,
|
|
547
|
+
file: tool.file,
|
|
548
|
+
line: tool.line,
|
|
549
|
+
score: description + examples + parameterNaming,
|
|
550
|
+
axes: Object.freeze({
|
|
551
|
+
description,
|
|
552
|
+
examples,
|
|
553
|
+
parameterNaming
|
|
554
|
+
}),
|
|
555
|
+
findings
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
function scoreDescription(tool, findings) {
|
|
559
|
+
if (findings.length > 0) return 0;
|
|
560
|
+
const desc = tool.description?.trim() ?? "";
|
|
561
|
+
if (desc.length >= 80) return 40;
|
|
562
|
+
if (desc.length >= 50) return 32;
|
|
563
|
+
if (desc.length >= 30) return 24;
|
|
564
|
+
if (desc.length >= MIN_DESCRIPTION_LENGTH$1) return 16;
|
|
565
|
+
return 0;
|
|
566
|
+
}
|
|
567
|
+
function scoreExamples(tool, findings) {
|
|
568
|
+
if (tool.examplesCount === 0) return 0;
|
|
569
|
+
if (tool.examplesCount > MAX_EXAMPLES$1) return 0;
|
|
570
|
+
const base = Math.min(30, 12 + (tool.examplesCount - 1) * 6);
|
|
571
|
+
const piiCount = findings.filter((f) => f.kind === "examples-pii-detected").length;
|
|
572
|
+
return Math.max(0, base - piiCount * 6);
|
|
573
|
+
}
|
|
574
|
+
function scoreParameterNaming(tool, findings) {
|
|
575
|
+
if (tool.parameterNames.length === 0) return 15;
|
|
576
|
+
const total = tool.parameterNames.length;
|
|
577
|
+
const ambiguousCount = findings.filter((f) => f.kind === "parameter-ambiguous").length;
|
|
578
|
+
const numericCount = findings.filter((f) => f.kind === "parameter-numeric-suffix").length;
|
|
579
|
+
const score = 30 - 30 / total * ambiguousCount - 10 / total * numericCount;
|
|
580
|
+
return Math.max(0, Math.round(score));
|
|
581
|
+
}
|
|
582
|
+
function finding(rule$9, kind, severity, tool, message, extra = {}) {
|
|
583
|
+
return Object.freeze({
|
|
584
|
+
rule: rule$9,
|
|
585
|
+
kind,
|
|
586
|
+
severity,
|
|
587
|
+
message,
|
|
588
|
+
toolName: tool.name,
|
|
589
|
+
file: tool.file,
|
|
590
|
+
line: tool.line,
|
|
591
|
+
...extra.hint !== void 0 ? { hint: extra.hint } : {},
|
|
592
|
+
...extra.matchedPattern !== void 0 ? { matchedPattern: extra.matchedPattern } : {}
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Extract the substring inside the `examples: [...]` array literal so
|
|
597
|
+
* the PII detector can scan only the example payloads (not the rest of
|
|
598
|
+
* the tool body).
|
|
599
|
+
*
|
|
600
|
+
* @internal
|
|
601
|
+
*/
|
|
602
|
+
function extractExamplesBlock(literal) {
|
|
603
|
+
const m = /examples\s*:\s*\[/.exec(literal);
|
|
604
|
+
if (m === null) return "";
|
|
605
|
+
const start = m.index + m[0].length - 1;
|
|
606
|
+
const end = matchBracket(literal, start);
|
|
607
|
+
if (end < 0) return "";
|
|
608
|
+
return literal.slice(start + 1, end);
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Parse a string literal value from the immediate object literal.
|
|
612
|
+
* Supports `'`, `"`, and template strings (without interpolation).
|
|
613
|
+
*
|
|
614
|
+
* @internal
|
|
615
|
+
*/
|
|
616
|
+
function readStringProp(literal, prop) {
|
|
617
|
+
const escapedProp = prop.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
618
|
+
const m = new RegExp(`(?:^|[\\s,{])${escapedProp}\\s*:\\s*(['"\`])`, "m").exec(literal);
|
|
619
|
+
if (m === null) return void 0;
|
|
620
|
+
const quote = m[1];
|
|
621
|
+
let i = m.index + m[0].length;
|
|
622
|
+
let out = "";
|
|
623
|
+
while (i < literal.length) {
|
|
624
|
+
const ch = literal[i];
|
|
625
|
+
if (ch === "\\" && i + 1 < literal.length) {
|
|
626
|
+
out += literal[i + 1];
|
|
627
|
+
i += 2;
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
if (ch === quote) return out;
|
|
631
|
+
out += ch;
|
|
632
|
+
i += 1;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Count entries in an array literal value of the form `prop: [...]`.
|
|
637
|
+
*
|
|
638
|
+
* @internal
|
|
639
|
+
*/
|
|
640
|
+
function countArrayProp(literal, prop) {
|
|
641
|
+
const escapedProp = prop.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
642
|
+
const m = new RegExp(`(?:^|[\\s,{])${escapedProp}\\s*:\\s*\\[`, "m").exec(literal);
|
|
643
|
+
if (m === null) return {
|
|
644
|
+
count: 0,
|
|
645
|
+
present: false
|
|
646
|
+
};
|
|
647
|
+
const start = m.index + m[0].length - 1;
|
|
648
|
+
const end = matchBracket(literal, start);
|
|
649
|
+
if (end < 0) return {
|
|
650
|
+
count: 0,
|
|
651
|
+
present: true
|
|
652
|
+
};
|
|
653
|
+
const inner = literal.slice(start + 1, end).trim().replace(/,\s*$/, "");
|
|
654
|
+
if (inner.length === 0) return {
|
|
655
|
+
count: 0,
|
|
656
|
+
present: true
|
|
657
|
+
};
|
|
658
|
+
let depth = 0;
|
|
659
|
+
let inString = null;
|
|
660
|
+
let count = 1;
|
|
661
|
+
for (let i = 0; i < inner.length; i += 1) {
|
|
662
|
+
const ch = inner[i];
|
|
663
|
+
if (inString !== null) {
|
|
664
|
+
if (ch === "\\") {
|
|
665
|
+
i += 1;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
if (ch === inString) inString = null;
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (ch === "\"" || ch === "'" || ch === "`") {
|
|
672
|
+
inString = ch;
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
if (ch === "(" || ch === "[" || ch === "{") depth += 1;
|
|
676
|
+
else if (ch === ")" || ch === "]" || ch === "}") depth -= 1;
|
|
677
|
+
else if (ch === "," && depth === 0) count += 1;
|
|
678
|
+
}
|
|
679
|
+
return {
|
|
680
|
+
count,
|
|
681
|
+
present: true
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Extract identifiers passed into a `z.object({...})` schema as
|
|
686
|
+
* top-level keys.
|
|
687
|
+
*
|
|
688
|
+
* @internal
|
|
689
|
+
*/
|
|
690
|
+
function extractParameterNames(literal) {
|
|
691
|
+
const m = /inputSchema\s*:\s*z\s*\.\s*object\s*\(\s*\{/.exec(literal);
|
|
692
|
+
if (m === null) return [];
|
|
693
|
+
const start = m.index + m[0].length - 1;
|
|
694
|
+
const end = matchBrace(literal, start);
|
|
695
|
+
if (end < 0) return [];
|
|
696
|
+
const inner = literal.slice(start + 1, end);
|
|
697
|
+
const names = [];
|
|
698
|
+
const idRegex = /(^|[\s,{])([A-Za-z_$][A-Za-z0-9_$]*)\s*:/g;
|
|
699
|
+
let id = idRegex.exec(inner);
|
|
700
|
+
while (id !== null) {
|
|
701
|
+
const name = id[2];
|
|
702
|
+
if (!names.includes(name)) names.push(name);
|
|
703
|
+
id = idRegex.exec(inner);
|
|
704
|
+
}
|
|
705
|
+
return names;
|
|
706
|
+
}
|
|
707
|
+
function parseToolLiteral(file, line, literal) {
|
|
708
|
+
const name = readStringProp(literal, "name") ?? "<anonymous>";
|
|
709
|
+
const description = readStringProp(literal, "description");
|
|
710
|
+
const examples = countArrayProp(literal, "examples");
|
|
711
|
+
const tagsArr = countArrayProp(literal, "tags");
|
|
712
|
+
const tags = [];
|
|
713
|
+
if (tagsArr.present && tagsArr.count > 0) {
|
|
714
|
+
const m = /tags\s*:\s*\[([^\]]*)\]/.exec(literal);
|
|
715
|
+
if (m !== null) {
|
|
716
|
+
const inner = m[1] ?? "";
|
|
717
|
+
const tagRegex = /['"`]([^'"`]+)['"`]/g;
|
|
718
|
+
let t = tagRegex.exec(inner);
|
|
719
|
+
while (t !== null) {
|
|
720
|
+
tags.push(t[1]);
|
|
721
|
+
t = tagRegex.exec(inner);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
const parameterNames = extractParameterNames(literal);
|
|
726
|
+
return Object.freeze({
|
|
727
|
+
file,
|
|
728
|
+
line,
|
|
729
|
+
name,
|
|
730
|
+
...description !== void 0 ? { description } : {},
|
|
731
|
+
examplesCount: examples.count,
|
|
732
|
+
hasExamples: examples.present,
|
|
733
|
+
parameterNames: Object.freeze(parameterNames),
|
|
734
|
+
tags: Object.freeze(tags),
|
|
735
|
+
source: literal
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
function countLines(source, index) {
|
|
739
|
+
let n = 1;
|
|
740
|
+
for (let i = 0; i < index; i += 1) if (source[i] === "\n") n += 1;
|
|
741
|
+
return n;
|
|
742
|
+
}
|
|
743
|
+
function matchBrace(source, openIndex) {
|
|
744
|
+
return matchPair(source, openIndex, "{", "}");
|
|
745
|
+
}
|
|
746
|
+
function matchBracket(source, openIndex) {
|
|
747
|
+
return matchPair(source, openIndex, "[", "]");
|
|
748
|
+
}
|
|
749
|
+
function matchPair(source, openIndex, open, close) {
|
|
750
|
+
let depth = 0;
|
|
751
|
+
let inString = null;
|
|
752
|
+
for (let i = openIndex; i < source.length; i += 1) {
|
|
753
|
+
const ch = source[i];
|
|
754
|
+
if (inString !== null) {
|
|
755
|
+
if (ch === "\\") {
|
|
756
|
+
i += 1;
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
if (ch === inString) inString = null;
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
if (ch === "\"" || ch === "'" || ch === "`") {
|
|
763
|
+
inString = ch;
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
if (ch === open) depth += 1;
|
|
767
|
+
else if (ch === close) {
|
|
768
|
+
depth -= 1;
|
|
769
|
+
if (depth === 0) return i;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
return -1;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
//#endregion
|
|
776
|
+
//#region src/rules/tool-description-required.ts
|
|
777
|
+
const MIN_DESCRIPTION_LENGTH = 20;
|
|
778
|
+
const rule$2 = {
|
|
779
|
+
meta: {
|
|
780
|
+
type: "problem",
|
|
781
|
+
docs: {
|
|
782
|
+
description: "Require a meaningful `description` on every `tool({...})` registration. RB-49 (Anthropic 2026 advanced tool use guidance).",
|
|
783
|
+
recommended: true
|
|
784
|
+
},
|
|
785
|
+
schema: [],
|
|
786
|
+
messages: {
|
|
787
|
+
missing: "tool '{{name}}' has no description; add a description that explains what the tool does and when to use it.",
|
|
788
|
+
tooShort: "tool '{{name}}' description is shorter than {{min}} characters (currently {{len}}).",
|
|
789
|
+
placeholder: "tool '{{name}}' description is a placeholder ('{{value}}')."
|
|
790
|
+
}
|
|
791
|
+
},
|
|
792
|
+
create(context) {
|
|
793
|
+
return { "Program:exit"() {
|
|
794
|
+
const filename = context.filename;
|
|
795
|
+
const source = context.sourceCode.text;
|
|
796
|
+
const tools = discoverToolCallsInSource(filename, source);
|
|
797
|
+
for (const tool of tools) {
|
|
798
|
+
const desc = tool.description?.trim();
|
|
799
|
+
if (desc === void 0 || desc.length === 0) context.report({
|
|
800
|
+
loc: {
|
|
801
|
+
line: tool.line,
|
|
802
|
+
column: 0
|
|
803
|
+
},
|
|
804
|
+
messageId: "missing",
|
|
805
|
+
data: { name: tool.name }
|
|
806
|
+
});
|
|
807
|
+
else if (desc.length < MIN_DESCRIPTION_LENGTH) context.report({
|
|
808
|
+
loc: {
|
|
809
|
+
line: tool.line,
|
|
810
|
+
column: 0
|
|
811
|
+
},
|
|
812
|
+
messageId: "tooShort",
|
|
813
|
+
data: {
|
|
814
|
+
name: tool.name,
|
|
815
|
+
min: String(MIN_DESCRIPTION_LENGTH),
|
|
816
|
+
len: String(desc.length)
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
else if (PLACEHOLDER_DESCRIPTIONS.includes(desc.toLowerCase())) context.report({
|
|
820
|
+
loc: {
|
|
821
|
+
line: tool.line,
|
|
822
|
+
column: 0
|
|
823
|
+
},
|
|
824
|
+
messageId: "placeholder",
|
|
825
|
+
data: {
|
|
826
|
+
name: tool.name,
|
|
827
|
+
value: desc
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
} };
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
var tool_description_required_default = rule$2;
|
|
835
|
+
|
|
836
|
+
//#endregion
|
|
837
|
+
//#region src/rules/tool-examples-recommended.ts
|
|
838
|
+
const MAX_EXAMPLES = 5;
|
|
839
|
+
const rule$1 = {
|
|
840
|
+
meta: {
|
|
841
|
+
type: "suggestion",
|
|
842
|
+
docs: {
|
|
843
|
+
description: "Recommend 1-5 `examples` entries on every `tool({...})` registration (Anthropic 2026 'Writing effective tools' guidance).",
|
|
844
|
+
recommended: true
|
|
845
|
+
},
|
|
846
|
+
schema: [],
|
|
847
|
+
messages: {
|
|
848
|
+
missing: "tool '{{name}}' has no examples; add 1-5 worked examples per Anthropic 2026 guidance.",
|
|
849
|
+
tooMany: "tool '{{name}}' declares {{count}} examples; the documented upper bound is {{max}}."
|
|
850
|
+
}
|
|
851
|
+
},
|
|
852
|
+
create(context) {
|
|
853
|
+
return { "Program:exit"() {
|
|
854
|
+
const filename = context.filename;
|
|
855
|
+
const source = context.sourceCode.text;
|
|
856
|
+
const tools = discoverToolCallsInSource(filename, source);
|
|
857
|
+
for (const tool of tools) if (!tool.hasExamples || tool.examplesCount === 0) context.report({
|
|
858
|
+
loc: {
|
|
859
|
+
line: tool.line,
|
|
860
|
+
column: 0
|
|
861
|
+
},
|
|
862
|
+
messageId: "missing",
|
|
863
|
+
data: { name: tool.name }
|
|
864
|
+
});
|
|
865
|
+
else if (tool.examplesCount > MAX_EXAMPLES) context.report({
|
|
866
|
+
loc: {
|
|
867
|
+
line: tool.line,
|
|
868
|
+
column: 0
|
|
869
|
+
},
|
|
870
|
+
messageId: "tooMany",
|
|
871
|
+
data: {
|
|
872
|
+
name: tool.name,
|
|
873
|
+
count: String(tool.examplesCount),
|
|
874
|
+
max: String(MAX_EXAMPLES)
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
} };
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
var tool_examples_recommended_default = rule$1;
|
|
881
|
+
|
|
882
|
+
//#endregion
|
|
883
|
+
//#region src/rules/tool-parameter-naming.ts
|
|
884
|
+
const NUMERIC_SUFFIX_PATTERN = /^[A-Za-z]+\d+$/;
|
|
885
|
+
const rule = {
|
|
886
|
+
meta: {
|
|
887
|
+
type: "suggestion",
|
|
888
|
+
docs: {
|
|
889
|
+
description: "Flag ambiguous or numeric-suffix parameter names on `tool({...})` `inputSchema` declarations (RB-49 — write self-documenting parameter names).",
|
|
890
|
+
recommended: true
|
|
891
|
+
},
|
|
892
|
+
schema: [],
|
|
893
|
+
messages: {
|
|
894
|
+
ambiguous: "tool '{{name}}' uses ambiguous parameter name '{{param}}'; prefer a self-documenting name (e.g. '{{param}}Id', '{{param}}Email').",
|
|
895
|
+
numericSuffix: "tool '{{name}}' uses numeric-suffix parameter name '{{param}}'; prefer a semantic name (e.g. 'queryText', 'userId')."
|
|
896
|
+
}
|
|
897
|
+
},
|
|
898
|
+
create(context) {
|
|
899
|
+
return { "Program:exit"() {
|
|
900
|
+
const filename = context.filename;
|
|
901
|
+
const source = context.sourceCode.text;
|
|
902
|
+
const tools = discoverToolCallsInSource(filename, source);
|
|
903
|
+
for (const tool of tools) {
|
|
904
|
+
if (tool.tags.some((t) => PARAMETER_NAMING_OPT_OUT_TAGS.includes(t))) continue;
|
|
905
|
+
for (const param of tool.parameterNames) if (AMBIGUOUS_PARAMETER_NAMES.includes(param)) context.report({
|
|
906
|
+
loc: {
|
|
907
|
+
line: tool.line,
|
|
908
|
+
column: 0
|
|
909
|
+
},
|
|
910
|
+
messageId: "ambiguous",
|
|
911
|
+
data: {
|
|
912
|
+
name: tool.name,
|
|
913
|
+
param
|
|
914
|
+
}
|
|
915
|
+
});
|
|
916
|
+
else if (NUMERIC_SUFFIX_PATTERN.test(param)) context.report({
|
|
917
|
+
loc: {
|
|
918
|
+
line: tool.line,
|
|
919
|
+
column: 0
|
|
920
|
+
},
|
|
921
|
+
messageId: "numericSuffix",
|
|
922
|
+
data: {
|
|
923
|
+
name: tool.name,
|
|
924
|
+
param
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
} };
|
|
929
|
+
}
|
|
930
|
+
};
|
|
931
|
+
var tool_parameter_naming_default = rule;
|
|
932
|
+
|
|
933
|
+
//#endregion
|
|
934
|
+
//#region src/index.ts
|
|
935
|
+
/**
|
|
936
|
+
* @graphorin/eslint-plugin — ESLint plugin for projects that build on
|
|
937
|
+
* the Graphorin framework.
|
|
938
|
+
*
|
|
939
|
+
* Phase 16 final ruleset:
|
|
940
|
+
*
|
|
941
|
+
* - `no-secret-unwrap` — DEC-020 / ADR-026. Active.
|
|
942
|
+
* - `no-secret-in-deps` — DEC-137. Active.
|
|
943
|
+
* - `provider-middleware-order` — DEC-145 / ADR-039. Active.
|
|
944
|
+
* - `no-implicit-network-call` — DEC-154 / ADR-041. Active.
|
|
945
|
+
* - `no-third-party-workflow-aliases` — DEC-019 / ADR-029. Active.
|
|
946
|
+
* - `no-bare-tool-exec` — principle 3 / DEC-143. Active.
|
|
947
|
+
* - `tool-description-required` — Active.
|
|
948
|
+
* - `tool-examples-recommended` — Active.
|
|
949
|
+
* - `tool-parameter-naming` — Active.
|
|
950
|
+
*
|
|
951
|
+
* (The former `no-console-in-public-api` scaffold — a permanent no-op
|
|
952
|
+
* since Phase 01 — was removed in the v0.4 hygiene pass (PS-21) rather
|
|
953
|
+
* than shipped inert.)
|
|
954
|
+
*
|
|
955
|
+
* @packageDocumentation
|
|
956
|
+
*/
|
|
957
|
+
const VERSION = "0.5.0";
|
|
958
|
+
const meta = {
|
|
959
|
+
name: "@graphorin/eslint-plugin",
|
|
960
|
+
version: VERSION
|
|
961
|
+
};
|
|
962
|
+
const rules = {
|
|
963
|
+
"no-bare-tool-exec": no_bare_tool_exec_default,
|
|
964
|
+
"no-implicit-network-call": no_implicit_network_call_default,
|
|
965
|
+
"no-secret-in-deps": no_secret_in_deps_default,
|
|
966
|
+
"no-secret-unwrap": no_secret_unwrap_default,
|
|
967
|
+
"no-third-party-workflow-aliases": no_third_party_workflow_aliases_default,
|
|
968
|
+
"provider-middleware-order": provider_middleware_order_default,
|
|
969
|
+
"tool-description-required": tool_description_required_default,
|
|
970
|
+
"tool-examples-recommended": tool_examples_recommended_default,
|
|
971
|
+
"tool-parameter-naming": tool_parameter_naming_default
|
|
972
|
+
};
|
|
973
|
+
/** Shared severity map for both the legacy and flat recommended presets. */
|
|
974
|
+
const RECOMMENDED_RULES = {
|
|
975
|
+
"@graphorin/no-bare-tool-exec": "warn",
|
|
976
|
+
"@graphorin/no-implicit-network-call": "error",
|
|
977
|
+
"@graphorin/no-secret-in-deps": "error",
|
|
978
|
+
"@graphorin/no-secret-unwrap": "error",
|
|
979
|
+
"@graphorin/no-third-party-workflow-aliases": "error",
|
|
980
|
+
"@graphorin/provider-middleware-order": "error",
|
|
981
|
+
"@graphorin/tool-description-required": "error",
|
|
982
|
+
"@graphorin/tool-examples-recommended": "warn",
|
|
983
|
+
"@graphorin/tool-parameter-naming": "warn"
|
|
984
|
+
};
|
|
985
|
+
/**
|
|
986
|
+
* PS-17: ship BOTH config shapes. `recommended` is the legacy `.eslintrc` form
|
|
987
|
+
* (`plugins: ['@graphorin']`); `flat/recommended` is the ESLint 9+ flat-config
|
|
988
|
+
* form that maps the namespace to the plugin object, so flat-config consumers
|
|
989
|
+
* can `...plugin.configs['flat/recommended']` instead of hand-wiring ten rules.
|
|
990
|
+
*/
|
|
991
|
+
const plugin = {
|
|
992
|
+
meta,
|
|
993
|
+
rules,
|
|
994
|
+
configs: {
|
|
995
|
+
recommended: {
|
|
996
|
+
plugins: ["@graphorin"],
|
|
997
|
+
rules: RECOMMENDED_RULES
|
|
998
|
+
},
|
|
999
|
+
"flat/recommended": {
|
|
1000
|
+
plugins: {},
|
|
1001
|
+
rules: RECOMMENDED_RULES
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
1005
|
+
plugin.configs["flat/recommended"].plugins = { "@graphorin": plugin };
|
|
1006
|
+
const configs = plugin.configs;
|
|
1007
|
+
var src_default = plugin;
|
|
1008
|
+
|
|
1009
|
+
//#endregion
|
|
1010
|
+
export { AMBIGUOUS_PARAMETER_NAMES, PARAMETER_NAMING_OPT_OUT_TAGS, PLACEHOLDER_DESCRIPTIONS, VERSION, configs, src_default as default, discoverToolCallsInSource, gradeTool, meta, rules, runToolRules };
|
|
1011
|
+
//# sourceMappingURL=index.js.map
|