@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/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