@sagargupta1610/skillcheck 0.2.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.
@@ -0,0 +1,1180 @@
1
+ // src/evals/schema.ts
2
+ import { z } from "zod";
3
+ var triggerTypeSchema = z.enum([
4
+ "explicit",
5
+ "implicit",
6
+ "contextual",
7
+ "negative"
8
+ ]);
9
+ var triggerSchema = z.object({
10
+ id: z.string().min(1),
11
+ type: triggerTypeSchema,
12
+ prompt: z.string().min(1),
13
+ should_trigger: z.boolean().optional()
14
+ }).loose();
15
+ var evalCaseSchema = z.object({
16
+ id: z.number().int(),
17
+ prompt: z.string().min(1),
18
+ expected_output: z.string().optional(),
19
+ files: z.array(z.string()).optional(),
20
+ expectations: z.array(z.string().min(1)).min(1)
21
+ }).loose();
22
+ var evalsFileSchema = z.object({
23
+ skill_name: z.string().min(1),
24
+ settings: z.object({
25
+ runs_per_prompt: z.number().int().min(1).optional(),
26
+ trigger_threshold: z.number().min(0).max(1).optional()
27
+ }).loose().optional(),
28
+ evals: z.array(evalCaseSchema).optional(),
29
+ triggers: z.array(triggerSchema).optional()
30
+ }).loose();
31
+
32
+ // src/evals/index.ts
33
+ import { mkdir, readFile, stat, writeFile } from "fs/promises";
34
+ import { basename, join } from "path";
35
+ function finding(severity, message) {
36
+ return {
37
+ code: severity === "error" ? "SC401" : "SC402",
38
+ alias: severity === "error" ? "evals-invalid" : "evals-advisory",
39
+ severity,
40
+ message,
41
+ file: "evals/evals.json"
42
+ };
43
+ }
44
+ async function checkEvals(skillDir, skillName) {
45
+ const path = join(skillDir, "evals", "evals.json");
46
+ let raw;
47
+ try {
48
+ raw = await readFile(path, "utf8");
49
+ } catch {
50
+ return [];
51
+ }
52
+ let data;
53
+ try {
54
+ data = JSON.parse(raw);
55
+ } catch (err) {
56
+ return [
57
+ finding(
58
+ "error",
59
+ `evals.json is not valid JSON: ${err instanceof Error ? err.message : err}`
60
+ )
61
+ ];
62
+ }
63
+ const parsed = evalsFileSchema.safeParse(data);
64
+ if (!parsed.success) {
65
+ return parsed.error.issues.slice(0, 10).map(
66
+ (i) => finding(
67
+ "error",
68
+ `evals.json ${i.path.join(".") || "(root)"}: ${i.message}`
69
+ )
70
+ );
71
+ }
72
+ const out = [];
73
+ const evals = parsed.data;
74
+ if (skillName && evals.skill_name !== skillName) {
75
+ out.push(
76
+ finding(
77
+ "error",
78
+ `skill_name "${evals.skill_name}" does not match the skill's resolved name "${skillName}"`
79
+ )
80
+ );
81
+ }
82
+ const ids = (evals.evals ?? []).map((e) => e.id);
83
+ if (new Set(ids).size !== ids.length) {
84
+ out.push(finding("error", "evals[].id values must be unique"));
85
+ }
86
+ for (const e of evals.evals ?? []) {
87
+ for (const f of e.files ?? []) {
88
+ try {
89
+ await stat(join(skillDir, f));
90
+ } catch {
91
+ out.push(
92
+ finding(
93
+ "error",
94
+ `evals[${e.id}].files: "${f}" does not exist relative to the skill root`
95
+ )
96
+ );
97
+ }
98
+ }
99
+ }
100
+ const triggers = evals.triggers ?? [];
101
+ for (const t of triggers) {
102
+ const effective = t.should_trigger ?? t.type !== "negative";
103
+ if (t.type === "negative" && effective) {
104
+ out.push(
105
+ finding(
106
+ "error",
107
+ `triggers["${t.id}"]: type "negative" requires should_trigger: false`
108
+ )
109
+ );
110
+ }
111
+ }
112
+ const negatives = triggers.filter((t) => t.type === "negative").length;
113
+ if (triggers.length > 0 && negatives < 2) {
114
+ out.push(
115
+ finding(
116
+ "warning",
117
+ `only ${negatives} negative trigger(s) -- near-miss negatives are what catch over-triggering; aim for at least 2`
118
+ )
119
+ );
120
+ }
121
+ return out;
122
+ }
123
+ async function initEvals(skillDir, skillName) {
124
+ const name = skillName ?? basename(skillDir);
125
+ const path = join(skillDir, "evals", "evals.json");
126
+ try {
127
+ await stat(path);
128
+ throw new Error(`already exists: ${path}`);
129
+ } catch (err) {
130
+ if (err instanceof Error && err.message.startsWith("already exists"))
131
+ throw err;
132
+ }
133
+ const scaffold = {
134
+ skill_name: name,
135
+ settings: { runs_per_prompt: 3, trigger_threshold: 0.8 },
136
+ evals: [
137
+ {
138
+ id: 1,
139
+ prompt: "REPLACE: a realistic user prompt this skill should handle end to end",
140
+ expected_output: "REPLACE: what a correct result looks like",
141
+ expectations: [
142
+ "REPLACE: a checkable statement about the output",
143
+ "REPLACE: another checkable statement"
144
+ ]
145
+ }
146
+ ],
147
+ triggers: [
148
+ {
149
+ id: "explicit-1",
150
+ type: "explicit",
151
+ prompt: `REPLACE: prompt naming ${name} directly`,
152
+ should_trigger: true
153
+ },
154
+ {
155
+ id: "explicit-2",
156
+ type: "explicit",
157
+ prompt: `REPLACE: /${name} slash invocation phrasing`,
158
+ should_trigger: true
159
+ },
160
+ {
161
+ id: "explicit-3",
162
+ type: "explicit",
163
+ prompt: "REPLACE: third explicit phrasing",
164
+ should_trigger: true
165
+ },
166
+ {
167
+ id: "implicit-1",
168
+ type: "implicit",
169
+ prompt: "REPLACE: describes the task without naming the skill",
170
+ should_trigger: true
171
+ },
172
+ {
173
+ id: "implicit-2",
174
+ type: "implicit",
175
+ prompt: "REPLACE: second implicit phrasing",
176
+ should_trigger: true
177
+ },
178
+ {
179
+ id: "implicit-3",
180
+ type: "implicit",
181
+ prompt: "REPLACE: third implicit phrasing",
182
+ should_trigger: true
183
+ },
184
+ {
185
+ id: "contextual-1",
186
+ type: "contextual",
187
+ prompt: "REPLACE: mid-conversation context where the skill should fire",
188
+ should_trigger: true
189
+ },
190
+ {
191
+ id: "contextual-2",
192
+ type: "contextual",
193
+ prompt: "REPLACE: second contextual phrasing",
194
+ should_trigger: true
195
+ },
196
+ {
197
+ id: "contextual-3",
198
+ type: "contextual",
199
+ prompt: "REPLACE: third contextual phrasing",
200
+ should_trigger: true
201
+ },
202
+ {
203
+ id: "negative-1",
204
+ type: "negative",
205
+ prompt: "REPLACE: same-domain near-miss that should NOT trigger",
206
+ should_trigger: false
207
+ },
208
+ {
209
+ id: "negative-2",
210
+ type: "negative",
211
+ prompt: "REPLACE: second same-domain near-miss",
212
+ should_trigger: false
213
+ },
214
+ {
215
+ id: "negative-3",
216
+ type: "negative",
217
+ prompt: "REPLACE: adjacent task that should NOT trigger",
218
+ should_trigger: false
219
+ },
220
+ {
221
+ id: "negative-4",
222
+ type: "negative",
223
+ prompt: "REPLACE: unrelated prompt that should NOT trigger",
224
+ should_trigger: false
225
+ }
226
+ ]
227
+ };
228
+ await mkdir(join(skillDir, "evals"), { recursive: true });
229
+ await writeFile(path, `${JSON.stringify(scaffold, null, 2)}
230
+ `, "utf8");
231
+ return path;
232
+ }
233
+
234
+ // src/lint/parse.ts
235
+ import { parse as parseYaml } from "yaml";
236
+ function parseSkillMd(source) {
237
+ if (!source.startsWith("---")) {
238
+ return {
239
+ frontmatter: null,
240
+ parseError: "SKILL.md must start with YAML frontmatter (---)",
241
+ rawFrontmatter: "",
242
+ body: source,
243
+ bodyStartLine: 1
244
+ };
245
+ }
246
+ const parts = splitN(source, "---", 2);
247
+ if (parts.length < 3) {
248
+ return {
249
+ frontmatter: null,
250
+ parseError: "SKILL.md frontmatter not properly closed with ---",
251
+ rawFrontmatter: "",
252
+ body: source,
253
+ bodyStartLine: 1
254
+ };
255
+ }
256
+ const rawFrontmatter = parts[1] ?? "";
257
+ const body = (parts[2] ?? "").replace(/^\r?\n/, "");
258
+ const bodyStartLine = 3 + (rawFrontmatter.match(/\r?\n/g)?.length ?? 0);
259
+ const strictErr = strictYamlViolation(rawFrontmatter);
260
+ if (strictErr) {
261
+ return {
262
+ frontmatter: null,
263
+ parseError: `Invalid YAML in frontmatter: ${strictErr}`,
264
+ rawFrontmatter,
265
+ body,
266
+ bodyStartLine
267
+ };
268
+ }
269
+ let parsed;
270
+ try {
271
+ parsed = parseYaml(rawFrontmatter);
272
+ } catch (err) {
273
+ return {
274
+ frontmatter: null,
275
+ parseError: `Invalid YAML in frontmatter: ${err instanceof Error ? err.message : String(err)}`,
276
+ rawFrontmatter,
277
+ body,
278
+ bodyStartLine
279
+ };
280
+ }
281
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
282
+ return {
283
+ frontmatter: null,
284
+ parseError: "SKILL.md frontmatter must be a YAML mapping",
285
+ rawFrontmatter,
286
+ body,
287
+ bodyStartLine
288
+ };
289
+ }
290
+ return {
291
+ frontmatter: parsed,
292
+ parseError: null,
293
+ rawFrontmatter,
294
+ body,
295
+ bodyStartLine
296
+ };
297
+ }
298
+ function splitN(s, sep, maxsplit) {
299
+ const out = [];
300
+ let rest = s;
301
+ for (let i = 0; i < maxsplit; i++) {
302
+ const idx = rest.indexOf(sep);
303
+ if (idx === -1) break;
304
+ out.push(rest.slice(0, idx));
305
+ rest = rest.slice(idx + sep.length);
306
+ }
307
+ out.push(rest);
308
+ return out;
309
+ }
310
+ function strictYamlViolation(raw) {
311
+ const seenKeys = /* @__PURE__ */ new Set();
312
+ for (const line of raw.split(/\r?\n/)) {
313
+ const keyMatch = /^([A-Za-z0-9_-]+):(.*)$/.exec(line);
314
+ if (!keyMatch) continue;
315
+ const key = keyMatch[1] ?? "";
316
+ const value = (keyMatch[2] ?? "").trim();
317
+ if (seenKeys.has(key)) {
318
+ return `duplicate key "${key}" (strictyaml rejects duplicate keys)`;
319
+ }
320
+ seenKeys.add(key);
321
+ if (value.startsWith("{") || value.startsWith("[")) {
322
+ return `flow-style collection in "${key}" (strictyaml requires block style)`;
323
+ }
324
+ if (value.startsWith("!")) {
325
+ return `YAML tag in "${key}" (strictyaml rejects tags)`;
326
+ }
327
+ }
328
+ return null;
329
+ }
330
+
331
+ // src/lint/registry.ts
332
+ var RULES = [
333
+ // --- Frontmatter (skills-ref parity = error) ---
334
+ {
335
+ code: "SC001",
336
+ alias: "skill-md-missing",
337
+ severity: "error",
338
+ fixable: null,
339
+ summary: "Skill directory must contain SKILL.md"
340
+ },
341
+ {
342
+ code: "SC002",
343
+ alias: "frontmatter-missing",
344
+ severity: "error",
345
+ fixable: null,
346
+ summary: "SKILL.md must start with YAML frontmatter"
347
+ },
348
+ {
349
+ code: "SC003",
350
+ alias: "frontmatter-unclosed",
351
+ severity: "error",
352
+ fixable: null,
353
+ summary: "Frontmatter must be closed with ---"
354
+ },
355
+ {
356
+ code: "SC004",
357
+ alias: "frontmatter-invalid-yaml",
358
+ severity: "error",
359
+ fixable: null,
360
+ summary: "Frontmatter must be valid strict YAML"
361
+ },
362
+ {
363
+ code: "SC005",
364
+ alias: "frontmatter-not-mapping",
365
+ severity: "error",
366
+ fixable: null,
367
+ summary: "Frontmatter must be a YAML mapping"
368
+ },
369
+ {
370
+ code: "SC006",
371
+ alias: "name-missing",
372
+ severity: "error",
373
+ fixable: null,
374
+ summary: "name is required"
375
+ },
376
+ {
377
+ code: "SC007",
378
+ alias: "description-missing",
379
+ severity: "error",
380
+ fixable: null,
381
+ summary: "description is required"
382
+ },
383
+ {
384
+ code: "SC008",
385
+ alias: "name-empty",
386
+ severity: "error",
387
+ fixable: null,
388
+ summary: "name must be a non-empty string"
389
+ },
390
+ {
391
+ code: "SC009",
392
+ alias: "name-too-long",
393
+ severity: "error",
394
+ lenientSeverity: "warning",
395
+ fixable: null,
396
+ summary: "name must be at most 64 characters"
397
+ },
398
+ {
399
+ code: "SC010",
400
+ alias: "name-not-lowercase",
401
+ severity: "error",
402
+ fixable: "safe",
403
+ summary: "name must be lowercase"
404
+ },
405
+ {
406
+ code: "SC011",
407
+ alias: "name-edge-hyphen",
408
+ severity: "error",
409
+ fixable: "safe",
410
+ summary: "name cannot start or end with a hyphen"
411
+ },
412
+ {
413
+ code: "SC012",
414
+ alias: "name-consecutive-hyphens",
415
+ severity: "error",
416
+ fixable: "safe",
417
+ summary: "name cannot contain consecutive hyphens"
418
+ },
419
+ {
420
+ code: "SC013",
421
+ alias: "name-invalid-chars",
422
+ severity: "error",
423
+ fixable: null,
424
+ summary: "name may only contain letters, digits, and hyphens"
425
+ },
426
+ {
427
+ code: "SC014",
428
+ alias: "name-dir-mismatch",
429
+ severity: "error",
430
+ lenientSeverity: "warning",
431
+ fixable: null,
432
+ summary: "directory name must match skill name"
433
+ },
434
+ {
435
+ code: "SC015",
436
+ alias: "description-empty",
437
+ severity: "error",
438
+ fixable: null,
439
+ summary: "description must be a non-empty string"
440
+ },
441
+ {
442
+ code: "SC016",
443
+ alias: "description-too-long",
444
+ severity: "error",
445
+ fixable: null,
446
+ summary: "description must be at most 1024 characters"
447
+ },
448
+ {
449
+ code: "SC017",
450
+ alias: "compatibility-invalid",
451
+ severity: "error",
452
+ fixable: null,
453
+ summary: "compatibility must be a string of at most 500 characters"
454
+ },
455
+ {
456
+ code: "SC018",
457
+ alias: "unknown-frontmatter-field",
458
+ severity: "error",
459
+ lenientSeverity: "warning",
460
+ fixable: null,
461
+ summary: "frontmatter field is not in the spec or a known extension"
462
+ },
463
+ {
464
+ code: "SC019",
465
+ alias: "metadata-not-string-map",
466
+ severity: "warning",
467
+ fixable: null,
468
+ summary: "metadata should be a string-to-string map"
469
+ },
470
+ {
471
+ code: "SC020",
472
+ alias: "skill-md-lowercase-filename",
473
+ severity: "warning",
474
+ fixable: "safe",
475
+ summary: "skill.md found; canonical filename is SKILL.md"
476
+ },
477
+ {
478
+ code: "SC021",
479
+ alias: "unquoted-colon-description",
480
+ severity: "warning",
481
+ fixable: "safe",
482
+ summary: "unquoted colon in name/description value breaks lenient parsers"
483
+ },
484
+ // --- Structure + references (beyond skills-ref: warning/info only) ---
485
+ {
486
+ code: "SC101",
487
+ alias: "broken-relative-reference",
488
+ severity: "warning",
489
+ fixable: null,
490
+ summary: "referenced bundled file does not exist"
491
+ },
492
+ {
493
+ code: "SC102",
494
+ alias: "absolute-path-reference",
495
+ severity: "warning",
496
+ fixable: null,
497
+ summary: "body references an absolute path"
498
+ },
499
+ {
500
+ code: "SC103",
501
+ alias: "deep-reference-chain",
502
+ severity: "info",
503
+ fixable: null,
504
+ summary: "bundled reference chains deeper than one level"
505
+ },
506
+ {
507
+ code: "SC104",
508
+ alias: "unreferenced-bundled-file",
509
+ severity: "info",
510
+ fixable: null,
511
+ summary: "bundled file never referenced from SKILL.md"
512
+ },
513
+ {
514
+ code: "SC105",
515
+ alias: "duplicate-skill-name",
516
+ severity: "warning",
517
+ fixable: null,
518
+ summary: "two skills resolve to the same name"
519
+ },
520
+ {
521
+ code: "SC106",
522
+ alias: "non-utf8-encoding",
523
+ severity: "warning",
524
+ fixable: null,
525
+ summary: "SKILL.md is not valid UTF-8"
526
+ },
527
+ {
528
+ code: "SC107",
529
+ alias: "hidden-unicode",
530
+ severity: "warning",
531
+ fixable: "unsafe",
532
+ summary: "zero-width or bidi control characters present"
533
+ },
534
+ {
535
+ code: "SC108",
536
+ alias: "legacy-basedir-placeholder",
537
+ severity: "warning",
538
+ fixable: "safe",
539
+ summary: "deprecated {baseDir} placeholder"
540
+ },
541
+ // --- Body (SC2xx) ---
542
+ {
543
+ code: "SC201",
544
+ alias: "body-empty",
545
+ severity: "warning",
546
+ fixable: null,
547
+ summary: "SKILL.md body is empty"
548
+ },
549
+ {
550
+ code: "SC202",
551
+ alias: "body-too-many-lines",
552
+ severity: "warning",
553
+ fixable: null,
554
+ summary: "body exceeds 500 lines"
555
+ },
556
+ {
557
+ code: "SC203",
558
+ alias: "body-too-many-tokens",
559
+ severity: "warning",
560
+ fixable: null,
561
+ summary: "body exceeds ~5000 tokens"
562
+ },
563
+ // --- Extensions / portability (SC3xx) ---
564
+ {
565
+ code: "SC301",
566
+ alias: "extension-field",
567
+ severity: "warning",
568
+ fixable: null,
569
+ summary: "client extension field (fails strict skills-ref validation)"
570
+ },
571
+ {
572
+ code: "SC302",
573
+ alias: "extension-invalid-value",
574
+ severity: "warning",
575
+ fixable: null,
576
+ summary: "extension field has an invalid value"
577
+ },
578
+ // --- Evals (SC4xx) ---
579
+ {
580
+ code: "SC401",
581
+ alias: "evals-invalid",
582
+ severity: "error",
583
+ fixable: null,
584
+ summary: "evals/evals.json fails schema or semantic validation"
585
+ },
586
+ {
587
+ code: "SC402",
588
+ alias: "evals-advisory",
589
+ severity: "warning",
590
+ fixable: null,
591
+ summary: "evals/evals.json advisory (coverage gaps)"
592
+ }
593
+ ];
594
+ var byCode = new Map(RULES.map((r) => [r.code, r]));
595
+ var byAlias = new Map(RULES.map((r) => [r.alias, r]));
596
+ function getRule(codeOrAlias) {
597
+ return byCode.get(codeOrAlias.toUpperCase()) ?? byAlias.get(codeOrAlias.toLowerCase());
598
+ }
599
+
600
+ // src/extensions.ts
601
+ var KNOWN_EXTENSIONS = {
602
+ when_to_use: { type: "string", runtimes: ["claude-code"] },
603
+ "argument-hint": { type: "string", runtimes: ["claude-code", "copilot"] },
604
+ arguments: { type: "string-or-array", runtimes: ["claude-code"] },
605
+ "disable-model-invocation": {
606
+ type: "boolean",
607
+ runtimes: ["claude-code", "cursor", "copilot"]
608
+ },
609
+ "user-invocable": { type: "boolean", runtimes: ["claude-code", "copilot"] },
610
+ "disallowed-tools": { type: "string-or-array", runtimes: ["claude-code"] },
611
+ model: { type: "string", runtimes: ["claude-code"] },
612
+ effort: {
613
+ type: "enum",
614
+ values: ["low", "medium", "high", "xhigh", "max"],
615
+ runtimes: ["claude-code"]
616
+ },
617
+ context: {
618
+ type: "enum",
619
+ values: ["fork"],
620
+ runtimes: ["claude-code", "copilot"]
621
+ },
622
+ agent: { type: "string", runtimes: ["claude-code"] },
623
+ hooks: { type: "object", runtimes: ["claude-code"] },
624
+ paths: { type: "string-or-array", runtimes: ["claude-code", "cursor"] },
625
+ globs: {
626
+ type: "string-or-array",
627
+ runtimes: ["cursor"],
628
+ deprecated: "Cursor legacy; use `paths`"
629
+ },
630
+ shell: {
631
+ type: "enum",
632
+ values: ["bash", "powershell"],
633
+ runtimes: ["claude-code"]
634
+ },
635
+ "display-name": { type: "string", runtimes: ["claude-code"] },
636
+ "default-enabled": { type: "boolean", runtimes: ["claude-code"] },
637
+ fallback: { type: "string", runtimes: ["claude-code"] },
638
+ version: { type: "string", runtimes: ["claude-code"] }
639
+ };
640
+ var SPEC_FIELDS = /* @__PURE__ */ new Set([
641
+ "name",
642
+ "description",
643
+ "license",
644
+ "allowed-tools",
645
+ "metadata",
646
+ "compatibility"
647
+ ]);
648
+ function validateExtensionValue(key, value) {
649
+ const spec = KNOWN_EXTENSIONS[key];
650
+ if (!spec) return null;
651
+ switch (spec.type) {
652
+ case "string":
653
+ return typeof value === "string" ? null : `\`${key}\` must be a string`;
654
+ case "boolean":
655
+ return typeof value === "boolean" ? null : `\`${key}\` must be a boolean`;
656
+ case "string-or-array":
657
+ if (typeof value === "string") return null;
658
+ if (Array.isArray(value) && value.every((v) => typeof v === "string"))
659
+ return null;
660
+ return `\`${key}\` must be a string or an array of strings`;
661
+ case "object":
662
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? null : `\`${key}\` must be a mapping`;
663
+ case "enum":
664
+ return typeof value === "string" && spec.values?.includes(value) ? null : `\`${key}\` must be one of: ${spec.values?.join(", ")}`;
665
+ }
666
+ }
667
+
668
+ // src/lint/index.ts
669
+ import { readdir, readFile as readFile2 } from "fs/promises";
670
+ import { basename as basename2, join as join2, relative, resolve } from "path";
671
+
672
+ // src/lint/rules.ts
673
+ var NAME_MAX = 64;
674
+ var DESCRIPTION_MAX = 1024;
675
+ var COMPATIBILITY_MAX = 500;
676
+ var BODY_LINE_LIMIT = 500;
677
+ var BODY_TOKEN_LIMIT = 5e3;
678
+ var CHARS_PER_TOKEN = 4;
679
+ var NAME_CHARS_RE = /^[\p{L}\p{N}-]*$/u;
680
+ var HIDDEN_UNICODE_RE = /[​-‍⁠‪-‮⁦-⁩]/;
681
+ function make(code, message, line, suggestion) {
682
+ const meta = getRule(code);
683
+ if (!meta) throw new Error(`unknown rule code ${code}`);
684
+ return {
685
+ code: meta.code,
686
+ alias: meta.alias,
687
+ severity: meta.severity,
688
+ message,
689
+ file: "SKILL.md",
690
+ ...line !== void 0 ? { line } : {},
691
+ ...suggestion !== void 0 ? { suggestion } : {}
692
+ };
693
+ }
694
+ function applyProfile(findings, profile) {
695
+ if (profile !== "lenient") return findings;
696
+ return findings.map((f) => {
697
+ const meta = getRule(f.code);
698
+ if (meta?.lenientSeverity && meta.lenientSeverity !== f.severity) {
699
+ return { ...f, severity: meta.lenientSeverity };
700
+ }
701
+ return f;
702
+ });
703
+ }
704
+ function runRules(ctx) {
705
+ const { parsed, dirName } = ctx;
706
+ const findings = [];
707
+ scanUnquotedColons(parsed, findings);
708
+ if (parsed.parseError !== null || parsed.frontmatter === null) {
709
+ const msg = parsed.parseError ?? "SKILL.md frontmatter unparseable";
710
+ let code = "SC004";
711
+ if (msg.startsWith("SKILL.md must start with")) code = "SC002";
712
+ else if (msg.includes("not properly closed")) code = "SC003";
713
+ else if (msg.includes("must be a YAML mapping")) code = "SC005";
714
+ findings.push(make(code, msg, 1));
715
+ return applyProfile(findings, ctx.profile);
716
+ }
717
+ const fm = parsed.frontmatter;
718
+ if (!("name" in fm)) {
719
+ findings.push(make("SC006", "Missing required field in frontmatter: name"));
720
+ } else if (typeof fm.name !== "string" || fm.name.trim().length === 0) {
721
+ findings.push(make("SC008", "Field 'name' must be a non-empty string"));
722
+ } else {
723
+ const name = fm.name.trim().normalize("NFKC");
724
+ if (name.length > NAME_MAX) {
725
+ findings.push(
726
+ make(
727
+ "SC009",
728
+ `Skill name '${name}' exceeds ${NAME_MAX} character limit (${name.length} chars)`
729
+ )
730
+ );
731
+ }
732
+ if (name !== name.toLowerCase()) {
733
+ findings.push(
734
+ make(
735
+ "SC010",
736
+ `Skill name '${name}' must be lowercase`,
737
+ void 0,
738
+ `rename to '${name.toLowerCase()}'`
739
+ )
740
+ );
741
+ }
742
+ if (name.startsWith("-") || name.endsWith("-")) {
743
+ findings.push(
744
+ make("SC011", "Skill name cannot start or end with a hyphen")
745
+ );
746
+ }
747
+ if (name.includes("--")) {
748
+ findings.push(
749
+ make("SC012", "Skill name cannot contain consecutive hyphens")
750
+ );
751
+ }
752
+ if (!NAME_CHARS_RE.test(name)) {
753
+ findings.push(
754
+ make(
755
+ "SC013",
756
+ `Skill name '${name}' contains invalid characters. Only letters, digits, and hyphens are allowed.`
757
+ )
758
+ );
759
+ }
760
+ const normalizedDir = dirName.normalize("NFKC");
761
+ if (name !== normalizedDir) {
762
+ findings.push(
763
+ make(
764
+ "SC014",
765
+ `Directory name '${normalizedDir}' must match skill name '${name}'`
766
+ )
767
+ );
768
+ }
769
+ }
770
+ if (!("description" in fm)) {
771
+ findings.push(
772
+ make("SC007", "Missing required field in frontmatter: description")
773
+ );
774
+ } else if (typeof fm.description !== "string" || fm.description.trim().length === 0) {
775
+ findings.push(
776
+ make("SC015", "Field 'description' must be a non-empty string")
777
+ );
778
+ } else if (fm.description.length > DESCRIPTION_MAX) {
779
+ findings.push(
780
+ make(
781
+ "SC016",
782
+ `Description exceeds ${DESCRIPTION_MAX} character limit (${fm.description.length} chars)`
783
+ )
784
+ );
785
+ }
786
+ if (fm.compatibility !== void 0) {
787
+ if (typeof fm.compatibility !== "string") {
788
+ findings.push(make("SC017", "Field 'compatibility' must be a string"));
789
+ } else if (fm.compatibility.length > COMPATIBILITY_MAX) {
790
+ findings.push(
791
+ make(
792
+ "SC017",
793
+ `Compatibility exceeds ${COMPATIBILITY_MAX} character limit (${fm.compatibility.length} chars)`
794
+ )
795
+ );
796
+ }
797
+ }
798
+ if (fm.metadata !== void 0) {
799
+ const md = fm.metadata;
800
+ const isStringMap = md !== null && typeof md === "object" && !Array.isArray(md) && Object.values(md).every(
801
+ (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean"
802
+ );
803
+ if (!isStringMap) {
804
+ findings.push(
805
+ make(
806
+ "SC019",
807
+ "`metadata` should be a map of string keys to string values"
808
+ )
809
+ );
810
+ }
811
+ }
812
+ if (fm["allowed-tools"] !== void 0 && typeof fm["allowed-tools"] !== "string") {
813
+ if (Array.isArray(fm["allowed-tools"]) && fm["allowed-tools"].every((v) => typeof v === "string")) {
814
+ findings.push(
815
+ make(
816
+ "SC302",
817
+ "`allowed-tools` is an array; the spec defines it as a space-separated string (arrays are a client extension)"
818
+ )
819
+ );
820
+ } else {
821
+ findings.push(
822
+ make("SC302", "`allowed-tools` must be a space-separated string")
823
+ );
824
+ }
825
+ }
826
+ const extras = [];
827
+ for (const key of Object.keys(fm)) {
828
+ if (SPEC_FIELDS.has(key)) continue;
829
+ if (key in KNOWN_EXTENSIONS) {
830
+ const ext = KNOWN_EXTENSIONS[key];
831
+ findings.push(
832
+ make(
833
+ "SC301",
834
+ `\`${key}\` is a client extension (${ext?.runtimes.join(", ")}), not in the Agent Skills spec -- fails strict skills-ref validation${ext?.deprecated ? `; ${ext.deprecated}` : ""}`
835
+ )
836
+ );
837
+ const valueErr = validateExtensionValue(key, fm[key]);
838
+ if (valueErr) findings.push(make("SC302", valueErr));
839
+ } else {
840
+ extras.push(key);
841
+ }
842
+ }
843
+ if (extras.length > 0) {
844
+ const allowed = [...SPEC_FIELDS].sort().join(", ");
845
+ findings.push(
846
+ // skills-ref verbatim: period before "Only", "are allowed."
847
+ make(
848
+ "SC018",
849
+ `Unexpected fields in frontmatter: ${extras.sort().join(", ")}. Only ${allowed} are allowed.`
850
+ )
851
+ );
852
+ }
853
+ const body = parsed.body;
854
+ if (body.trim().length === 0) {
855
+ findings.push(
856
+ make(
857
+ "SC201",
858
+ "SKILL.md body is empty; the skill provides no instructions"
859
+ )
860
+ );
861
+ } else {
862
+ const bodyLines = body.split(/\r?\n/).length;
863
+ if (bodyLines > BODY_LINE_LIMIT) {
864
+ findings.push(
865
+ make(
866
+ "SC202",
867
+ `body is ${bodyLines} lines; spec recommends under ${BODY_LINE_LIMIT}`
868
+ )
869
+ );
870
+ }
871
+ const approxTokens = Math.round(body.length / CHARS_PER_TOKEN);
872
+ if (approxTokens > BODY_TOKEN_LIMIT) {
873
+ findings.push(
874
+ make(
875
+ "SC203",
876
+ `body is ~${approxTokens} tokens; spec recommends under ${BODY_TOKEN_LIMIT} (move detail to references/)`
877
+ )
878
+ );
879
+ }
880
+ }
881
+ findings.push(...referenceChecks(parsed, ctx.bundledFiles ?? []));
882
+ if (HIDDEN_UNICODE_RE.test(body) || HIDDEN_UNICODE_RE.test(parsed.rawFrontmatter)) {
883
+ findings.push(
884
+ make(
885
+ "SC107",
886
+ "SKILL.md contains zero-width or bidi control characters (Trojan-Source risk)"
887
+ )
888
+ );
889
+ }
890
+ if (body.includes("{baseDir}")) {
891
+ findings.push(
892
+ make(
893
+ "SC108",
894
+ "deprecated `{baseDir}` placeholder found",
895
+ void 0,
896
+ "use relative paths from the skill root instead"
897
+ )
898
+ );
899
+ }
900
+ return applyProfile(findings, ctx.profile);
901
+ }
902
+ function scanUnquotedColons(parsed, findings) {
903
+ for (const line of parsed.rawFrontmatter.split(/\r?\n/)) {
904
+ const m = /^(name|description):\s+(?!['"|>])(.*: .*)$/.exec(line);
905
+ if (m) {
906
+ findings.push(
907
+ make(
908
+ "SC021",
909
+ `unquoted ': ' inside \`${m[1]}\` value -- lenient YAML parsers in some clients truncate here`,
910
+ void 0,
911
+ `quote the value: ${m[1]}: "${(m[2] ?? "").trim()}"`
912
+ )
913
+ );
914
+ }
915
+ }
916
+ }
917
+ function referenceChecks(parsed, bundledFiles) {
918
+ const findings = [];
919
+ const body = parsed.body;
920
+ const bundled = new Set(bundledFiles.map((f) => f.replace(/\\/g, "/")));
921
+ const referenced = /* @__PURE__ */ new Set();
922
+ for (const m of body.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)) {
923
+ const target = (m[1] ?? "").split(/[#?]/)[0]?.trim() ?? "";
924
+ if (target) referenced.add(target);
925
+ }
926
+ for (const m of body.matchAll(
927
+ /`((?:scripts|references|assets)\/[^\s`]+)`/g
928
+ )) {
929
+ referenced.add((m[1] ?? "").trim());
930
+ }
931
+ for (const target of referenced) {
932
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(target) || target.startsWith("mailto:"))
933
+ continue;
934
+ if (target.startsWith("#")) continue;
935
+ if (target.includes("${") || target.includes("{{")) continue;
936
+ if (/^(\/|[A-Za-z]:[\\/]|~\/)/.test(target)) {
937
+ findings.push(
938
+ make(
939
+ "SC102",
940
+ `absolute path reference \`${target}\` -- skills must use paths relative to the skill root`
941
+ )
942
+ );
943
+ continue;
944
+ }
945
+ const normalized = target.replace(/^\.\//, "").replace(/\\/g, "/");
946
+ if (bundled.size > 0 && !bundled.has(normalized)) {
947
+ findings.push(
948
+ make(
949
+ "SC101",
950
+ `referenced file \`${target}\` does not exist in the skill directory`
951
+ )
952
+ );
953
+ }
954
+ }
955
+ for (const f of bundled) {
956
+ if (!/^(scripts|references|assets)\//.test(f)) continue;
957
+ const mentioned = body.includes(f) || referenced.has(f);
958
+ if (!mentioned) {
959
+ findings.push({
960
+ code: "SC104",
961
+ alias: "unreferenced-bundled-file",
962
+ severity: "info",
963
+ message: `bundled file \`${f}\` is never referenced from SKILL.md`,
964
+ file: f
965
+ });
966
+ }
967
+ }
968
+ return findings;
969
+ }
970
+
971
+ // src/lint/index.ts
972
+ async function lintSkillDir(dir, options = {}) {
973
+ const skillDir = resolve(dir);
974
+ const dirName = basename2(skillDir);
975
+ let entries = [];
976
+ try {
977
+ entries = (await readdir(skillDir)).map(String);
978
+ } catch {
979
+ }
980
+ let source = null;
981
+ let usedLowercase = false;
982
+ for (const candidate of ["SKILL.md", "skill.md"]) {
983
+ if (!entries.includes(candidate)) continue;
984
+ try {
985
+ source = await readFile2(join2(skillDir, candidate));
986
+ usedLowercase = candidate === "skill.md";
987
+ break;
988
+ } catch {
989
+ }
990
+ }
991
+ if (source === null) {
992
+ return summarize(skillDir, null, [
993
+ {
994
+ code: "SC001",
995
+ alias: "skill-md-missing",
996
+ severity: "error",
997
+ message: "Missing required file: SKILL.md",
998
+ file: "SKILL.md"
999
+ }
1000
+ ]);
1001
+ }
1002
+ const findings = [];
1003
+ const text = source.toString("utf8");
1004
+ if (text.includes("\uFFFD")) {
1005
+ findings.push({
1006
+ code: "SC106",
1007
+ alias: "non-utf8-encoding",
1008
+ severity: "warning",
1009
+ message: "SKILL.md is not valid UTF-8 (replacement characters found on decode)",
1010
+ file: "SKILL.md"
1011
+ });
1012
+ }
1013
+ if (usedLowercase) {
1014
+ findings.push({
1015
+ code: "SC020",
1016
+ alias: "skill-md-lowercase-filename",
1017
+ severity: "warning",
1018
+ message: "found skill.md; the canonical filename is SKILL.md (uppercase)",
1019
+ file: "skill.md"
1020
+ });
1021
+ }
1022
+ const parsed = parseSkillMd(text.replace(/^/, ""));
1023
+ const bundledFiles = await listBundledFiles(skillDir);
1024
+ findings.push(
1025
+ ...runRules({
1026
+ parsed,
1027
+ dirName,
1028
+ profile: options.profile ?? "strict",
1029
+ bundledFiles
1030
+ })
1031
+ );
1032
+ const skillName = parsed.frontmatter && typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name.trim().normalize("NFKC") : null;
1033
+ return summarize(skillDir, skillName, applyOverrides(findings, options));
1034
+ }
1035
+ function crossSkillFindings(results) {
1036
+ const byName = /* @__PURE__ */ new Map();
1037
+ for (const r of results) {
1038
+ if (!r.skillName) continue;
1039
+ const list = byName.get(r.skillName) ?? [];
1040
+ list.push(r);
1041
+ byName.set(r.skillName, list);
1042
+ }
1043
+ for (const [name, list] of byName) {
1044
+ if (list.length < 2) continue;
1045
+ for (const r of list) {
1046
+ r.findings.push({
1047
+ code: "SC105",
1048
+ alias: "duplicate-skill-name",
1049
+ severity: "warning",
1050
+ message: `skill name '${name}' is used by ${list.length} skills in this tree (client-guide: collisions warn; project scope wins)`,
1051
+ file: "SKILL.md"
1052
+ });
1053
+ recount(r);
1054
+ }
1055
+ }
1056
+ }
1057
+ function applyOverrides(findings, options) {
1058
+ const overrides = options.ruleOverrides;
1059
+ if (!overrides) return findings;
1060
+ const resolved = /* @__PURE__ */ new Map();
1061
+ for (const [key, value] of Object.entries(overrides)) {
1062
+ const meta = getRule(key);
1063
+ if (!meta) continue;
1064
+ resolved.set(meta.code, value === "warn" ? "warning" : value);
1065
+ }
1066
+ const out = [];
1067
+ for (const f of findings) {
1068
+ const o = resolved.get(f.code);
1069
+ if (o === "off") continue;
1070
+ out.push(o ? { ...f, severity: o } : f);
1071
+ }
1072
+ return out;
1073
+ }
1074
+ async function listBundledFiles(skillDir) {
1075
+ const files = [];
1076
+ async function walk(dir, depth) {
1077
+ if (depth > 4) return;
1078
+ let entries;
1079
+ try {
1080
+ entries = await readdir(dir, { withFileTypes: true });
1081
+ } catch {
1082
+ return;
1083
+ }
1084
+ for (const entry of entries) {
1085
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
1086
+ const full = join2(dir, entry.name);
1087
+ if (entry.isDirectory()) {
1088
+ await walk(full, depth + 1);
1089
+ } else {
1090
+ files.push(relative(skillDir, full).replace(/\\/g, "/"));
1091
+ }
1092
+ }
1093
+ }
1094
+ await walk(skillDir, 0);
1095
+ return files;
1096
+ }
1097
+ function summarize(skillDir, skillName, findings) {
1098
+ const result = {
1099
+ skillDir,
1100
+ skillName,
1101
+ findings,
1102
+ errorCount: 0,
1103
+ warningCount: 0,
1104
+ infoCount: 0
1105
+ };
1106
+ recount(result);
1107
+ return result;
1108
+ }
1109
+ function recount(r) {
1110
+ r.errorCount = r.findings.filter((f) => f.severity === "error").length;
1111
+ r.warningCount = r.findings.filter((f) => f.severity === "warning").length;
1112
+ r.infoCount = r.findings.filter((f) => f.severity === "info").length;
1113
+ }
1114
+
1115
+ // src/output/sarif.ts
1116
+ import { relative as relative2 } from "path";
1117
+ var LEVEL = {
1118
+ error: "error",
1119
+ warning: "warning",
1120
+ info: "note"
1121
+ };
1122
+ function toSarif(results, version, cwd = process.cwd()) {
1123
+ const sarif = {
1124
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
1125
+ version: "2.1.0",
1126
+ runs: [
1127
+ {
1128
+ tool: {
1129
+ driver: {
1130
+ name: "skillcheck",
1131
+ informationUri: "https://github.com/Sagargupta16/skillcheck",
1132
+ version,
1133
+ rules: RULES.map((r) => ({
1134
+ id: r.code,
1135
+ name: r.alias,
1136
+ shortDescription: { text: r.summary.slice(0, 255) },
1137
+ helpUri: `https://github.com/Sagargupta16/skillcheck/blob/main/docs/rules.md#${r.code.toLowerCase()}`,
1138
+ defaultConfiguration: { level: LEVEL[r.severity] }
1139
+ }))
1140
+ }
1141
+ },
1142
+ results: results.flatMap(
1143
+ (result) => result.findings.map((f) => ({
1144
+ ruleId: f.code,
1145
+ level: LEVEL[f.severity],
1146
+ message: { text: f.message },
1147
+ locations: [
1148
+ {
1149
+ physicalLocation: {
1150
+ artifactLocation: {
1151
+ uri: relative2(cwd, `${result.skillDir}/${f.file}`).replace(
1152
+ /\\/g,
1153
+ "/"
1154
+ )
1155
+ },
1156
+ region: { startLine: f.line ?? 1, startColumn: 1 }
1157
+ }
1158
+ }
1159
+ ]
1160
+ }))
1161
+ )
1162
+ }
1163
+ ]
1164
+ };
1165
+ return JSON.stringify(sarif, null, 2);
1166
+ }
1167
+
1168
+ export {
1169
+ evalsFileSchema,
1170
+ checkEvals,
1171
+ initEvals,
1172
+ parseSkillMd,
1173
+ RULES,
1174
+ getRule,
1175
+ KNOWN_EXTENSIONS,
1176
+ SPEC_FIELDS,
1177
+ lintSkillDir,
1178
+ crossSkillFindings,
1179
+ toSarif
1180
+ };