@ganju/utils 0.0.12 → 0.0.14
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/constants.js +1 -1
- package/dist/toolLint.d.ts +1 -1
- package/dist/toolLint.d.ts.map +1 -1
- package/dist/toolLint.js +70 -27
- package/package.json +1 -1
- package/src/constants.ts +1 -1
- package/src/toolLint.ts +81 -26
package/dist/constants.js
CHANGED
|
@@ -815,7 +815,7 @@ const RESOURCE_SEARCH_COMMON_TERM_MIN = 5;
|
|
|
815
815
|
const RESOURCE_SEARCH_RERANK_CANDIDATES = 30;
|
|
816
816
|
const RESOURCE_SEARCH_RERANK_MODEL = '@cf/baai/bge-reranker-base';
|
|
817
817
|
const RESOURCE_HANDLER_SLEEP_AFTER = '10m';
|
|
818
|
-
const DOCS_URL = 'https://
|
|
818
|
+
const DOCS_URL = 'https://ganju.ai/docs/';
|
|
819
819
|
const BASE64_DATA_URI_RE = /data:image\/[a-zA-Z0-9+\-.]+;base64,[A-Za-z0-9+/=\s]+/g;
|
|
820
820
|
const RAW_BASE64_BLOB_RE = /[A-Za-z0-9+/]{512,}={0,2}/g;
|
|
821
821
|
const CHUNK_SEPARATORS = [
|
package/dist/toolLint.d.ts
CHANGED
package/dist/toolLint.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"toolLint.d.ts","sourceRoot":"","sources":["../src/toolLint.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,WAAW,CAAC,EAAE;QACZ,YAAY,CAAC,EAAE,OAAO,CAAC;QACvB,eAAe,CAAC,EAAE,OAAO,CAAC;QAC1B,cAAc,CAAC,EAAE,OAAO,CAAC;QACzB,aAAa,CAAC,EAAE,OAAO,CAAC;KACzB,CAAC;CACH;AAED,eAAO,MAAM,eAAe,+JAQlB,CAAC;AAEX,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC;AAE5D,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,YAAY,CAAC;IAGnB,IAAI,CAAC,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"toolLint.d.ts","sourceRoot":"","sources":["../src/toolLint.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,WAAW,CAAC,EAAE;QACZ,YAAY,CAAC,EAAE,OAAO,CAAC;QACvB,eAAe,CAAC,EAAE,OAAO,CAAC;QAC1B,cAAc,CAAC,EAAE,OAAO,CAAC;QACzB,aAAa,CAAC,EAAE,OAAO,CAAC;KACzB,CAAC;CACH;AAED,eAAO,MAAM,eAAe,+JAQlB,CAAC;AAEX,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC;AAE5D,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,YAAY,CAAC;IAGnB,IAAI,CAAC,EAAE,MAAM,CAAC;IAGd,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IAEjB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IAEjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IAGf,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,eAAe;IAI9B,MAAM,CAAC,EAAE,YAAY,EAAE,CAAC;IAGxB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAID,eAAO,MAAM,gCAAgC,KAAK,CAAC;AAKnD,eAAO,MAAM,mBAAmB,KAAK,CAAC;AA2JtC;;;GAGG;AACH,eAAO,MAAM,SAAS,GACpB,OAAO,YAAY,EAAE,EACrB,UAAS,eAAoB,KAC5B,eAAe,EA8FjB,CAAC"}
|
package/dist/toolLint.js
CHANGED
|
@@ -29,18 +29,26 @@ exports.TOOL_LINT_MIN_DESCRIPTION_LENGTH = 40;
|
|
|
29
29
|
// start choosing among them badly. A channel turn hard-caps its list at 40
|
|
30
30
|
// (CHANNEL_MAX_TOOLS); this warns well before that.
|
|
31
31
|
exports.TOOL_LINT_MAX_TOOLS = 25;
|
|
32
|
-
// Two descriptions sharing at least this much of their vocabulary
|
|
33
|
-
//
|
|
32
|
+
// Two descriptions sharing at least this much of their vocabulary read as the
|
|
33
|
+
// same tool to a model. Words are weighted by how rare they are among the
|
|
34
|
+
// server's tools (see descriptionWeights), so tools written from one template
|
|
35
|
+
// — "Use when someone asks about X" — are told apart by their X.
|
|
34
36
|
const DESCRIPTION_OVERLAP = 0.6;
|
|
35
37
|
// Too few content words and two unrelated descriptions can share most of them.
|
|
36
38
|
const MIN_OVERLAP_WORDS = 5;
|
|
37
39
|
// Lowercased, accents off, so "Úsala cuando" and "usala cuando" are one text.
|
|
38
|
-
const fold = (text) => text
|
|
40
|
+
const fold = (text) => text
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.normalize('NFKD')
|
|
43
|
+
.replace(/[\u0300-\u036f]/g, '');
|
|
39
44
|
// Wording that tells a model WHEN to call a tool rather than only what it does
|
|
40
45
|
// — English, Spanish and Portuguese, the languages descriptions here are
|
|
41
46
|
// written in. Loose on purpose: a false "says when" costs nothing, a false
|
|
42
|
-
// "doesn't" nags someone whose description is fine.
|
|
43
|
-
|
|
47
|
+
// "doesn't" nags someone whose description is fine. But not so loose that
|
|
48
|
+
// words about how a tool works count: "uses the Stripe API" and "makes an API
|
|
49
|
+
// call" say nothing about when to reach for it, so "call" counts only as an
|
|
50
|
+
// instruction ("call this…", "call it…").
|
|
51
|
+
const USAGE_GUIDANCE = /\b(use|call (?:this|it)|when|whenever|before|after|instead|only if|if the user|if you|for questions|usa|usala|usalo|usar|llama|llamala|llamar|cuando|antes de|despues de|en lugar de|si el usuario|quando|use-a|utilize|chame)\b/;
|
|
44
52
|
const STOPWORDS = new Set([
|
|
45
53
|
'the',
|
|
46
54
|
'and',
|
|
@@ -82,31 +90,51 @@ const STOPWORDS = new Set([
|
|
|
82
90
|
const contentWords = (text) => new Set(fold(text)
|
|
83
91
|
.split(/[^a-z0-9]+/)
|
|
84
92
|
.filter(word => word.length >= 3 && !STOPWORDS.has(word)));
|
|
93
|
+
// A proxied tool is named `<server prefix>__<remote name>`. The prefix says
|
|
94
|
+
// which connection it came from, not what it does, so `search_repositories`
|
|
95
|
+
// and `github__search_repositories` are the same tool to a model. Kept here
|
|
96
|
+
// rather than imported from constants (MCP_PROXY_TOOL_NAME_SEP), since this
|
|
97
|
+
// file imports nothing.
|
|
98
|
+
const PROXY_NAME_SEP = '__';
|
|
99
|
+
const withoutProxyPrefix = (name) => {
|
|
100
|
+
const at = name.lastIndexOf(PROXY_NAME_SEP);
|
|
101
|
+
const bare = at === -1 ? name : name.slice(at + PROXY_NAME_SEP.length);
|
|
102
|
+
return bare || name;
|
|
103
|
+
};
|
|
85
104
|
// `lookupOrder`, `lookup-order` and `order_lookup` name the same thing.
|
|
86
|
-
const nameTokens = (name) => name
|
|
105
|
+
const nameTokens = (name) => withoutProxyPrefix(name)
|
|
87
106
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
88
107
|
.toLowerCase()
|
|
89
108
|
.split(/[^a-z0-9]+/)
|
|
90
109
|
.filter(Boolean)
|
|
91
110
|
.sort()
|
|
92
111
|
.join(' ');
|
|
93
|
-
|
|
112
|
+
// How much each word says about which tool a description belongs to: a word
|
|
113
|
+
// in every description (the template, the domain) next to nothing, a word in
|
|
114
|
+
// one of them a lot. Smoothed, so with two tools a shared word still counts.
|
|
115
|
+
const descriptionWeights = (descriptions) => {
|
|
116
|
+
const frequency = new Map();
|
|
117
|
+
for (const words of descriptions) {
|
|
118
|
+
for (const word of words) {
|
|
119
|
+
frequency.set(word, (frequency.get(word) ?? 0) + 1);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const total = descriptions.length;
|
|
123
|
+
return word => Math.log(1 + total / (frequency.get(word) ?? 1));
|
|
124
|
+
};
|
|
125
|
+
// Jaccard, with each word counted by its weight.
|
|
126
|
+
const weightedJaccard = (a, b, weight) => {
|
|
94
127
|
let shared = 0;
|
|
95
|
-
|
|
128
|
+
let union = 0;
|
|
129
|
+
for (const word of a) {
|
|
130
|
+
union += weight(word);
|
|
96
131
|
if (b.has(word))
|
|
97
|
-
shared
|
|
98
|
-
const union = a.size + b.size - shared;
|
|
99
|
-
return union === 0 ? 0 : shared / union;
|
|
100
|
-
};
|
|
101
|
-
const overlaps = (a, b) => {
|
|
102
|
-
if (nameTokens(a.name) === nameTokens(b.name))
|
|
103
|
-
return true;
|
|
104
|
-
const aWords = contentWords(a.description ?? '');
|
|
105
|
-
const bWords = contentWords(b.description ?? '');
|
|
106
|
-
if (aWords.size < MIN_OVERLAP_WORDS || bWords.size < MIN_OVERLAP_WORDS) {
|
|
107
|
-
return false;
|
|
132
|
+
shared += weight(word);
|
|
108
133
|
}
|
|
109
|
-
|
|
134
|
+
for (const word of b)
|
|
135
|
+
if (!a.has(word))
|
|
136
|
+
union += weight(word);
|
|
137
|
+
return union === 0 ? 0 : shared / union;
|
|
110
138
|
};
|
|
111
139
|
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
112
140
|
// Every property the model may fill in that doesn't say what it is for, down
|
|
@@ -140,6 +168,19 @@ const quoted = (name) => `"${name}"`;
|
|
|
140
168
|
const lintTools = (tools, options = {}) => {
|
|
141
169
|
const others = options.others ?? [];
|
|
142
170
|
const findings = [];
|
|
171
|
+
const everyTool = [...tools, ...others];
|
|
172
|
+
const wordsOf = new Map(everyTool.map(tool => [tool, contentWords(tool.description ?? '')]));
|
|
173
|
+
const weight = descriptionWeights([...wordsOf.values()]);
|
|
174
|
+
const overlaps = (a, b) => {
|
|
175
|
+
if (nameTokens(a.name) === nameTokens(b.name))
|
|
176
|
+
return true;
|
|
177
|
+
const aWords = wordsOf.get(a);
|
|
178
|
+
const bWords = wordsOf.get(b);
|
|
179
|
+
if (aWords.size < MIN_OVERLAP_WORDS || bWords.size < MIN_OVERLAP_WORDS) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
return weightedJaccard(aWords, bWords, weight) >= DESCRIPTION_OVERLAP;
|
|
183
|
+
};
|
|
143
184
|
tools.forEach((tool, index) => {
|
|
144
185
|
const description = (tool.description ?? '').trim();
|
|
145
186
|
if (!description) {
|
|
@@ -184,16 +225,18 @@ const lintTools = (tools, options = {}) => {
|
|
|
184
225
|
message: `${quoted(tool.name)} has input properties with no description: ${paths.join(', ')}. A model has to guess what to pass.`
|
|
185
226
|
});
|
|
186
227
|
}
|
|
187
|
-
// Each pair once
|
|
188
|
-
// server
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
228
|
+
// Each pair once — against the tools after this one, then the rest of the
|
|
229
|
+
// server — and one finding per tool, so twenty look-alikes are nineteen
|
|
230
|
+
// lines rather than a hundred and ninety.
|
|
231
|
+
const alike = [...tools.slice(index + 1), ...others]
|
|
232
|
+
.filter(other => other.name !== tool.name && overlaps(tool, other))
|
|
233
|
+
.map(other => other.name);
|
|
234
|
+
if (alike.length > 0) {
|
|
192
235
|
findings.push({
|
|
193
236
|
rule: 'overlapping-tools',
|
|
194
237
|
tool: tool.name,
|
|
195
|
-
|
|
196
|
-
message: `${quoted(tool.name)}
|
|
238
|
+
alike,
|
|
239
|
+
message: `${quoted(tool.name)} looks like ${alike.map(quoted).join(', ')} in name or description, so a model may call one for another. Make each description say what sets it apart.`
|
|
197
240
|
});
|
|
198
241
|
}
|
|
199
242
|
});
|
package/package.json
CHANGED
package/src/constants.ts
CHANGED
|
@@ -967,7 +967,7 @@ const RESOURCE_SEARCH_RERANK_MODEL = '@cf/baai/bge-reranker-base';
|
|
|
967
967
|
|
|
968
968
|
const RESOURCE_HANDLER_SLEEP_AFTER = '10m';
|
|
969
969
|
|
|
970
|
-
const DOCS_URL = 'https://
|
|
970
|
+
const DOCS_URL = 'https://ganju.ai/docs/';
|
|
971
971
|
|
|
972
972
|
const BASE64_DATA_URI_RE =
|
|
973
973
|
/data:image\/[a-zA-Z0-9+\-.]+;base64,[A-Za-z0-9+/=\s]+/g;
|
package/src/toolLint.ts
CHANGED
|
@@ -43,8 +43,9 @@ export interface ToolLintFinding {
|
|
|
43
43
|
// The tool the finding is about. Absent only for `too-many-tools`, which is
|
|
44
44
|
// about the server as a whole.
|
|
45
45
|
tool?: string;
|
|
46
|
-
// `overlapping-tools`: the
|
|
47
|
-
|
|
46
|
+
// `overlapping-tools`: the tools it may be confused with. Each look-alike
|
|
47
|
+
// pair is reported once, on the first of the two.
|
|
48
|
+
alike?: string[];
|
|
48
49
|
// `undescribed-input`: the properties with no description, as dotted paths.
|
|
49
50
|
paths?: string[];
|
|
50
51
|
// `too-many-tools`: how many are enabled, and the number past which it warns.
|
|
@@ -74,22 +75,30 @@ export const TOOL_LINT_MIN_DESCRIPTION_LENGTH = 40;
|
|
|
74
75
|
// (CHANNEL_MAX_TOOLS); this warns well before that.
|
|
75
76
|
export const TOOL_LINT_MAX_TOOLS = 25;
|
|
76
77
|
|
|
77
|
-
// Two descriptions sharing at least this much of their vocabulary
|
|
78
|
-
//
|
|
78
|
+
// Two descriptions sharing at least this much of their vocabulary read as the
|
|
79
|
+
// same tool to a model. Words are weighted by how rare they are among the
|
|
80
|
+
// server's tools (see descriptionWeights), so tools written from one template
|
|
81
|
+
// — "Use when someone asks about X" — are told apart by their X.
|
|
79
82
|
const DESCRIPTION_OVERLAP = 0.6;
|
|
80
83
|
// Too few content words and two unrelated descriptions can share most of them.
|
|
81
84
|
const MIN_OVERLAP_WORDS = 5;
|
|
82
85
|
|
|
83
86
|
// Lowercased, accents off, so "Úsala cuando" and "usala cuando" are one text.
|
|
84
87
|
const fold = (text: string): string =>
|
|
85
|
-
text
|
|
88
|
+
text
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
.normalize('NFKD')
|
|
91
|
+
.replace(/[\u0300-\u036f]/g, '');
|
|
86
92
|
|
|
87
93
|
// Wording that tells a model WHEN to call a tool rather than only what it does
|
|
88
94
|
// — English, Spanish and Portuguese, the languages descriptions here are
|
|
89
95
|
// written in. Loose on purpose: a false "says when" costs nothing, a false
|
|
90
|
-
// "doesn't" nags someone whose description is fine.
|
|
96
|
+
// "doesn't" nags someone whose description is fine. But not so loose that
|
|
97
|
+
// words about how a tool works count: "uses the Stripe API" and "makes an API
|
|
98
|
+
// call" say nothing about when to reach for it, so "call" counts only as an
|
|
99
|
+
// instruction ("call this…", "call it…").
|
|
91
100
|
const USAGE_GUIDANCE =
|
|
92
|
-
/\b(use|
|
|
101
|
+
/\b(use|call (?:this|it)|when|whenever|before|after|instead|only if|if the user|if you|for questions|usa|usala|usalo|usar|llama|llamala|llamar|cuando|antes de|despues de|en lugar de|si el usuario|quando|use-a|utilize|chame)\b/;
|
|
93
102
|
|
|
94
103
|
const STOPWORDS = new Set([
|
|
95
104
|
'the',
|
|
@@ -137,9 +146,22 @@ const contentWords = (text: string): Set<string> =>
|
|
|
137
146
|
.filter(word => word.length >= 3 && !STOPWORDS.has(word))
|
|
138
147
|
);
|
|
139
148
|
|
|
149
|
+
// A proxied tool is named `<server prefix>__<remote name>`. The prefix says
|
|
150
|
+
// which connection it came from, not what it does, so `search_repositories`
|
|
151
|
+
// and `github__search_repositories` are the same tool to a model. Kept here
|
|
152
|
+
// rather than imported from constants (MCP_PROXY_TOOL_NAME_SEP), since this
|
|
153
|
+
// file imports nothing.
|
|
154
|
+
const PROXY_NAME_SEP = '__';
|
|
155
|
+
|
|
156
|
+
const withoutProxyPrefix = (name: string): string => {
|
|
157
|
+
const at = name.lastIndexOf(PROXY_NAME_SEP);
|
|
158
|
+
const bare = at === -1 ? name : name.slice(at + PROXY_NAME_SEP.length);
|
|
159
|
+
return bare || name;
|
|
160
|
+
};
|
|
161
|
+
|
|
140
162
|
// `lookupOrder`, `lookup-order` and `order_lookup` name the same thing.
|
|
141
163
|
const nameTokens = (name: string): string =>
|
|
142
|
-
name
|
|
164
|
+
withoutProxyPrefix(name)
|
|
143
165
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
144
166
|
.toLowerCase()
|
|
145
167
|
.split(/[^a-z0-9]+/)
|
|
@@ -147,21 +169,36 @@ const nameTokens = (name: string): string =>
|
|
|
147
169
|
.sort()
|
|
148
170
|
.join(' ');
|
|
149
171
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
172
|
+
// How much each word says about which tool a description belongs to: a word
|
|
173
|
+
// in every description (the template, the domain) next to nothing, a word in
|
|
174
|
+
// one of them a lot. Smoothed, so with two tools a shared word still counts.
|
|
175
|
+
const descriptionWeights = (
|
|
176
|
+
descriptions: Set<string>[]
|
|
177
|
+
): ((word: string) => number) => {
|
|
178
|
+
const frequency = new Map<string, number>();
|
|
179
|
+
for (const words of descriptions) {
|
|
180
|
+
for (const word of words) {
|
|
181
|
+
frequency.set(word, (frequency.get(word) ?? 0) + 1);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const total = descriptions.length;
|
|
185
|
+
return word => Math.log(1 + total / (frequency.get(word) ?? 1));
|
|
155
186
|
};
|
|
156
187
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
188
|
+
// Jaccard, with each word counted by its weight.
|
|
189
|
+
const weightedJaccard = (
|
|
190
|
+
a: Set<string>,
|
|
191
|
+
b: Set<string>,
|
|
192
|
+
weight: (word: string) => number
|
|
193
|
+
): number => {
|
|
194
|
+
let shared = 0;
|
|
195
|
+
let union = 0;
|
|
196
|
+
for (const word of a) {
|
|
197
|
+
union += weight(word);
|
|
198
|
+
if (b.has(word)) shared += weight(word);
|
|
163
199
|
}
|
|
164
|
-
|
|
200
|
+
for (const word of b) if (!a.has(word)) union += weight(word);
|
|
201
|
+
return union === 0 ? 0 : shared / union;
|
|
165
202
|
};
|
|
166
203
|
|
|
167
204
|
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
@@ -202,6 +239,21 @@ export const lintTools = (
|
|
|
202
239
|
const others = options.others ?? [];
|
|
203
240
|
const findings: ToolLintFinding[] = [];
|
|
204
241
|
|
|
242
|
+
const everyTool = [...tools, ...others];
|
|
243
|
+
const wordsOf = new Map(
|
|
244
|
+
everyTool.map(tool => [tool, contentWords(tool.description ?? '')])
|
|
245
|
+
);
|
|
246
|
+
const weight = descriptionWeights([...wordsOf.values()]);
|
|
247
|
+
const overlaps = (a: LintableTool, b: LintableTool): boolean => {
|
|
248
|
+
if (nameTokens(a.name) === nameTokens(b.name)) return true;
|
|
249
|
+
const aWords = wordsOf.get(a)!;
|
|
250
|
+
const bWords = wordsOf.get(b)!;
|
|
251
|
+
if (aWords.size < MIN_OVERLAP_WORDS || bWords.size < MIN_OVERLAP_WORDS) {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
return weightedJaccard(aWords, bWords, weight) >= DESCRIPTION_OVERLAP;
|
|
255
|
+
};
|
|
256
|
+
|
|
205
257
|
tools.forEach((tool, index) => {
|
|
206
258
|
const description = (tool.description ?? '').trim();
|
|
207
259
|
|
|
@@ -250,15 +302,18 @@ export const lintTools = (
|
|
|
250
302
|
});
|
|
251
303
|
}
|
|
252
304
|
|
|
253
|
-
// Each pair once
|
|
254
|
-
// server
|
|
255
|
-
|
|
256
|
-
|
|
305
|
+
// Each pair once — against the tools after this one, then the rest of the
|
|
306
|
+
// server — and one finding per tool, so twenty look-alikes are nineteen
|
|
307
|
+
// lines rather than a hundred and ninety.
|
|
308
|
+
const alike = [...tools.slice(index + 1), ...others]
|
|
309
|
+
.filter(other => other.name !== tool.name && overlaps(tool, other))
|
|
310
|
+
.map(other => other.name);
|
|
311
|
+
if (alike.length > 0) {
|
|
257
312
|
findings.push({
|
|
258
313
|
rule: 'overlapping-tools',
|
|
259
314
|
tool: tool.name,
|
|
260
|
-
|
|
261
|
-
message: `${quoted(tool.name)}
|
|
315
|
+
alike,
|
|
316
|
+
message: `${quoted(tool.name)} looks like ${alike.map(quoted).join(', ')} in name or description, so a model may call one for another. Make each description say what sets it apart.`
|
|
262
317
|
});
|
|
263
318
|
}
|
|
264
319
|
});
|