@weareikko/eslint-plugin-skill 0.0.0-stage → 0.1.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.
Files changed (105) hide show
  1. package/README.md +164 -2
  2. package/configs/check-refs.workflow.yml +25 -0
  3. package/configs/eslint.config.example.js +23 -0
  4. package/configs/lint-skills.workflow.yml +26 -0
  5. package/dist/dedup/analyze.d.ts +26 -0
  6. package/dist/dedup/analyze.d.ts.map +1 -0
  7. package/dist/dedup/cli.d.ts +10 -0
  8. package/dist/dedup/cli.d.ts.map +1 -0
  9. package/dist/dedup/cli.js +37 -0
  10. package/dist/dedup/cli.js.map +1 -0
  11. package/dist/dedup/discover.d.ts +16 -0
  12. package/dist/dedup/discover.d.ts.map +1 -0
  13. package/dist/dedup/hash.d.ts +17 -0
  14. package/dist/dedup/hash.d.ts.map +1 -0
  15. package/dist/dedup/index.d.ts +4 -0
  16. package/dist/dedup/index.d.ts.map +1 -0
  17. package/dist/dedup/index.js +2 -0
  18. package/dist/dedup/report.d.ts +6 -0
  19. package/dist/dedup/report.d.ts.map +1 -0
  20. package/dist/dedup/types.d.ts +92 -0
  21. package/dist/dedup/types.d.ts.map +1 -0
  22. package/dist/index.d.ts +109 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +1236 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/report-ClPKWYJy.js +266 -0
  27. package/dist/report-ClPKWYJy.js.map +1 -0
  28. package/dist/rules/body-max-length.d.ts +16 -0
  29. package/dist/rules/body-max-length.d.ts.map +1 -0
  30. package/dist/rules/description-has-trigger.d.ts +16 -0
  31. package/dist/rules/description-has-trigger.d.ts.map +1 -0
  32. package/dist/rules/description-length.d.ts +16 -0
  33. package/dist/rules/description-length.d.ts.map +1 -0
  34. package/dist/rules/description-no-html.d.ts +13 -0
  35. package/dist/rules/description-no-html.d.ts.map +1 -0
  36. package/dist/rules/descriptive-reference-filenames.d.ts +15 -0
  37. package/dist/rules/descriptive-reference-filenames.d.ts.map +1 -0
  38. package/dist/rules/h1-matches-name.d.ts +13 -0
  39. package/dist/rules/h1-matches-name.d.ts.map +1 -0
  40. package/dist/rules/large-reference-toc.d.ts +16 -0
  41. package/dist/rules/large-reference-toc.d.ts.map +1 -0
  42. package/dist/rules/meta-prefers-dispatch-table.d.ts +19 -0
  43. package/dist/rules/meta-prefers-dispatch-table.d.ts.map +1 -0
  44. package/dist/rules/multi-domain-has-variants.d.ts +16 -0
  45. package/dist/rules/multi-domain-has-variants.d.ts.map +1 -0
  46. package/dist/rules/name-kebab-case.d.ts +10 -0
  47. package/dist/rules/name-kebab-case.d.ts.map +1 -0
  48. package/dist/rules/name-matches-dir.d.ts +10 -0
  49. package/dist/rules/name-matches-dir.d.ts.map +1 -0
  50. package/dist/rules/no-broken-relative-links.d.ts +14 -0
  51. package/dist/rules/no-broken-relative-links.d.ts.map +1 -0
  52. package/dist/rules/no-multiple-h1.d.ts +15 -0
  53. package/dist/rules/no-multiple-h1.d.ts.map +1 -0
  54. package/dist/rules/no-orphan-references.d.ts +15 -0
  55. package/dist/rules/no-orphan-references.d.ts.map +1 -0
  56. package/dist/rules/no-unknown-frontmatter-keys.d.ts +18 -0
  57. package/dist/rules/no-unknown-frontmatter-keys.d.ts.map +1 -0
  58. package/dist/rules/procedural-has-steps.d.ts +15 -0
  59. package/dist/rules/procedural-has-steps.d.ts.map +1 -0
  60. package/dist/rules/reference-has-content.d.ts +15 -0
  61. package/dist/rules/reference-has-content.d.ts.map +1 -0
  62. package/dist/rules/refs-one-level-deep.d.ts +15 -0
  63. package/dist/rules/refs-one-level-deep.d.ts.map +1 -0
  64. package/dist/rules/require-frontmatter.d.ts +11 -0
  65. package/dist/rules/require-frontmatter.d.ts.map +1 -0
  66. package/dist/rules/valid-allowed-tools.d.ts +15 -0
  67. package/dist/rules/valid-allowed-tools.d.ts.map +1 -0
  68. package/dist/types.d.ts +15 -0
  69. package/dist/types.d.ts.map +1 -0
  70. package/dist/utils/ast.d.ts +21 -0
  71. package/dist/utils/ast.d.ts.map +1 -0
  72. package/dist/utils/frontmatter.d.ts +24 -0
  73. package/dist/utils/frontmatter.d.ts.map +1 -0
  74. package/dist/utils/kind.d.ts +41 -0
  75. package/dist/utils/kind.d.ts.map +1 -0
  76. package/dist/utils/links.d.ts +23 -0
  77. package/dist/utils/links.d.ts.map +1 -0
  78. package/dist/utils/references.d.ts +7 -0
  79. package/dist/utils/references.d.ts.map +1 -0
  80. package/dist/utils/skill-file.d.ts +32 -0
  81. package/dist/utils/skill-file.d.ts.map +1 -0
  82. package/dist/utils/skill-fs.d.ts +30 -0
  83. package/dist/utils/skill-fs.d.ts.map +1 -0
  84. package/docs/check-refs.md +60 -0
  85. package/docs/rules/body-max-length.md +23 -0
  86. package/docs/rules/description-has-trigger.md +28 -0
  87. package/docs/rules/description-length.md +27 -0
  88. package/docs/rules/description-no-html.md +22 -0
  89. package/docs/rules/descriptive-reference-filenames.md +26 -0
  90. package/docs/rules/h1-matches-name.md +36 -0
  91. package/docs/rules/large-reference-toc.md +26 -0
  92. package/docs/rules/meta-prefers-dispatch-table.md +30 -0
  93. package/docs/rules/multi-domain-has-variants.md +26 -0
  94. package/docs/rules/name-kebab-case.md +31 -0
  95. package/docs/rules/name-matches-dir.md +29 -0
  96. package/docs/rules/no-broken-relative-links.md +25 -0
  97. package/docs/rules/no-multiple-h1.md +31 -0
  98. package/docs/rules/no-orphan-references.md +25 -0
  99. package/docs/rules/no-unknown-frontmatter-keys.md +37 -0
  100. package/docs/rules/procedural-has-steps.md +43 -0
  101. package/docs/rules/reference-has-content.md +26 -0
  102. package/docs/rules/refs-one-level-deep.md +26 -0
  103. package/docs/rules/require-frontmatter.md +34 -0
  104. package/docs/rules/valid-allowed-tools.md +27 -0
  105. package/package.json +65 -4
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.0"
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