@weareikko/eslint-plugin-skill 0.0.1 → 0.1.1
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/README.md +162 -2
- package/configs/check-refs.workflow.yml +25 -0
- package/configs/eslint.config.example.js +23 -0
- package/configs/lint-skills.workflow.yml +26 -0
- package/dist/dedup/analyze.d.ts +26 -0
- package/dist/dedup/analyze.d.ts.map +1 -0
- package/dist/dedup/cli.d.ts +10 -0
- package/dist/dedup/cli.d.ts.map +1 -0
- package/dist/dedup/cli.js +37 -0
- package/dist/dedup/cli.js.map +1 -0
- package/dist/dedup/discover.d.ts +16 -0
- package/dist/dedup/discover.d.ts.map +1 -0
- package/dist/dedup/hash.d.ts +17 -0
- package/dist/dedup/hash.d.ts.map +1 -0
- package/dist/dedup/index.d.ts +4 -0
- package/dist/dedup/index.d.ts.map +1 -0
- package/dist/dedup/index.js +2 -0
- package/dist/dedup/report.d.ts +6 -0
- package/dist/dedup/report.d.ts.map +1 -0
- package/dist/dedup/types.d.ts +92 -0
- package/dist/dedup/types.d.ts.map +1 -0
- package/dist/index.d.ts +109 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1236 -0
- package/dist/index.js.map +1 -0
- package/dist/report-ClPKWYJy.js +266 -0
- package/dist/report-ClPKWYJy.js.map +1 -0
- package/dist/rules/body-max-length.d.ts +16 -0
- package/dist/rules/body-max-length.d.ts.map +1 -0
- package/dist/rules/description-has-trigger.d.ts +16 -0
- package/dist/rules/description-has-trigger.d.ts.map +1 -0
- package/dist/rules/description-length.d.ts +16 -0
- package/dist/rules/description-length.d.ts.map +1 -0
- package/dist/rules/description-no-html.d.ts +13 -0
- package/dist/rules/description-no-html.d.ts.map +1 -0
- package/dist/rules/descriptive-reference-filenames.d.ts +15 -0
- package/dist/rules/descriptive-reference-filenames.d.ts.map +1 -0
- package/dist/rules/h1-matches-name.d.ts +13 -0
- package/dist/rules/h1-matches-name.d.ts.map +1 -0
- package/dist/rules/large-reference-toc.d.ts +16 -0
- package/dist/rules/large-reference-toc.d.ts.map +1 -0
- package/dist/rules/meta-prefers-dispatch-table.d.ts +19 -0
- package/dist/rules/meta-prefers-dispatch-table.d.ts.map +1 -0
- package/dist/rules/multi-domain-has-variants.d.ts +16 -0
- package/dist/rules/multi-domain-has-variants.d.ts.map +1 -0
- package/dist/rules/name-kebab-case.d.ts +10 -0
- package/dist/rules/name-kebab-case.d.ts.map +1 -0
- package/dist/rules/name-matches-dir.d.ts +10 -0
- package/dist/rules/name-matches-dir.d.ts.map +1 -0
- package/dist/rules/no-broken-relative-links.d.ts +14 -0
- package/dist/rules/no-broken-relative-links.d.ts.map +1 -0
- package/dist/rules/no-multiple-h1.d.ts +15 -0
- package/dist/rules/no-multiple-h1.d.ts.map +1 -0
- package/dist/rules/no-orphan-references.d.ts +15 -0
- package/dist/rules/no-orphan-references.d.ts.map +1 -0
- package/dist/rules/no-unknown-frontmatter-keys.d.ts +18 -0
- package/dist/rules/no-unknown-frontmatter-keys.d.ts.map +1 -0
- package/dist/rules/procedural-has-steps.d.ts +15 -0
- package/dist/rules/procedural-has-steps.d.ts.map +1 -0
- package/dist/rules/reference-has-content.d.ts +15 -0
- package/dist/rules/reference-has-content.d.ts.map +1 -0
- package/dist/rules/refs-one-level-deep.d.ts +15 -0
- package/dist/rules/refs-one-level-deep.d.ts.map +1 -0
- package/dist/rules/require-frontmatter.d.ts +11 -0
- package/dist/rules/require-frontmatter.d.ts.map +1 -0
- package/dist/rules/valid-allowed-tools.d.ts +15 -0
- package/dist/rules/valid-allowed-tools.d.ts.map +1 -0
- package/dist/types.d.ts +15 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/utils/ast.d.ts +21 -0
- package/dist/utils/ast.d.ts.map +1 -0
- package/dist/utils/frontmatter.d.ts +24 -0
- package/dist/utils/frontmatter.d.ts.map +1 -0
- package/dist/utils/kind.d.ts +41 -0
- package/dist/utils/kind.d.ts.map +1 -0
- package/dist/utils/links.d.ts +23 -0
- package/dist/utils/links.d.ts.map +1 -0
- package/dist/utils/references.d.ts +7 -0
- package/dist/utils/references.d.ts.map +1 -0
- package/dist/utils/skill-file.d.ts +32 -0
- package/dist/utils/skill-file.d.ts.map +1 -0
- package/dist/utils/skill-fs.d.ts +30 -0
- package/dist/utils/skill-fs.d.ts.map +1 -0
- package/docs/check-refs.md +60 -0
- package/docs/rules/body-max-length.md +23 -0
- package/docs/rules/description-has-trigger.md +28 -0
- package/docs/rules/description-length.md +27 -0
- package/docs/rules/description-no-html.md +22 -0
- package/docs/rules/descriptive-reference-filenames.md +26 -0
- package/docs/rules/h1-matches-name.md +36 -0
- package/docs/rules/large-reference-toc.md +26 -0
- package/docs/rules/meta-prefers-dispatch-table.md +30 -0
- package/docs/rules/multi-domain-has-variants.md +26 -0
- package/docs/rules/name-kebab-case.md +31 -0
- package/docs/rules/name-matches-dir.md +29 -0
- package/docs/rules/no-broken-relative-links.md +25 -0
- package/docs/rules/no-multiple-h1.md +31 -0
- package/docs/rules/no-orphan-references.md +25 -0
- package/docs/rules/no-unknown-frontmatter-keys.md +37 -0
- package/docs/rules/procedural-has-steps.md +43 -0
- package/docs/rules/reference-has-content.md +26 -0
- package/docs/rules/refs-one-level-deep.md +26 -0
- package/docs/rules/require-frontmatter.md +34 -0
- package/docs/rules/valid-allowed-tools.md +27 -0
- package/package.json +55 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,1236 @@
|
|
|
1
|
+
import markdown from "@eslint/markdown";
|
|
2
|
+
import { parse } from "yaml";
|
|
3
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
5
|
+
//#region src/utils/skill-file.ts
|
|
6
|
+
/**
|
|
7
|
+
* Helpers for recognizing and locating Claude Code skill files.
|
|
8
|
+
*
|
|
9
|
+
* Kept dependency-free and side-effect-free so the compiled output ports
|
|
10
|
+
* cleanly to oxlint's ESLint-compatible JS plugin runtime.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* The canonical filename of a Claude Code Agent Skill.
|
|
14
|
+
*/
|
|
15
|
+
var SKILL_FILENAME = "SKILL.md";
|
|
16
|
+
/**
|
|
17
|
+
* Extract the basename of a POSIX or Windows path without relying on `node:path`,
|
|
18
|
+
* so the logic is identical regardless of the host runtime's path semantics.
|
|
19
|
+
*/
|
|
20
|
+
function basename$1(filename) {
|
|
21
|
+
if (typeof filename !== "string") return "";
|
|
22
|
+
const normalized = filename.replace(/\\/g, "/");
|
|
23
|
+
const trimmed = normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
|
|
24
|
+
const slash = trimmed.lastIndexOf("/");
|
|
25
|
+
return slash === -1 ? trimmed : trimmed.slice(slash + 1);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The name of the directory that directly contains the given file.
|
|
29
|
+
*/
|
|
30
|
+
function dirName(filename) {
|
|
31
|
+
if (typeof filename !== "string") return "";
|
|
32
|
+
const normalized = filename.replace(/\\/g, "/");
|
|
33
|
+
const trimmed = normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
|
|
34
|
+
const slash = trimmed.lastIndexOf("/");
|
|
35
|
+
if (slash === -1) return "";
|
|
36
|
+
return basename$1(trimmed.slice(0, slash));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Whether the linted file is a skill definition (`SKILL.md`).
|
|
40
|
+
*
|
|
41
|
+
* Rules call this first and no-op when it returns `false`, so the plugin is
|
|
42
|
+
* safe even when its config globs every `*.md` file in a repository.
|
|
43
|
+
*/
|
|
44
|
+
function isSkillFile(filename) {
|
|
45
|
+
return basename$1(filename) === SKILL_FILENAME;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Whether the linted file is a bundled reference document — a `.md` file
|
|
49
|
+
* (other than `SKILL.md`) living under a `references/` directory.
|
|
50
|
+
*/
|
|
51
|
+
function isReferenceFile(filename) {
|
|
52
|
+
if (typeof filename !== "string" || !filename.endsWith(".md")) return false;
|
|
53
|
+
if (basename$1(filename) === "SKILL.md") return false;
|
|
54
|
+
return filename.replace(/\\/g, "/").split("/").includes("references");
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/utils/frontmatter.ts
|
|
58
|
+
function parseFrontmatter(node) {
|
|
59
|
+
const raw = typeof node.value === "string" ? node.value : "";
|
|
60
|
+
const contentStartLine = (node.position?.start?.line ?? 1) + 1;
|
|
61
|
+
const nodeLoc = node.position ?? {
|
|
62
|
+
start: {
|
|
63
|
+
line: 1,
|
|
64
|
+
column: 1
|
|
65
|
+
},
|
|
66
|
+
end: {
|
|
67
|
+
line: 1,
|
|
68
|
+
column: 1
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
let data = null;
|
|
72
|
+
let error = null;
|
|
73
|
+
try {
|
|
74
|
+
const parsed = parse(raw);
|
|
75
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) data = parsed;
|
|
76
|
+
else if (parsed === null || parsed === void 0) data = {};
|
|
77
|
+
else error = "frontmatter is not a key/value mapping";
|
|
78
|
+
} catch (e) {
|
|
79
|
+
error = e instanceof Error ? e.message : String(e);
|
|
80
|
+
}
|
|
81
|
+
const lines = raw.split("\n");
|
|
82
|
+
function keyLoc(key) {
|
|
83
|
+
const pattern = new RegExp(`^(\\s*)(${escapeRegExp(key)})\\s*:`);
|
|
84
|
+
for (let i = 0; i < lines.length; i++) {
|
|
85
|
+
const match = pattern.exec(lines[i]);
|
|
86
|
+
if (match) {
|
|
87
|
+
const line = contentStartLine + i;
|
|
88
|
+
const column = match[1].length + 1;
|
|
89
|
+
return {
|
|
90
|
+
start: {
|
|
91
|
+
line,
|
|
92
|
+
column
|
|
93
|
+
},
|
|
94
|
+
end: {
|
|
95
|
+
line,
|
|
96
|
+
column: column + key.length
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
start: nodeLoc.start,
|
|
103
|
+
end: nodeLoc.end
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
data,
|
|
108
|
+
error,
|
|
109
|
+
raw,
|
|
110
|
+
keyLoc
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function escapeRegExp(value) {
|
|
114
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
115
|
+
}
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/utils/ast.ts
|
|
118
|
+
/**
|
|
119
|
+
* The frontmatter `yaml` node, if present as a direct child of root.
|
|
120
|
+
*/
|
|
121
|
+
function findFrontmatter(root) {
|
|
122
|
+
return root.children.find((child) => child.type === "yaml");
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* All nodes of a given type anywhere in the tree, in document order.
|
|
126
|
+
*/
|
|
127
|
+
function collect(node, type) {
|
|
128
|
+
const out = [];
|
|
129
|
+
const walk = (current) => {
|
|
130
|
+
if (current.type === type) out.push(current);
|
|
131
|
+
if ("children" in current) for (const child of current.children) walk(child);
|
|
132
|
+
};
|
|
133
|
+
walk(node);
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Concatenated text content of a node's subtree.
|
|
138
|
+
*/
|
|
139
|
+
function textContent(node) {
|
|
140
|
+
if ("value" in node && typeof node.value === "string") return node.value;
|
|
141
|
+
if ("children" in node) return node.children.map((child) => textContent(child)).join("");
|
|
142
|
+
return "";
|
|
143
|
+
}
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region src/rules/require-frontmatter.ts
|
|
146
|
+
var TOP$5 = {
|
|
147
|
+
start: {
|
|
148
|
+
line: 1,
|
|
149
|
+
column: 1
|
|
150
|
+
},
|
|
151
|
+
end: {
|
|
152
|
+
line: 1,
|
|
153
|
+
column: 1
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
var rule$19 = {
|
|
157
|
+
meta: {
|
|
158
|
+
type: "problem",
|
|
159
|
+
docs: {
|
|
160
|
+
description: "Require `name` and `description` frontmatter on a skill",
|
|
161
|
+
recommended: true,
|
|
162
|
+
url: "https://github.com/weareikko/motto/blob/main/packages/eslint-plugin-skill/docs/rules/require-frontmatter.md"
|
|
163
|
+
},
|
|
164
|
+
schema: [],
|
|
165
|
+
messages: {
|
|
166
|
+
missingFrontmatter: "SKILL.md must start with YAML frontmatter containing `name` and `description`.",
|
|
167
|
+
invalidFrontmatter: "Skill frontmatter is not valid YAML: {{reason}}",
|
|
168
|
+
missingKey: "Skill frontmatter is missing a `{{key}}` field.",
|
|
169
|
+
emptyKey: "Skill frontmatter field `{{key}}` must not be empty."
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
create(context) {
|
|
173
|
+
if (!isSkillFile(context.filename)) return {};
|
|
174
|
+
return { root(root) {
|
|
175
|
+
const node = findFrontmatter(root);
|
|
176
|
+
if (!node) {
|
|
177
|
+
context.report({
|
|
178
|
+
loc: TOP$5,
|
|
179
|
+
messageId: "missingFrontmatter"
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const { data, error, keyLoc } = parseFrontmatter(node);
|
|
184
|
+
if (error || !data) {
|
|
185
|
+
context.report({
|
|
186
|
+
loc: node.position ?? TOP$5,
|
|
187
|
+
messageId: "invalidFrontmatter",
|
|
188
|
+
data: { reason: error ?? "unknown error" }
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
for (const key of ["name", "description"]) {
|
|
193
|
+
if (!(key in data)) {
|
|
194
|
+
context.report({
|
|
195
|
+
loc: node.position ?? TOP$5,
|
|
196
|
+
messageId: "missingKey",
|
|
197
|
+
data: { key }
|
|
198
|
+
});
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const value = data[key];
|
|
202
|
+
if (typeof value !== "string" || value.trim() === "") context.report({
|
|
203
|
+
loc: keyLoc(key),
|
|
204
|
+
messageId: "emptyKey",
|
|
205
|
+
data: { key }
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
} };
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
//#endregion
|
|
212
|
+
//#region src/rules/name-kebab-case.ts
|
|
213
|
+
var KEBAB = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
214
|
+
var MAX_LENGTH = 64;
|
|
215
|
+
var RESERVED = ["anthropic", "claude"];
|
|
216
|
+
var rule$18 = {
|
|
217
|
+
meta: {
|
|
218
|
+
type: "problem",
|
|
219
|
+
docs: {
|
|
220
|
+
description: "Require the skill `name` to be a valid Agent Skills identifier",
|
|
221
|
+
recommended: true,
|
|
222
|
+
url: "https://github.com/weareikko/motto/blob/main/packages/eslint-plugin-skill/docs/rules/name-kebab-case.md"
|
|
223
|
+
},
|
|
224
|
+
schema: [],
|
|
225
|
+
messages: {
|
|
226
|
+
notKebab: "Skill `name` must be letter-start kebab-case (e.g. `my-skill`), got `{{name}}`.",
|
|
227
|
+
tooLong: "Skill `name` must be at most {{max}} characters; got {{length}}.",
|
|
228
|
+
reservedWord: "Skill `name` must not contain the reserved word `{{word}}`."
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
create(context) {
|
|
232
|
+
if (!isSkillFile(context.filename)) return {};
|
|
233
|
+
return { root(root) {
|
|
234
|
+
const node = findFrontmatter(root);
|
|
235
|
+
if (!node) return;
|
|
236
|
+
const { data, keyLoc } = parseFrontmatter(node);
|
|
237
|
+
if (!data || typeof data.name !== "string" || data.name === "") return;
|
|
238
|
+
const name = data.name;
|
|
239
|
+
const loc = keyLoc("name");
|
|
240
|
+
if (!KEBAB.test(name)) context.report({
|
|
241
|
+
loc,
|
|
242
|
+
messageId: "notKebab",
|
|
243
|
+
data: { name }
|
|
244
|
+
});
|
|
245
|
+
if (name.length > MAX_LENGTH) context.report({
|
|
246
|
+
loc,
|
|
247
|
+
messageId: "tooLong",
|
|
248
|
+
data: {
|
|
249
|
+
max: MAX_LENGTH,
|
|
250
|
+
length: name.length
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
const lowered = name.toLowerCase();
|
|
254
|
+
const reserved = RESERVED.find((word) => lowered.includes(word));
|
|
255
|
+
if (reserved) context.report({
|
|
256
|
+
loc,
|
|
257
|
+
messageId: "reservedWord",
|
|
258
|
+
data: { word: reserved }
|
|
259
|
+
});
|
|
260
|
+
} };
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
//#endregion
|
|
264
|
+
//#region src/rules/name-matches-dir.ts
|
|
265
|
+
var rule$17 = {
|
|
266
|
+
meta: {
|
|
267
|
+
type: "problem",
|
|
268
|
+
docs: {
|
|
269
|
+
description: "Require the skill `name` to match its containing directory",
|
|
270
|
+
recommended: true,
|
|
271
|
+
url: "https://github.com/weareikko/motto/blob/main/packages/eslint-plugin-skill/docs/rules/name-matches-dir.md"
|
|
272
|
+
},
|
|
273
|
+
schema: [],
|
|
274
|
+
messages: { mismatch: "Skill `name` (`{{name}}`) must match its directory name (`{{dir}}`)." }
|
|
275
|
+
},
|
|
276
|
+
create(context) {
|
|
277
|
+
if (!isSkillFile(context.filename)) return {};
|
|
278
|
+
const dir = dirName(context.filename);
|
|
279
|
+
if (!dir) return {};
|
|
280
|
+
return { root(root) {
|
|
281
|
+
const node = findFrontmatter(root);
|
|
282
|
+
if (!node) return;
|
|
283
|
+
const { data, keyLoc } = parseFrontmatter(node);
|
|
284
|
+
if (!data || typeof data.name !== "string" || data.name === "") return;
|
|
285
|
+
if (data.name !== dir) context.report({
|
|
286
|
+
loc: keyLoc("name"),
|
|
287
|
+
messageId: "mismatch",
|
|
288
|
+
data: {
|
|
289
|
+
name: data.name,
|
|
290
|
+
dir
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
} };
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
//#endregion
|
|
297
|
+
//#region src/rules/description-length.ts
|
|
298
|
+
var DEFAULT_MAX = 1024;
|
|
299
|
+
var rule$16 = {
|
|
300
|
+
meta: {
|
|
301
|
+
type: "problem",
|
|
302
|
+
docs: {
|
|
303
|
+
description: "Cap the length of the skill `description`",
|
|
304
|
+
recommended: true,
|
|
305
|
+
url: "https://github.com/weareikko/motto/blob/main/packages/eslint-plugin-skill/docs/rules/description-length.md"
|
|
306
|
+
},
|
|
307
|
+
schema: [{
|
|
308
|
+
type: "object",
|
|
309
|
+
properties: { max: {
|
|
310
|
+
type: "integer",
|
|
311
|
+
minimum: 1
|
|
312
|
+
} },
|
|
313
|
+
additionalProperties: false
|
|
314
|
+
}],
|
|
315
|
+
messages: { tooLong: "Skill `description` is {{length}} characters; keep it at or under {{max}}." }
|
|
316
|
+
},
|
|
317
|
+
create(context) {
|
|
318
|
+
if (!isSkillFile(context.filename)) return {};
|
|
319
|
+
const max = context.options[0]?.max ?? DEFAULT_MAX;
|
|
320
|
+
return { root(root) {
|
|
321
|
+
const node = findFrontmatter(root);
|
|
322
|
+
if (!node) return;
|
|
323
|
+
const { data, keyLoc } = parseFrontmatter(node);
|
|
324
|
+
if (!data || typeof data.description !== "string") return;
|
|
325
|
+
const length = data.description.length;
|
|
326
|
+
if (length > max) context.report({
|
|
327
|
+
loc: keyLoc("description"),
|
|
328
|
+
messageId: "tooLong",
|
|
329
|
+
data: {
|
|
330
|
+
length,
|
|
331
|
+
max
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
} };
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
//#endregion
|
|
338
|
+
//#region src/rules/description-has-trigger.ts
|
|
339
|
+
var DEFAULT_PATTERNS = [
|
|
340
|
+
"use when",
|
|
341
|
+
"use this when",
|
|
342
|
+
"use for",
|
|
343
|
+
"used when",
|
|
344
|
+
"used for",
|
|
345
|
+
"when the user",
|
|
346
|
+
"when you",
|
|
347
|
+
"when asked",
|
|
348
|
+
"trigger",
|
|
349
|
+
"invoke",
|
|
350
|
+
"for when"
|
|
351
|
+
];
|
|
352
|
+
var rule$15 = {
|
|
353
|
+
meta: {
|
|
354
|
+
type: "suggestion",
|
|
355
|
+
docs: {
|
|
356
|
+
description: "Require the `description` to express when the skill applies",
|
|
357
|
+
recommended: true,
|
|
358
|
+
url: "https://github.com/weareikko/motto/blob/main/packages/eslint-plugin-skill/docs/rules/description-has-trigger.md"
|
|
359
|
+
},
|
|
360
|
+
schema: [{
|
|
361
|
+
type: "object",
|
|
362
|
+
properties: { patterns: {
|
|
363
|
+
type: "array",
|
|
364
|
+
items: { type: "string" },
|
|
365
|
+
minItems: 1
|
|
366
|
+
} },
|
|
367
|
+
additionalProperties: false
|
|
368
|
+
}],
|
|
369
|
+
messages: { noTrigger: "Skill `description` should state when to use the skill (e.g. a phrase like {{examples}})." }
|
|
370
|
+
},
|
|
371
|
+
create(context) {
|
|
372
|
+
if (!isSkillFile(context.filename)) return {};
|
|
373
|
+
const patterns = context.options[0]?.patterns ?? DEFAULT_PATTERNS;
|
|
374
|
+
const lowered = patterns.map((p) => p.toLowerCase());
|
|
375
|
+
return { root(root) {
|
|
376
|
+
const node = findFrontmatter(root);
|
|
377
|
+
if (!node) return;
|
|
378
|
+
const { data, keyLoc } = parseFrontmatter(node);
|
|
379
|
+
if (!data || typeof data.description !== "string" || data.description.trim() === "") return;
|
|
380
|
+
const haystack = data.description.toLowerCase();
|
|
381
|
+
if (!lowered.some((p) => haystack.includes(p))) context.report({
|
|
382
|
+
loc: keyLoc("description"),
|
|
383
|
+
messageId: "noTrigger",
|
|
384
|
+
data: { examples: patterns.slice(0, 2).map((p) => `"${p}"`).join(", ") }
|
|
385
|
+
});
|
|
386
|
+
} };
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
//#endregion
|
|
390
|
+
//#region src/rules/description-no-html.ts
|
|
391
|
+
var TAG = /<\/?[a-zA-Z][^>]*>/;
|
|
392
|
+
var rule$14 = {
|
|
393
|
+
meta: {
|
|
394
|
+
type: "problem",
|
|
395
|
+
docs: {
|
|
396
|
+
description: "Disallow HTML/XML tags in the skill `description`",
|
|
397
|
+
recommended: true,
|
|
398
|
+
url: "https://github.com/weareikko/motto/blob/main/packages/eslint-plugin-skill/docs/rules/description-no-html.md"
|
|
399
|
+
},
|
|
400
|
+
schema: [],
|
|
401
|
+
messages: { hasTag: "Skill `description` must not contain HTML/XML tags (found `{{tag}}`)." }
|
|
402
|
+
},
|
|
403
|
+
create(context) {
|
|
404
|
+
if (!isSkillFile(context.filename)) return {};
|
|
405
|
+
return { root(root) {
|
|
406
|
+
const node = findFrontmatter(root);
|
|
407
|
+
if (!node) return;
|
|
408
|
+
const { data, keyLoc } = parseFrontmatter(node);
|
|
409
|
+
if (!data || typeof data.description !== "string") return;
|
|
410
|
+
const match = TAG.exec(data.description);
|
|
411
|
+
if (match) context.report({
|
|
412
|
+
loc: keyLoc("description"),
|
|
413
|
+
messageId: "hasTag",
|
|
414
|
+
data: { tag: match[0] }
|
|
415
|
+
});
|
|
416
|
+
} };
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
//#endregion
|
|
420
|
+
//#region src/rules/no-multiple-h1.ts
|
|
421
|
+
var rule$13 = {
|
|
422
|
+
meta: {
|
|
423
|
+
type: "problem",
|
|
424
|
+
docs: {
|
|
425
|
+
description: "Disallow more than one top-level H1 in the skill body",
|
|
426
|
+
recommended: true,
|
|
427
|
+
url: "https://github.com/weareikko/motto/blob/main/packages/eslint-plugin-skill/docs/rules/no-multiple-h1.md"
|
|
428
|
+
},
|
|
429
|
+
schema: [],
|
|
430
|
+
messages: { multiple: "Skill body must have at most one `#` H1 title; found {{count}}." }
|
|
431
|
+
},
|
|
432
|
+
create(context) {
|
|
433
|
+
if (!isSkillFile(context.filename)) return {};
|
|
434
|
+
return { root(root) {
|
|
435
|
+
const h1s = collect(root, "heading").filter((h) => h.depth === 1);
|
|
436
|
+
if (h1s.length > 1) {
|
|
437
|
+
for (const extra of h1s.slice(1)) if (extra.position) context.report({
|
|
438
|
+
loc: extra.position,
|
|
439
|
+
messageId: "multiple",
|
|
440
|
+
data: { count: h1s.length }
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
} };
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
//#endregion
|
|
447
|
+
//#region src/rules/h1-matches-name.ts
|
|
448
|
+
function normalize(value) {
|
|
449
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
450
|
+
}
|
|
451
|
+
var rule$12 = {
|
|
452
|
+
meta: {
|
|
453
|
+
type: "suggestion",
|
|
454
|
+
docs: {
|
|
455
|
+
description: "Require the body H1 to correspond to the skill `name`",
|
|
456
|
+
recommended: false,
|
|
457
|
+
url: "https://github.com/weareikko/motto/blob/main/packages/eslint-plugin-skill/docs/rules/h1-matches-name.md"
|
|
458
|
+
},
|
|
459
|
+
schema: [],
|
|
460
|
+
messages: { mismatch: "Body H1 (`{{title}}`) should correspond to the skill `name` (`{{name}}`)." }
|
|
461
|
+
},
|
|
462
|
+
create(context) {
|
|
463
|
+
if (!isSkillFile(context.filename)) return {};
|
|
464
|
+
return { root(root) {
|
|
465
|
+
const node = findFrontmatter(root);
|
|
466
|
+
if (!node) return;
|
|
467
|
+
const { data } = parseFrontmatter(node);
|
|
468
|
+
if (!data || typeof data.name !== "string" || data.name === "") return;
|
|
469
|
+
const h1 = collect(root, "heading").find((h) => h.depth === 1);
|
|
470
|
+
if (!h1 || !h1.position) return;
|
|
471
|
+
const title = textContent(h1).trim();
|
|
472
|
+
if (normalize(title) !== normalize(data.name)) context.report({
|
|
473
|
+
loc: h1.position,
|
|
474
|
+
messageId: "mismatch",
|
|
475
|
+
data: {
|
|
476
|
+
title,
|
|
477
|
+
name: data.name
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
} };
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
//#endregion
|
|
484
|
+
//#region src/rules/body-max-length.ts
|
|
485
|
+
var DEFAULT_MAX_LINES$1 = 500;
|
|
486
|
+
var rule$11 = {
|
|
487
|
+
meta: {
|
|
488
|
+
type: "suggestion",
|
|
489
|
+
docs: {
|
|
490
|
+
description: "Keep the skill body short to preserve progressive disclosure",
|
|
491
|
+
recommended: true,
|
|
492
|
+
url: "https://github.com/weareikko/motto/blob/main/packages/eslint-plugin-skill/docs/rules/body-max-length.md"
|
|
493
|
+
},
|
|
494
|
+
schema: [{
|
|
495
|
+
type: "object",
|
|
496
|
+
properties: { maxLines: {
|
|
497
|
+
type: "integer",
|
|
498
|
+
minimum: 1
|
|
499
|
+
} },
|
|
500
|
+
additionalProperties: false
|
|
501
|
+
}],
|
|
502
|
+
messages: { tooLong: "Skill body is {{lines}} lines; keep it at or under {{max}} and move detail into referenced files." }
|
|
503
|
+
},
|
|
504
|
+
create(context) {
|
|
505
|
+
if (!isSkillFile(context.filename)) return {};
|
|
506
|
+
const max = context.options[0]?.maxLines ?? DEFAULT_MAX_LINES$1;
|
|
507
|
+
return { root(root) {
|
|
508
|
+
const totalLines = root.position?.end?.line ?? 0;
|
|
509
|
+
const fm = findFrontmatter(root);
|
|
510
|
+
const bodyStart = fm ? fm.position?.end?.line ?? 0 : 0;
|
|
511
|
+
const bodyLines = Math.max(0, totalLines - bodyStart);
|
|
512
|
+
if (bodyLines > max) context.report({
|
|
513
|
+
loc: {
|
|
514
|
+
start: {
|
|
515
|
+
line: bodyStart + 1,
|
|
516
|
+
column: 1
|
|
517
|
+
},
|
|
518
|
+
end: {
|
|
519
|
+
line: totalLines,
|
|
520
|
+
column: 1
|
|
521
|
+
}
|
|
522
|
+
},
|
|
523
|
+
messageId: "tooLong",
|
|
524
|
+
data: {
|
|
525
|
+
lines: bodyLines,
|
|
526
|
+
max
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
} };
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
//#endregion
|
|
533
|
+
//#region src/utils/links.ts
|
|
534
|
+
/**
|
|
535
|
+
* Whether a URL points at a repo-relative path (not an anchor, scheme, or
|
|
536
|
+
* protocol-relative URL).
|
|
537
|
+
*/
|
|
538
|
+
function isRelativeUrl(url) {
|
|
539
|
+
if (!url || url.startsWith("#")) return false;
|
|
540
|
+
if (url.startsWith("//") || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) return false;
|
|
541
|
+
return true;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Strip a URL's fragment and query so it can be resolved to a filesystem path.
|
|
545
|
+
*/
|
|
546
|
+
function urlToPath(url) {
|
|
547
|
+
let path = url;
|
|
548
|
+
const hash = path.indexOf("#");
|
|
549
|
+
if (hash !== -1) path = path.slice(0, hash);
|
|
550
|
+
const query = path.indexOf("?");
|
|
551
|
+
if (query !== -1) path = path.slice(0, query);
|
|
552
|
+
try {
|
|
553
|
+
return decodeURIComponent(path);
|
|
554
|
+
} catch {
|
|
555
|
+
return path;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* All link and image targets in the tree, in document order.
|
|
560
|
+
*/
|
|
561
|
+
function collectLinkTargets(root) {
|
|
562
|
+
return [...collect(root, "link").map((node) => ({
|
|
563
|
+
url: node.url,
|
|
564
|
+
kind: "link",
|
|
565
|
+
node
|
|
566
|
+
})), ...collect(root, "image").map((node) => ({
|
|
567
|
+
url: node.url,
|
|
568
|
+
kind: "image",
|
|
569
|
+
node
|
|
570
|
+
}))];
|
|
571
|
+
}
|
|
572
|
+
//#endregion
|
|
573
|
+
//#region src/utils/skill-fs.ts
|
|
574
|
+
/**
|
|
575
|
+
* The file access seam of the rules that look past the linted file
|
|
576
|
+
* (`no-broken-relative-links`, `no-orphan-references`, the `kind` rules).
|
|
577
|
+
*
|
|
578
|
+
* By default they read the disk. A host that keeps its skills somewhere else
|
|
579
|
+
* (a database, a remote store) passes every file of the linted skills in the
|
|
580
|
+
* shared setting `settings.skill.files` — a map from path to content — and
|
|
581
|
+
* lints them in memory with ESLint's `Linter`. The paths are resolved like
|
|
582
|
+
* the linted `filename`, so `/skills/demo/SKILL.md` and
|
|
583
|
+
* `/skills/demo/references/a.md` are siblings exactly as on disk. A flat
|
|
584
|
+
* config matches only files under the `Linter`'s `cwd`, so pass the virtual
|
|
585
|
+
* root as `cwd` (`new Linter({ cwd: "/skills" })`).
|
|
586
|
+
*/
|
|
587
|
+
function diskMarkdownFilesUnder(dir) {
|
|
588
|
+
if (!existsSync(dir)) return [];
|
|
589
|
+
const out = [];
|
|
590
|
+
const walk = (current) => {
|
|
591
|
+
let entries;
|
|
592
|
+
try {
|
|
593
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
594
|
+
} catch {
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
for (const entry of entries) {
|
|
598
|
+
const full = join(current, entry.name);
|
|
599
|
+
if (entry.isDirectory()) walk(full);
|
|
600
|
+
else if (entry.isFile() && entry.name.endsWith(".md")) out.push(full);
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
walk(dir);
|
|
604
|
+
return out;
|
|
605
|
+
}
|
|
606
|
+
var diskFs = {
|
|
607
|
+
exists: existsSync,
|
|
608
|
+
markdownFilesUnder: diskMarkdownFilesUnder
|
|
609
|
+
};
|
|
610
|
+
/** A file system over an in-memory map of path → content. */
|
|
611
|
+
function memoryFs(files) {
|
|
612
|
+
const paths = Object.keys(files).map((path) => resolve(path));
|
|
613
|
+
const under = (dir) => {
|
|
614
|
+
const prefix = resolve(dir) + sep;
|
|
615
|
+
return paths.filter((path) => path.startsWith(prefix));
|
|
616
|
+
};
|
|
617
|
+
return {
|
|
618
|
+
exists: (path) => {
|
|
619
|
+
const target = resolve(path);
|
|
620
|
+
return paths.includes(target) || under(target).length > 0;
|
|
621
|
+
},
|
|
622
|
+
markdownFilesUnder: (dir) => under(dir).filter((path) => path.endsWith(".md"))
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
/** The file system a rule must use, chosen from the shared settings. */
|
|
626
|
+
function skillFs(settings) {
|
|
627
|
+
const files = (settings?.skill)?.files;
|
|
628
|
+
return files ? memoryFs(files) : diskFs;
|
|
629
|
+
}
|
|
630
|
+
//#endregion
|
|
631
|
+
//#region src/rules/no-broken-relative-links.ts
|
|
632
|
+
/**
|
|
633
|
+
* @fileoverview Flag relative links/images whose target does not exist.
|
|
634
|
+
*
|
|
635
|
+
* Skills lean on referenced files for progressive disclosure; a dead relative
|
|
636
|
+
* link means the agent will try to open something that isn't there. Absolute
|
|
637
|
+
* URLs (`https:`, `mailto:`) and pure anchors (`#section`) are ignored.
|
|
638
|
+
*/
|
|
639
|
+
var rule$10 = {
|
|
640
|
+
meta: {
|
|
641
|
+
type: "problem",
|
|
642
|
+
docs: {
|
|
643
|
+
description: "Flag relative links/images whose target does not exist",
|
|
644
|
+
recommended: true,
|
|
645
|
+
url: "https://github.com/weareikko/motto/blob/main/packages/eslint-plugin-skill/docs/rules/no-broken-relative-links.md"
|
|
646
|
+
},
|
|
647
|
+
schema: [],
|
|
648
|
+
messages: { broken: "Relative {{kind}} target `{{url}}` does not exist relative to this skill." }
|
|
649
|
+
},
|
|
650
|
+
create(context) {
|
|
651
|
+
if (!isSkillFile(context.filename)) return {};
|
|
652
|
+
const baseDir = dirname(context.filename);
|
|
653
|
+
if (!baseDir || baseDir === "." || context.filename === "<input>") return {};
|
|
654
|
+
const fs = skillFs(context.settings);
|
|
655
|
+
return { root(root) {
|
|
656
|
+
for (const { node, kind, url } of collectLinkTargets(root)) {
|
|
657
|
+
if (!isRelativeUrl(url) || !node.position) continue;
|
|
658
|
+
const target = resolve(baseDir, urlToPath(url));
|
|
659
|
+
if (!fs.exists(target)) context.report({
|
|
660
|
+
loc: node.position,
|
|
661
|
+
messageId: "broken",
|
|
662
|
+
data: {
|
|
663
|
+
kind,
|
|
664
|
+
url
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
} };
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
//#endregion
|
|
672
|
+
//#region src/rules/no-unknown-frontmatter-keys.ts
|
|
673
|
+
var KNOWN_KEYS = /* @__PURE__ */ new Set([
|
|
674
|
+
"name",
|
|
675
|
+
"description",
|
|
676
|
+
"license",
|
|
677
|
+
"compatibility",
|
|
678
|
+
"metadata",
|
|
679
|
+
"allowed-tools",
|
|
680
|
+
"when_to_use",
|
|
681
|
+
"disable-model-invocation",
|
|
682
|
+
"argument-hint",
|
|
683
|
+
"model",
|
|
684
|
+
"context"
|
|
685
|
+
]);
|
|
686
|
+
var rule$9 = {
|
|
687
|
+
meta: {
|
|
688
|
+
type: "problem",
|
|
689
|
+
docs: {
|
|
690
|
+
description: "Flag unknown top-level frontmatter keys on a skill",
|
|
691
|
+
recommended: true,
|
|
692
|
+
url: "https://github.com/weareikko/motto/blob/main/packages/eslint-plugin-skill/docs/rules/no-unknown-frontmatter-keys.md"
|
|
693
|
+
},
|
|
694
|
+
schema: [{
|
|
695
|
+
type: "object",
|
|
696
|
+
properties: { allow: {
|
|
697
|
+
type: "array",
|
|
698
|
+
items: { type: "string" }
|
|
699
|
+
} },
|
|
700
|
+
additionalProperties: false
|
|
701
|
+
}],
|
|
702
|
+
messages: { unknownKey: "Unknown frontmatter key `{{key}}` — likely a typo or an ignored field." }
|
|
703
|
+
},
|
|
704
|
+
create(context) {
|
|
705
|
+
if (!isSkillFile(context.filename)) return {};
|
|
706
|
+
const allow = new Set(context.options[0]?.allow ?? []);
|
|
707
|
+
return { root(root) {
|
|
708
|
+
const node = findFrontmatter(root);
|
|
709
|
+
if (!node) return;
|
|
710
|
+
const { data, keyLoc } = parseFrontmatter(node);
|
|
711
|
+
if (!data) return;
|
|
712
|
+
for (const key of Object.keys(data)) {
|
|
713
|
+
if (KNOWN_KEYS.has(key) || allow.has(key)) continue;
|
|
714
|
+
context.report({
|
|
715
|
+
loc: keyLoc(key),
|
|
716
|
+
messageId: "unknownKey",
|
|
717
|
+
data: { key }
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
} };
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
//#endregion
|
|
724
|
+
//#region src/rules/refs-one-level-deep.ts
|
|
725
|
+
var rule$8 = {
|
|
726
|
+
meta: {
|
|
727
|
+
type: "problem",
|
|
728
|
+
docs: {
|
|
729
|
+
description: "Require reference links to stay one level deep from SKILL.md",
|
|
730
|
+
recommended: true,
|
|
731
|
+
url: "https://github.com/weareikko/motto/blob/main/packages/eslint-plugin-skill/docs/rules/refs-one-level-deep.md"
|
|
732
|
+
},
|
|
733
|
+
schema: [],
|
|
734
|
+
messages: {
|
|
735
|
+
tooDeep: "Reference `{{url}}` is more than one directory deep; keep reference files one level below SKILL.md.",
|
|
736
|
+
escapes: "Reference `{{url}}` points outside the skill directory; keep reference files one level below SKILL.md."
|
|
737
|
+
}
|
|
738
|
+
},
|
|
739
|
+
create(context) {
|
|
740
|
+
if (!isSkillFile(context.filename)) return {};
|
|
741
|
+
return { root(root) {
|
|
742
|
+
for (const { node, url } of collectLinkTargets(root)) {
|
|
743
|
+
if (!isRelativeUrl(url) || !node.position) continue;
|
|
744
|
+
const dirs = urlToPath(url).split("/").filter((s) => s && s !== ".").slice(0, -1);
|
|
745
|
+
if (dirs.includes("..")) context.report({
|
|
746
|
+
loc: node.position,
|
|
747
|
+
messageId: "escapes",
|
|
748
|
+
data: { url }
|
|
749
|
+
});
|
|
750
|
+
else if (dirs.length > 1) context.report({
|
|
751
|
+
loc: node.position,
|
|
752
|
+
messageId: "tooDeep",
|
|
753
|
+
data: { url }
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
} };
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
//#endregion
|
|
760
|
+
//#region src/utils/references.ts
|
|
761
|
+
/**
|
|
762
|
+
* Shared helpers for a skill's `references/` directory.
|
|
763
|
+
*/
|
|
764
|
+
/** Every `.md` file under a skill's `references/` directory, recursively. */
|
|
765
|
+
function referenceMarkdownFiles(skillDir, fs) {
|
|
766
|
+
return fs.markdownFilesUnder(join(skillDir, "references"));
|
|
767
|
+
}
|
|
768
|
+
//#endregion
|
|
769
|
+
//#region src/rules/no-orphan-references.ts
|
|
770
|
+
/**
|
|
771
|
+
* @fileoverview Flag reference files not linked from SKILL.md.
|
|
772
|
+
*
|
|
773
|
+
* A `.md` file sitting in `references/` that nothing in SKILL.md links to is
|
|
774
|
+
* dead weight: Claude is told to load reference files via the pointers in
|
|
775
|
+
* SKILL.md, so an unlinked one is never read. It's usually a leftover, a
|
|
776
|
+
* rename that broke the link, or a missing pointer.
|
|
777
|
+
*/
|
|
778
|
+
var TOP$4 = {
|
|
779
|
+
start: {
|
|
780
|
+
line: 1,
|
|
781
|
+
column: 1
|
|
782
|
+
},
|
|
783
|
+
end: {
|
|
784
|
+
line: 1,
|
|
785
|
+
column: 1
|
|
786
|
+
}
|
|
787
|
+
};
|
|
788
|
+
var rule$7 = {
|
|
789
|
+
meta: {
|
|
790
|
+
type: "suggestion",
|
|
791
|
+
docs: {
|
|
792
|
+
description: "Flag reference files not linked from SKILL.md",
|
|
793
|
+
recommended: true,
|
|
794
|
+
url: "https://github.com/weareikko/motto/blob/main/packages/eslint-plugin-skill/docs/rules/no-orphan-references.md"
|
|
795
|
+
},
|
|
796
|
+
schema: [],
|
|
797
|
+
messages: { orphan: "Reference file `{{file}}` is not linked from SKILL.md and will never be read." }
|
|
798
|
+
},
|
|
799
|
+
create(context) {
|
|
800
|
+
if (!isSkillFile(context.filename)) return {};
|
|
801
|
+
const skillDir = dirname(context.filename);
|
|
802
|
+
const files = referenceMarkdownFiles(skillDir, skillFs(context.settings));
|
|
803
|
+
if (!skillDir || skillDir === "." || context.filename === "<input>" || files.length === 0) return {};
|
|
804
|
+
return { root(root) {
|
|
805
|
+
const linked = new Set(collectLinkTargets(root).filter(({ url }) => isRelativeUrl(url)).map(({ url }) => resolve(skillDir, urlToPath(url))));
|
|
806
|
+
for (const file of files) if (!linked.has(file)) context.report({
|
|
807
|
+
loc: TOP$4,
|
|
808
|
+
messageId: "orphan",
|
|
809
|
+
data: { file: relative(skillDir, file) }
|
|
810
|
+
});
|
|
811
|
+
} };
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
//#endregion
|
|
815
|
+
//#region src/rules/descriptive-reference-filenames.ts
|
|
816
|
+
var GENERIC = /^(docs?|references?|files?|pages?|untitled|temp|tmp|new|copy|notes?|documents?)[-_ ]?\d*$/i;
|
|
817
|
+
var NUMERIC = /^\d+$/;
|
|
818
|
+
var rule$6 = {
|
|
819
|
+
meta: {
|
|
820
|
+
type: "suggestion",
|
|
821
|
+
docs: {
|
|
822
|
+
description: "Require reference files to have descriptive names",
|
|
823
|
+
recommended: true,
|
|
824
|
+
url: "https://github.com/weareikko/motto/blob/main/packages/eslint-plugin-skill/docs/rules/descriptive-reference-filenames.md"
|
|
825
|
+
},
|
|
826
|
+
schema: [],
|
|
827
|
+
messages: { generic: "Reference file `{{name}}` has a generic name; use a descriptive one (e.g. `form_validation_rules.md`)." }
|
|
828
|
+
},
|
|
829
|
+
create(context) {
|
|
830
|
+
if (!isReferenceFile(context.filename)) return {};
|
|
831
|
+
const name = basename$1(context.filename);
|
|
832
|
+
const stem = name.replace(/\.md$/i, "");
|
|
833
|
+
return { root() {
|
|
834
|
+
if (GENERIC.test(stem) || NUMERIC.test(stem)) context.report({
|
|
835
|
+
loc: {
|
|
836
|
+
start: {
|
|
837
|
+
line: 1,
|
|
838
|
+
column: 1
|
|
839
|
+
},
|
|
840
|
+
end: {
|
|
841
|
+
line: 1,
|
|
842
|
+
column: 1
|
|
843
|
+
}
|
|
844
|
+
},
|
|
845
|
+
messageId: "generic",
|
|
846
|
+
data: { name }
|
|
847
|
+
});
|
|
848
|
+
} };
|
|
849
|
+
}
|
|
850
|
+
};
|
|
851
|
+
//#endregion
|
|
852
|
+
//#region src/rules/large-reference-toc.ts
|
|
853
|
+
var DEFAULT_MAX_LINES = 300;
|
|
854
|
+
var TOC_HEADING = /^(table of contents|contents|toc)$/i;
|
|
855
|
+
var rule$5 = {
|
|
856
|
+
meta: {
|
|
857
|
+
type: "suggestion",
|
|
858
|
+
docs: {
|
|
859
|
+
description: "Require large reference files to include a table of contents",
|
|
860
|
+
recommended: true,
|
|
861
|
+
url: "https://github.com/weareikko/motto/blob/main/packages/eslint-plugin-skill/docs/rules/large-reference-toc.md"
|
|
862
|
+
},
|
|
863
|
+
schema: [{
|
|
864
|
+
type: "object",
|
|
865
|
+
properties: { maxLines: {
|
|
866
|
+
type: "integer",
|
|
867
|
+
minimum: 1
|
|
868
|
+
} },
|
|
869
|
+
additionalProperties: false
|
|
870
|
+
}],
|
|
871
|
+
messages: { missingToc: "Reference file is {{lines}} lines; add a table of contents (over {{max}} lines)." }
|
|
872
|
+
},
|
|
873
|
+
create(context) {
|
|
874
|
+
if (!isReferenceFile(context.filename)) return {};
|
|
875
|
+
const max = context.options[0]?.maxLines ?? DEFAULT_MAX_LINES;
|
|
876
|
+
return { root(root) {
|
|
877
|
+
const lines = root.position?.end?.line ?? 0;
|
|
878
|
+
if (lines <= max) return;
|
|
879
|
+
const hasTocHeading = collect(root, "heading").some((h) => TOC_HEADING.test(textContent(h).trim()));
|
|
880
|
+
const hasAnchorToc = collect(root, "link").filter((n) => n.url.startsWith("#")).length >= 3;
|
|
881
|
+
if (!hasTocHeading && !hasAnchorToc) context.report({
|
|
882
|
+
loc: {
|
|
883
|
+
start: {
|
|
884
|
+
line: 1,
|
|
885
|
+
column: 1
|
|
886
|
+
},
|
|
887
|
+
end: {
|
|
888
|
+
line: 1,
|
|
889
|
+
column: 1
|
|
890
|
+
}
|
|
891
|
+
},
|
|
892
|
+
messageId: "missingToc",
|
|
893
|
+
data: {
|
|
894
|
+
lines,
|
|
895
|
+
max
|
|
896
|
+
}
|
|
897
|
+
});
|
|
898
|
+
} };
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
//#endregion
|
|
902
|
+
//#region src/rules/valid-allowed-tools.ts
|
|
903
|
+
var rule$4 = {
|
|
904
|
+
meta: {
|
|
905
|
+
type: "problem",
|
|
906
|
+
docs: {
|
|
907
|
+
description: "Validate the shape of the optional `allowed-tools` frontmatter field",
|
|
908
|
+
recommended: true,
|
|
909
|
+
url: "https://github.com/weareikko/motto/blob/main/packages/eslint-plugin-skill/docs/rules/valid-allowed-tools.md"
|
|
910
|
+
},
|
|
911
|
+
schema: [],
|
|
912
|
+
messages: {
|
|
913
|
+
notArray: "`allowed-tools` must be an array of tool names.",
|
|
914
|
+
empty: "`allowed-tools` must not be empty; omit the field to allow all tools.",
|
|
915
|
+
invalidEntry: "`allowed-tools` entries must be non-empty strings."
|
|
916
|
+
}
|
|
917
|
+
},
|
|
918
|
+
create(context) {
|
|
919
|
+
if (!isSkillFile(context.filename)) return {};
|
|
920
|
+
return { root(root) {
|
|
921
|
+
const node = findFrontmatter(root);
|
|
922
|
+
if (!node) return;
|
|
923
|
+
const { data, keyLoc } = parseFrontmatter(node);
|
|
924
|
+
if (!data || !("allowed-tools" in data)) return;
|
|
925
|
+
const value = data["allowed-tools"];
|
|
926
|
+
const loc = keyLoc("allowed-tools");
|
|
927
|
+
if (!Array.isArray(value)) {
|
|
928
|
+
context.report({
|
|
929
|
+
loc,
|
|
930
|
+
messageId: "notArray"
|
|
931
|
+
});
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (value.length === 0) {
|
|
935
|
+
context.report({
|
|
936
|
+
loc,
|
|
937
|
+
messageId: "empty"
|
|
938
|
+
});
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
if (value.some((v) => typeof v !== "string" || v.trim() === "")) context.report({
|
|
942
|
+
loc,
|
|
943
|
+
messageId: "invalidEntry"
|
|
944
|
+
});
|
|
945
|
+
} };
|
|
946
|
+
}
|
|
947
|
+
};
|
|
948
|
+
//#endregion
|
|
949
|
+
//#region src/utils/kind.ts
|
|
950
|
+
/**
|
|
951
|
+
* Skill KIND: a taxonomy for applying different lint expectations to
|
|
952
|
+
* different shapes of skill.
|
|
953
|
+
*
|
|
954
|
+
* Grounded in official Claude Code guidance, which distinguishes "Reference
|
|
955
|
+
* content" (inline domain knowledge) from "Task content" (step-by-step
|
|
956
|
+
* actions, often gated with `disable-model-invocation`), plus the
|
|
957
|
+
* `skill-creator` meta-skill's multi-domain/variant pattern
|
|
958
|
+
* (`references/{aws,gcp,azure}.md`). `tool-wrapper` and `meta` are defensible
|
|
959
|
+
* extensions, not explicitly named in any single source — see
|
|
960
|
+
* docs/design/skill-taxonomy-and-rules.md.
|
|
961
|
+
*
|
|
962
|
+
* A skill may declare its kind explicitly via `metadata.kind` in frontmatter;
|
|
963
|
+
* this is the only signal the lint rules act on, since heuristic detection is
|
|
964
|
+
* inherently fuzzy and a wrong guess should never cause a false positive.
|
|
965
|
+
* `detectKind` is exposed separately as an informative, best-effort signal —
|
|
966
|
+
* it never gates a rule.
|
|
967
|
+
*/
|
|
968
|
+
var SKILL_KINDS = [
|
|
969
|
+
"procedural",
|
|
970
|
+
"reference",
|
|
971
|
+
"multi-domain",
|
|
972
|
+
"tool-wrapper",
|
|
973
|
+
"meta"
|
|
974
|
+
];
|
|
975
|
+
function isSkillKind(value) {
|
|
976
|
+
return typeof value === "string" && SKILL_KINDS.includes(value);
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* The skill's explicitly declared kind, via `metadata.kind` in frontmatter.
|
|
980
|
+
* Returns `undefined` when absent, malformed, or not a recognized kind.
|
|
981
|
+
*/
|
|
982
|
+
function declaredKind(data) {
|
|
983
|
+
const metadata = data?.metadata;
|
|
984
|
+
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return;
|
|
985
|
+
const kind = metadata.kind;
|
|
986
|
+
return isSkillKind(kind) ? kind : void 0;
|
|
987
|
+
}
|
|
988
|
+
//#endregion
|
|
989
|
+
//#region src/rules/procedural-has-steps.ts
|
|
990
|
+
var TOP$3 = {
|
|
991
|
+
start: {
|
|
992
|
+
line: 1,
|
|
993
|
+
column: 1
|
|
994
|
+
},
|
|
995
|
+
end: {
|
|
996
|
+
line: 1,
|
|
997
|
+
column: 1
|
|
998
|
+
}
|
|
999
|
+
};
|
|
1000
|
+
var rule$3 = {
|
|
1001
|
+
meta: {
|
|
1002
|
+
type: "suggestion",
|
|
1003
|
+
docs: {
|
|
1004
|
+
description: "Expect a procedural skill's body to contain ordered steps",
|
|
1005
|
+
recommended: false,
|
|
1006
|
+
url: "https://github.com/weareikko/motto/blob/main/packages/eslint-plugin-skill/docs/rules/procedural-has-steps.md"
|
|
1007
|
+
},
|
|
1008
|
+
schema: [],
|
|
1009
|
+
messages: { noSteps: "Skill is declared `metadata.kind: procedural` but its body has no ordered/numbered steps." }
|
|
1010
|
+
},
|
|
1011
|
+
create(context) {
|
|
1012
|
+
if (!isSkillFile(context.filename)) return {};
|
|
1013
|
+
return { root(root) {
|
|
1014
|
+
const node = findFrontmatter(root);
|
|
1015
|
+
if (!node) return;
|
|
1016
|
+
const { data } = parseFrontmatter(node);
|
|
1017
|
+
if (declaredKind(data) !== "procedural") return;
|
|
1018
|
+
if (!collect(root, "list").some((list) => list.ordered)) context.report({
|
|
1019
|
+
loc: TOP$3,
|
|
1020
|
+
messageId: "noSteps"
|
|
1021
|
+
});
|
|
1022
|
+
} };
|
|
1023
|
+
}
|
|
1024
|
+
};
|
|
1025
|
+
//#endregion
|
|
1026
|
+
//#region src/rules/reference-has-content.ts
|
|
1027
|
+
/**
|
|
1028
|
+
* @fileoverview Expect a reference skill to carry at least one reference file.
|
|
1029
|
+
*
|
|
1030
|
+
* Opt-in: only fires when the skill explicitly declares itself as
|
|
1031
|
+
* `metadata.kind: reference`. A reference (domain-knowledge) skill exists to
|
|
1032
|
+
* supply background material on demand; a declared reference skill with an
|
|
1033
|
+
* empty or missing `references/` directory has nothing to supply.
|
|
1034
|
+
*/
|
|
1035
|
+
var TOP$2 = {
|
|
1036
|
+
start: {
|
|
1037
|
+
line: 1,
|
|
1038
|
+
column: 1
|
|
1039
|
+
},
|
|
1040
|
+
end: {
|
|
1041
|
+
line: 1,
|
|
1042
|
+
column: 1
|
|
1043
|
+
}
|
|
1044
|
+
};
|
|
1045
|
+
var rule$2 = {
|
|
1046
|
+
meta: {
|
|
1047
|
+
type: "suggestion",
|
|
1048
|
+
docs: {
|
|
1049
|
+
description: "Expect a reference skill to carry at least one reference file",
|
|
1050
|
+
recommended: false,
|
|
1051
|
+
url: "https://github.com/weareikko/motto/blob/main/packages/eslint-plugin-skill/docs/rules/reference-has-content.md"
|
|
1052
|
+
},
|
|
1053
|
+
schema: [],
|
|
1054
|
+
messages: { noReferences: "Skill is declared `metadata.kind: reference` but its `references/` directory is empty or missing." }
|
|
1055
|
+
},
|
|
1056
|
+
create(context) {
|
|
1057
|
+
if (!isSkillFile(context.filename)) return {};
|
|
1058
|
+
const skillDir = dirname(context.filename);
|
|
1059
|
+
if (!skillDir || skillDir === "." || context.filename === "<input>") return {};
|
|
1060
|
+
return { root(root) {
|
|
1061
|
+
const node = findFrontmatter(root);
|
|
1062
|
+
if (!node) return;
|
|
1063
|
+
const { data } = parseFrontmatter(node);
|
|
1064
|
+
if (declaredKind(data) !== "reference") return;
|
|
1065
|
+
if (referenceMarkdownFiles(skillDir, skillFs(context.settings)).length === 0) context.report({
|
|
1066
|
+
loc: TOP$2,
|
|
1067
|
+
messageId: "noReferences"
|
|
1068
|
+
});
|
|
1069
|
+
} };
|
|
1070
|
+
}
|
|
1071
|
+
};
|
|
1072
|
+
//#endregion
|
|
1073
|
+
//#region src/rules/multi-domain-has-variants.ts
|
|
1074
|
+
/**
|
|
1075
|
+
* @fileoverview Expect a multi-domain skill to carry at least two variant references.
|
|
1076
|
+
*
|
|
1077
|
+
* Opt-in: only fires when the skill explicitly declares itself as
|
|
1078
|
+
* `metadata.kind: multi-domain`. The multi-domain pattern (e.g.
|
|
1079
|
+
* `references/{aws,gcp,azure}.md`, with `SKILL.md` holding the selection
|
|
1080
|
+
* logic) only earns its complexity when there is more than one variant to
|
|
1081
|
+
* select between.
|
|
1082
|
+
*/
|
|
1083
|
+
var TOP$1 = {
|
|
1084
|
+
start: {
|
|
1085
|
+
line: 1,
|
|
1086
|
+
column: 1
|
|
1087
|
+
},
|
|
1088
|
+
end: {
|
|
1089
|
+
line: 1,
|
|
1090
|
+
column: 1
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
var MIN_VARIANTS = 2;
|
|
1094
|
+
var rule$1 = {
|
|
1095
|
+
meta: {
|
|
1096
|
+
type: "suggestion",
|
|
1097
|
+
docs: {
|
|
1098
|
+
description: "Expect a multi-domain skill to carry at least two variant references",
|
|
1099
|
+
recommended: false,
|
|
1100
|
+
url: "https://github.com/weareikko/motto/blob/main/packages/eslint-plugin-skill/docs/rules/multi-domain-has-variants.md"
|
|
1101
|
+
},
|
|
1102
|
+
schema: [],
|
|
1103
|
+
messages: { tooFewVariants: "Skill is declared `metadata.kind: multi-domain` but has fewer than {{min}} reference files to select between." }
|
|
1104
|
+
},
|
|
1105
|
+
create(context) {
|
|
1106
|
+
if (!isSkillFile(context.filename)) return {};
|
|
1107
|
+
const skillDir = dirname(context.filename);
|
|
1108
|
+
if (!skillDir || skillDir === "." || context.filename === "<input>") return {};
|
|
1109
|
+
return { root(root) {
|
|
1110
|
+
const node = findFrontmatter(root);
|
|
1111
|
+
if (!node) return;
|
|
1112
|
+
const { data } = parseFrontmatter(node);
|
|
1113
|
+
if (declaredKind(data) !== "multi-domain") return;
|
|
1114
|
+
if (referenceMarkdownFiles(skillDir, skillFs(context.settings)).length < MIN_VARIANTS) context.report({
|
|
1115
|
+
loc: TOP$1,
|
|
1116
|
+
messageId: "tooFewVariants",
|
|
1117
|
+
data: { min: MIN_VARIANTS }
|
|
1118
|
+
});
|
|
1119
|
+
} };
|
|
1120
|
+
}
|
|
1121
|
+
};
|
|
1122
|
+
//#endregion
|
|
1123
|
+
//#region src/rules/meta-prefers-dispatch-table.ts
|
|
1124
|
+
var TOP = {
|
|
1125
|
+
start: {
|
|
1126
|
+
line: 1,
|
|
1127
|
+
column: 1
|
|
1128
|
+
},
|
|
1129
|
+
end: {
|
|
1130
|
+
line: 1,
|
|
1131
|
+
column: 1
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
//#endregion
|
|
1135
|
+
//#region src/index.ts
|
|
1136
|
+
/**
|
|
1137
|
+
* @weareikko/eslint-plugin-skill — lint Claude Code Agent Skills (`SKILL.md`).
|
|
1138
|
+
*
|
|
1139
|
+
* The plugin exposes a set of rules plus two ready-to-use flat configs. Each
|
|
1140
|
+
* config is self-contained: it targets `**/SKILL.md`, wires up the
|
|
1141
|
+
* `@eslint/markdown` language with YAML frontmatter, and turns on the rules.
|
|
1142
|
+
*/
|
|
1143
|
+
var plugin = {
|
|
1144
|
+
meta: {
|
|
1145
|
+
name: "@weareikko/eslint-plugin-skill",
|
|
1146
|
+
version: "0.1.1"
|
|
1147
|
+
},
|
|
1148
|
+
rules: {
|
|
1149
|
+
"require-frontmatter": rule$19,
|
|
1150
|
+
"name-kebab-case": rule$18,
|
|
1151
|
+
"name-matches-dir": rule$17,
|
|
1152
|
+
"description-length": rule$16,
|
|
1153
|
+
"description-has-trigger": rule$15,
|
|
1154
|
+
"description-no-html": rule$14,
|
|
1155
|
+
"no-multiple-h1": rule$13,
|
|
1156
|
+
"h1-matches-name": rule$12,
|
|
1157
|
+
"body-max-length": rule$11,
|
|
1158
|
+
"no-broken-relative-links": rule$10,
|
|
1159
|
+
"no-unknown-frontmatter-keys": rule$9,
|
|
1160
|
+
"refs-one-level-deep": rule$8,
|
|
1161
|
+
"no-orphan-references": rule$7,
|
|
1162
|
+
"descriptive-reference-filenames": rule$6,
|
|
1163
|
+
"large-reference-toc": rule$5,
|
|
1164
|
+
"valid-allowed-tools": rule$4,
|
|
1165
|
+
"procedural-has-steps": rule$3,
|
|
1166
|
+
"reference-has-content": rule$2,
|
|
1167
|
+
"multi-domain-has-variants": rule$1,
|
|
1168
|
+
"meta-prefers-dispatch-table": {
|
|
1169
|
+
meta: {
|
|
1170
|
+
type: "suggestion",
|
|
1171
|
+
docs: {
|
|
1172
|
+
description: "Suggest a markdown table for a meta/router skill's dispatch list",
|
|
1173
|
+
recommended: false,
|
|
1174
|
+
url: "https://github.com/weareikko/motto/blob/main/packages/eslint-plugin-skill/docs/rules/meta-prefers-dispatch-table.md"
|
|
1175
|
+
},
|
|
1176
|
+
schema: [],
|
|
1177
|
+
messages: { preferTable: "Skill is declared `metadata.kind: meta` with no markdown table; consider a table for the dispatch list — measured ~21% cheaper than prose with no accuracy difference (see docs/research/meta-dispatch-table-vs-prose.md)." }
|
|
1178
|
+
},
|
|
1179
|
+
create(context) {
|
|
1180
|
+
if (!isSkillFile(context.filename)) return {};
|
|
1181
|
+
return { root(root) {
|
|
1182
|
+
const node = findFrontmatter(root);
|
|
1183
|
+
if (!node) return;
|
|
1184
|
+
const { data } = parseFrontmatter(node);
|
|
1185
|
+
if (declaredKind(data) !== "meta") return;
|
|
1186
|
+
if (collect(root, "table").length === 0) context.report({
|
|
1187
|
+
loc: TOP,
|
|
1188
|
+
messageId: "preferTable"
|
|
1189
|
+
});
|
|
1190
|
+
} };
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
},
|
|
1194
|
+
configs: {}
|
|
1195
|
+
};
|
|
1196
|
+
/**
|
|
1197
|
+
* Rule severities for the `recommended` preset. `warn` marks quality signals
|
|
1198
|
+
* that shouldn't necessarily fail CI; `strict` promotes them to `error`.
|
|
1199
|
+
*/
|
|
1200
|
+
var RECOMMENDED_RULES = {
|
|
1201
|
+
"skill/require-frontmatter": "error",
|
|
1202
|
+
"skill/name-kebab-case": "error",
|
|
1203
|
+
"skill/name-matches-dir": "error",
|
|
1204
|
+
"skill/description-length": "error",
|
|
1205
|
+
"skill/description-no-html": "error",
|
|
1206
|
+
"skill/no-multiple-h1": "error",
|
|
1207
|
+
"skill/no-broken-relative-links": "error",
|
|
1208
|
+
"skill/no-unknown-frontmatter-keys": "error",
|
|
1209
|
+
"skill/refs-one-level-deep": "error",
|
|
1210
|
+
"skill/valid-allowed-tools": "error",
|
|
1211
|
+
"skill/description-has-trigger": "warn",
|
|
1212
|
+
"skill/body-max-length": "warn",
|
|
1213
|
+
"skill/no-orphan-references": "warn",
|
|
1214
|
+
"skill/descriptive-reference-filenames": "warn",
|
|
1215
|
+
"skill/large-reference-toc": "warn"
|
|
1216
|
+
};
|
|
1217
|
+
var STRICT_RULES = Object.fromEntries(Object.keys(RECOMMENDED_RULES).map((id) => [id, "error"]));
|
|
1218
|
+
function makeConfig(name, rules) {
|
|
1219
|
+
return {
|
|
1220
|
+
name,
|
|
1221
|
+
files: ["**/SKILL.md", "**/references/**/*.md"],
|
|
1222
|
+
plugins: {
|
|
1223
|
+
markdown,
|
|
1224
|
+
skill: plugin
|
|
1225
|
+
},
|
|
1226
|
+
language: "markdown/gfm",
|
|
1227
|
+
languageOptions: { frontmatter: "yaml" },
|
|
1228
|
+
rules
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
plugin.configs.recommended = makeConfig("skill/recommended", RECOMMENDED_RULES);
|
|
1232
|
+
plugin.configs.strict = makeConfig("skill/strict", STRICT_RULES);
|
|
1233
|
+
//#endregion
|
|
1234
|
+
export { plugin as default };
|
|
1235
|
+
|
|
1236
|
+
//# sourceMappingURL=index.js.map
|