@jkeskikangas/skillcheck 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 (68) hide show
  1. package/bin/skillcheck.js +5 -0
  2. package/dist/cli.d.ts +20 -0
  3. package/dist/cli.js +101 -0
  4. package/dist/cli.test.d.ts +1 -0
  5. package/dist/cli.test.js +202 -0
  6. package/dist/discover.d.ts +5 -0
  7. package/dist/discover.js +51 -0
  8. package/dist/discover.test.d.ts +1 -0
  9. package/dist/discover.test.js +30 -0
  10. package/dist/formatters/json.d.ts +2 -0
  11. package/dist/formatters/json.js +8 -0
  12. package/dist/formatters/json.test.d.ts +1 -0
  13. package/dist/formatters/json.test.js +23 -0
  14. package/dist/formatters/stylish.d.ts +2 -0
  15. package/dist/formatters/stylish.js +25 -0
  16. package/dist/formatters/stylish.test.d.ts +1 -0
  17. package/dist/formatters/stylish.test.js +34 -0
  18. package/dist/frontmatter.d.ts +3 -0
  19. package/dist/frontmatter.js +108 -0
  20. package/dist/frontmatter.test.d.ts +1 -0
  21. package/dist/frontmatter.test.js +55 -0
  22. package/dist/index.d.ts +6 -0
  23. package/dist/index.js +107 -0
  24. package/dist/index.test.d.ts +1 -0
  25. package/dist/index.test.js +52 -0
  26. package/dist/rules/description-constraints.d.ts +2 -0
  27. package/dist/rules/description-constraints.js +29 -0
  28. package/dist/rules/frontmatter-keys.d.ts +2 -0
  29. package/dist/rules/frontmatter-keys.js +26 -0
  30. package/dist/rules/frontmatter-valid.d.ts +2 -0
  31. package/dist/rules/frontmatter-valid.js +16 -0
  32. package/dist/rules/index.d.ts +3 -0
  33. package/dist/rules/index.js +32 -0
  34. package/dist/rules/line-count.d.ts +2 -0
  35. package/dist/rules/line-count.js +16 -0
  36. package/dist/rules/links-resolve.d.ts +3 -0
  37. package/dist/rules/links-resolve.js +44 -0
  38. package/dist/rules/links-within-skill-dir.d.ts +2 -0
  39. package/dist/rules/links-within-skill-dir.js +19 -0
  40. package/dist/rules/name-constraints.d.ts +2 -0
  41. package/dist/rules/name-constraints.js +38 -0
  42. package/dist/rules/name-matches-dir.d.ts +2 -0
  43. package/dist/rules/name-matches-dir.js +16 -0
  44. package/dist/rules/no-deep-chaining.d.ts +3 -0
  45. package/dist/rules/no-deep-chaining.js +39 -0
  46. package/dist/rules/no-deep-chaining.test.d.ts +1 -0
  47. package/dist/rules/no-deep-chaining.test.js +50 -0
  48. package/dist/rules/no-placeholders.d.ts +2 -0
  49. package/dist/rules/no-placeholders.js +20 -0
  50. package/dist/rules/openai-yaml-sanity.d.ts +3 -0
  51. package/dist/rules/openai-yaml-sanity.js +22 -0
  52. package/dist/rules/openai-yaml-sanity.test.d.ts +1 -0
  53. package/dist/rules/openai-yaml-sanity.test.js +65 -0
  54. package/dist/rules/rubric-evidence.d.ts +2 -0
  55. package/dist/rules/rubric-evidence.js +14 -0
  56. package/dist/rules/rubric-grade-bands.d.ts +5 -0
  57. package/dist/rules/rubric-grade-bands.js +43 -0
  58. package/dist/rules/rubric-priorities.d.ts +4 -0
  59. package/dist/rules/rubric-priorities.js +41 -0
  60. package/dist/rules/rubric-rules.test.d.ts +1 -0
  61. package/dist/rules/rubric-rules.test.js +112 -0
  62. package/dist/rules/skill-md-exists.d.ts +2 -0
  63. package/dist/rules/skill-md-exists.js +13 -0
  64. package/dist/rules/skill-rules.test.d.ts +1 -0
  65. package/dist/rules/skill-rules.test.js +229 -0
  66. package/dist/types.d.ts +67 -0
  67. package/dist/types.js +1 -0
  68. package/package.json +32 -0
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { run } from "../dist/cli.js";
4
+
5
+ process.exit(run());
package/dist/cli.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ import type { LintResult, OutputFormat } from "./types.js";
2
+ type ParseSuccess = {
3
+ paths: string[];
4
+ options: {
5
+ format: OutputFormat;
6
+ maxLines: number;
7
+ fix: boolean;
8
+ skillsOnly: boolean;
9
+ rubricsOnly: boolean;
10
+ };
11
+ };
12
+ type ParseResult = ParseSuccess | {
13
+ error: string;
14
+ } | {
15
+ help: true;
16
+ };
17
+ export declare function parseCliArgs(argv: string[]): ParseResult;
18
+ export declare function formatLintOutput(result: LintResult, format: OutputFormat, fix: boolean): string;
19
+ export declare function run(argv?: string[]): number;
20
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,101 @@
1
+ import { parseArgs } from "node:util";
2
+ import { lint } from "./index.js";
3
+ import { formatStylish } from "./formatters/stylish.js";
4
+ import { formatJson } from "./formatters/json.js";
5
+ export function parseCliArgs(argv) {
6
+ let parsed;
7
+ try {
8
+ parsed = parseArgs({
9
+ args: argv,
10
+ allowPositionals: true,
11
+ options: {
12
+ format: { type: "string", default: "stylish" },
13
+ "max-lines": { type: "string", default: "500" },
14
+ fix: { type: "boolean", default: false },
15
+ "skills-only": { type: "boolean", default: false },
16
+ "rubrics-only": { type: "boolean", default: false },
17
+ help: { type: "boolean", short: "h", default: false },
18
+ },
19
+ });
20
+ }
21
+ catch (e) {
22
+ return { error: e instanceof Error ? e.message : String(e) };
23
+ }
24
+ if (parsed.values.help)
25
+ return { help: true };
26
+ if (parsed.positionals.length === 0) {
27
+ return { error: "at least one path is required." };
28
+ }
29
+ const format = parsed.values.format;
30
+ if (format !== "stylish" && format !== "json") {
31
+ return { error: `unknown format '${format}'. Use 'stylish' or 'json'.` };
32
+ }
33
+ const maxLines = parseInt(parsed.values["max-lines"], 10);
34
+ if (isNaN(maxLines) || maxLines <= 0) {
35
+ return { error: "--max-lines must be a positive integer." };
36
+ }
37
+ return {
38
+ paths: parsed.positionals,
39
+ options: {
40
+ format: format,
41
+ maxLines,
42
+ fix: parsed.values.fix,
43
+ skillsOnly: parsed.values["skills-only"],
44
+ rubricsOnly: parsed.values["rubrics-only"],
45
+ },
46
+ };
47
+ }
48
+ export function formatLintOutput(result, format, fix) {
49
+ if (result.diagnostics.length === 0) {
50
+ const parts = [];
51
+ if (result.skillCount > 0)
52
+ parts.push(`${result.skillCount} skill${result.skillCount === 1 ? "" : "s"}`);
53
+ if (result.rubricCount > 0)
54
+ parts.push(`${result.rubricCount} rubric${result.rubricCount === 1 ? "" : "s"}`);
55
+ return `[OK] ${parts.join(" and ")} valid.`;
56
+ }
57
+ const output = format === "json"
58
+ ? formatJson(result.diagnostics)
59
+ : formatStylish(result.diagnostics);
60
+ const lines = [output];
61
+ const fixable = result.diagnostics.filter((d) => d.fix);
62
+ if (fixable.length > 0) {
63
+ lines.push(fix
64
+ ? `Applied ${fixable.length} fix${fixable.length === 1 ? "" : "es"}.`
65
+ : "Tip: re-run with --fix to apply automated fixes.");
66
+ }
67
+ return lines.join("\n");
68
+ }
69
+ const USAGE = `Usage: skillcheck [options] <path...>
70
+
71
+ Options:
72
+ --format <stylish|json> Output format (default: stylish)
73
+ --max-lines <n> Max allowed SKILL.md lines (default: 500)
74
+ --fix Auto-fix rubric drift
75
+ --skills-only Skip rubric checks
76
+ --rubrics-only Skip skill checks
77
+ -h, --help Show this help
78
+ `;
79
+ export function run(argv = process.argv.slice(2)) {
80
+ const parsed = parseCliArgs(argv);
81
+ if ("error" in parsed) {
82
+ process.stderr.write(`Error: ${parsed.error}\n`);
83
+ process.stderr.write(USAGE);
84
+ return 2;
85
+ }
86
+ if ("help" in parsed) {
87
+ process.stderr.write(USAGE);
88
+ return 0;
89
+ }
90
+ const result = lint(parsed.paths, parsed.options);
91
+ const output = formatLintOutput(result, parsed.options.format, parsed.options.fix);
92
+ if (result.diagnostics.length === 0) {
93
+ process.stdout.write(output + "\n");
94
+ return 0;
95
+ }
96
+ process.stdout.write(output.split("\n")[0] + "\n");
97
+ const extra = output.split("\n").slice(1).filter(Boolean);
98
+ for (const line of extra)
99
+ process.stderr.write(line + "\n");
100
+ return 1;
101
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,202 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { resolve } from "node:path";
3
+ import { parseCliArgs, formatLintOutput, run } from "./cli.js";
4
+ const FIXTURES = resolve(import.meta.dirname, "../fixtures");
5
+ describe("parseCliArgs", () => {
6
+ it("parses valid args", () => {
7
+ const result = parseCliArgs(["skills/"]);
8
+ expect(result).toEqual({
9
+ paths: ["skills/"],
10
+ options: {
11
+ format: "stylish",
12
+ maxLines: 500,
13
+ fix: false,
14
+ skillsOnly: false,
15
+ rubricsOnly: false,
16
+ },
17
+ });
18
+ });
19
+ it("returns error for missing paths", () => {
20
+ const result = parseCliArgs([]);
21
+ expect(result).toEqual({ error: "at least one path is required." });
22
+ });
23
+ it("returns error for unknown format", () => {
24
+ const result = parseCliArgs(["--format", "xml", "skills/"]);
25
+ expect(result).toEqual({
26
+ error: "unknown format 'xml'. Use 'stylish' or 'json'.",
27
+ });
28
+ });
29
+ it("returns help", () => {
30
+ const result = parseCliArgs(["--help"]);
31
+ expect(result).toEqual({ help: true });
32
+ });
33
+ it("returns help with -h", () => {
34
+ const result = parseCliArgs(["-h"]);
35
+ expect(result).toEqual({ help: true });
36
+ });
37
+ it("parses --fix", () => {
38
+ const result = parseCliArgs(["--fix", "skills/"]);
39
+ expect("options" in result && result.options.fix).toBe(true);
40
+ });
41
+ it("parses --max-lines", () => {
42
+ const result = parseCliArgs(["--max-lines", "100", "skills/"]);
43
+ expect("options" in result && result.options.maxLines).toBe(100);
44
+ });
45
+ it("returns error for invalid --max-lines", () => {
46
+ const result = parseCliArgs(["--max-lines", "abc", "skills/"]);
47
+ expect(result).toEqual({ error: "--max-lines must be a positive integer." });
48
+ });
49
+ it("returns error for zero --max-lines", () => {
50
+ const result = parseCliArgs(["--max-lines", "0", "skills/"]);
51
+ expect(result).toEqual({ error: "--max-lines must be a positive integer." });
52
+ });
53
+ it("parses --rubrics-only", () => {
54
+ const result = parseCliArgs(["--rubrics-only", "skills/"]);
55
+ expect("options" in result && result.options.rubricsOnly).toBe(true);
56
+ });
57
+ it("parses --skills-only", () => {
58
+ const result = parseCliArgs(["--skills-only", "skills/"]);
59
+ expect("options" in result && result.options.skillsOnly).toBe(true);
60
+ });
61
+ it("parses json format", () => {
62
+ const result = parseCliArgs(["--format", "json", "skills/"]);
63
+ expect("options" in result && result.options.format).toBe("json");
64
+ });
65
+ it("returns error for unknown option", () => {
66
+ const result = parseCliArgs(["--unknown", "skills/"]);
67
+ expect("error" in result).toBe(true);
68
+ });
69
+ it("handles multiple paths", () => {
70
+ const result = parseCliArgs(["a/", "b/"]);
71
+ expect("paths" in result && result.paths).toEqual(["a/", "b/"]);
72
+ });
73
+ });
74
+ describe("formatLintOutput", () => {
75
+ it("formats OK message when no diagnostics", () => {
76
+ const result = { diagnostics: [], skillCount: 2, rubricCount: 1 };
77
+ expect(formatLintOutput(result, "stylish", false)).toBe("[OK] 2 skills and 1 rubric valid.");
78
+ });
79
+ it("formats singular counts", () => {
80
+ const result = { diagnostics: [], skillCount: 1, rubricCount: 0 };
81
+ expect(formatLintOutput(result, "stylish", false)).toBe("[OK] 1 skill valid.");
82
+ });
83
+ it("formats stylish output with diagnostics", () => {
84
+ const result = {
85
+ diagnostics: [
86
+ { rule: "test-rule", message: "bad stuff", file: "/foo/SKILL.md" },
87
+ ],
88
+ skillCount: 1,
89
+ rubricCount: 0,
90
+ };
91
+ const output = formatLintOutput(result, "stylish", false);
92
+ expect(output).toContain("/foo/SKILL.md");
93
+ expect(output).toContain("test-rule");
94
+ expect(output).toContain("1 problem");
95
+ });
96
+ it("formats json output", () => {
97
+ const result = {
98
+ diagnostics: [
99
+ { rule: "r", message: "m", file: "/f" },
100
+ ],
101
+ skillCount: 1,
102
+ rubricCount: 0,
103
+ };
104
+ const output = formatLintOutput(result, "json", false);
105
+ // The JSON output is the first "section" before any tip/fix lines
106
+ // formatJson produces pretty-printed JSON; extract it by finding the closing ]
107
+ const jsonEnd = output.lastIndexOf("]");
108
+ const parsed = JSON.parse(output.slice(0, jsonEnd + 1));
109
+ expect(parsed).toEqual([{ rule: "r", message: "m", file: "/f" }]);
110
+ });
111
+ it("includes fix tip when not fixing", () => {
112
+ const result = {
113
+ diagnostics: [
114
+ { rule: "r", message: "m", file: "/f", fix: () => { } },
115
+ ],
116
+ skillCount: 1,
117
+ rubricCount: 0,
118
+ };
119
+ const output = formatLintOutput(result, "stylish", false);
120
+ expect(output).toContain("re-run with --fix");
121
+ });
122
+ it("includes fix summary when fixing", () => {
123
+ const result = {
124
+ diagnostics: [
125
+ { rule: "r", message: "m", file: "/f", fix: () => { } },
126
+ ],
127
+ skillCount: 1,
128
+ rubricCount: 0,
129
+ };
130
+ const output = formatLintOutput(result, "stylish", true);
131
+ expect(output).toContain("Applied 1 fix.");
132
+ });
133
+ it("pluralizes fix count", () => {
134
+ const result = {
135
+ diagnostics: [
136
+ { rule: "r", message: "m", file: "/f", fix: () => { } },
137
+ { rule: "r2", message: "m2", file: "/f", fix: () => { } },
138
+ ],
139
+ skillCount: 1,
140
+ rubricCount: 0,
141
+ };
142
+ const output = formatLintOutput(result, "stylish", true);
143
+ expect(output).toContain("Applied 2 fixes.");
144
+ });
145
+ it("no fixable diagnostics omits tip", () => {
146
+ const result = {
147
+ diagnostics: [
148
+ { rule: "r", message: "m", file: "/f" },
149
+ ],
150
+ skillCount: 1,
151
+ rubricCount: 0,
152
+ };
153
+ const output = formatLintOutput(result, "stylish", false);
154
+ expect(output).not.toContain("fix");
155
+ });
156
+ });
157
+ describe("run", () => {
158
+ it("returns 0 for valid skill", () => {
159
+ const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
160
+ const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
161
+ try {
162
+ const code = run([resolve(FIXTURES, "valid-skill")]);
163
+ expect(code).toBe(0);
164
+ }
165
+ finally {
166
+ stderrSpy.mockRestore();
167
+ stdoutSpy.mockRestore();
168
+ }
169
+ });
170
+ it("returns 1 for invalid skill", () => {
171
+ const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
172
+ const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
173
+ try {
174
+ const code = run([resolve(FIXTURES, "invalid-skill")]);
175
+ expect(code).toBe(1);
176
+ }
177
+ finally {
178
+ stderrSpy.mockRestore();
179
+ stdoutSpy.mockRestore();
180
+ }
181
+ });
182
+ it("returns 2 for missing paths", () => {
183
+ const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
184
+ try {
185
+ const code = run([]);
186
+ expect(code).toBe(2);
187
+ }
188
+ finally {
189
+ stderrSpy.mockRestore();
190
+ }
191
+ });
192
+ it("returns 0 for help", () => {
193
+ const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
194
+ try {
195
+ const code = run(["--help"]);
196
+ expect(code).toBe(0);
197
+ }
198
+ finally {
199
+ stderrSpy.mockRestore();
200
+ }
201
+ });
202
+ });
@@ -0,0 +1,5 @@
1
+ export interface DiscoverResult {
2
+ skillDirs: string[];
3
+ rubricFiles: string[];
4
+ }
5
+ export declare function discover(basePath: string): DiscoverResult;
@@ -0,0 +1,51 @@
1
+ import { readdirSync, statSync, existsSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ export function discover(basePath) {
4
+ const abs = resolve(basePath);
5
+ const skillDirs = [];
6
+ const rubricFiles = [];
7
+ if (existsSync(join(abs, "SKILL.md"))) {
8
+ skillDirs.push(abs);
9
+ collectRubrics(abs, rubricFiles);
10
+ }
11
+ else {
12
+ let entries;
13
+ try {
14
+ entries = readdirSync(abs);
15
+ }
16
+ catch {
17
+ return { skillDirs, rubricFiles };
18
+ }
19
+ for (const entry of entries.sort()) {
20
+ const child = join(abs, entry);
21
+ try {
22
+ if (!statSync(child).isDirectory())
23
+ continue;
24
+ }
25
+ catch {
26
+ continue;
27
+ }
28
+ if (existsSync(join(child, "SKILL.md")))
29
+ skillDirs.push(child);
30
+ collectRubrics(child, rubricFiles);
31
+ }
32
+ }
33
+ return { skillDirs, rubricFiles: rubricFiles.sort() };
34
+ }
35
+ function collectRubrics(dir, out) {
36
+ const refsDir = join(dir, "references");
37
+ if (!existsSync(refsDir))
38
+ return;
39
+ let entries;
40
+ try {
41
+ entries = readdirSync(refsDir);
42
+ }
43
+ catch {
44
+ return;
45
+ }
46
+ for (const entry of entries) {
47
+ if (entry.includes("rubric") && entry.endsWith(".md")) {
48
+ out.push(join(refsDir, entry));
49
+ }
50
+ }
51
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { resolve } from "node:path";
3
+ import { discover } from "./discover.js";
4
+ const FIXTURES = resolve(import.meta.dirname, "../fixtures");
5
+ describe("discover", () => {
6
+ it("finds a single skill dir", () => {
7
+ const result = discover(resolve(FIXTURES, "valid-skill"));
8
+ expect(result.skillDirs).toEqual([resolve(FIXTURES, "valid-skill")]);
9
+ });
10
+ it("finds children skill dirs from parent", () => {
11
+ const result = discover(FIXTURES);
12
+ expect(result.skillDirs.length).toBeGreaterThanOrEqual(2);
13
+ expect(result.skillDirs).toContain(resolve(FIXTURES, "valid-skill"));
14
+ expect(result.skillDirs).toContain(resolve(FIXTURES, "invalid-skill"));
15
+ });
16
+ it("collects rubric files", () => {
17
+ const result = discover(resolve(FIXTURES, "valid-rubric"));
18
+ expect(result.rubricFiles.length).toBe(1);
19
+ expect(result.rubricFiles[0]).toContain("test-rubric.md");
20
+ });
21
+ it("returns empty for nonexistent path", () => {
22
+ const result = discover("/nonexistent/path/that/does/not/exist");
23
+ expect(result.skillDirs).toEqual([]);
24
+ expect(result.rubricFiles).toEqual([]);
25
+ });
26
+ it("collects rubrics from parent scan", () => {
27
+ const result = discover(FIXTURES);
28
+ expect(result.rubricFiles.length).toBeGreaterThanOrEqual(1);
29
+ });
30
+ });
@@ -0,0 +1,2 @@
1
+ import type { LintDiagnostic } from "../types.js";
2
+ export declare function formatJson(diagnostics: LintDiagnostic[]): string;
@@ -0,0 +1,8 @@
1
+ export function formatJson(diagnostics) {
2
+ const output = diagnostics.map((d) => ({
3
+ rule: d.rule,
4
+ message: d.message,
5
+ file: d.file,
6
+ }));
7
+ return JSON.stringify(output, null, 2);
8
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { formatJson } from "./json.js";
3
+ describe("formatJson", () => {
4
+ it("returns empty array for no diagnostics", () => {
5
+ expect(JSON.parse(formatJson([]))).toEqual([]);
6
+ });
7
+ it("maps diagnostics correctly", () => {
8
+ const result = JSON.parse(formatJson([
9
+ { rule: "r1", message: "m1", file: "/f1" },
10
+ { rule: "r2", message: "m2", file: "/f2", fix: () => { } },
11
+ ]));
12
+ expect(result).toEqual([
13
+ { rule: "r1", message: "m1", file: "/f1" },
14
+ { rule: "r2", message: "m2", file: "/f2" },
15
+ ]);
16
+ });
17
+ it("excludes fix functions from output", () => {
18
+ const output = formatJson([
19
+ { rule: "r", message: "m", file: "/f", fix: () => { } },
20
+ ]);
21
+ expect(output).not.toContain("fix");
22
+ });
23
+ });
@@ -0,0 +1,2 @@
1
+ import type { LintDiagnostic } from "../types.js";
2
+ export declare function formatStylish(diagnostics: LintDiagnostic[]): string;
@@ -0,0 +1,25 @@
1
+ export function formatStylish(diagnostics) {
2
+ if (diagnostics.length === 0)
3
+ return "";
4
+ // Group by file
5
+ const byFile = new Map();
6
+ for (const d of diagnostics) {
7
+ const existing = byFile.get(d.file);
8
+ if (existing) {
9
+ existing.push(d);
10
+ }
11
+ else {
12
+ byFile.set(d.file, [d]);
13
+ }
14
+ }
15
+ const lines = [];
16
+ for (const [file, diags] of byFile) {
17
+ lines.push(file);
18
+ for (const d of diags) {
19
+ lines.push(` ${d.rule} ${d.message}`);
20
+ }
21
+ lines.push("");
22
+ }
23
+ lines.push(`${diagnostics.length} problem${diagnostics.length === 1 ? "" : "s"}`);
24
+ return lines.join("\n");
25
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { formatStylish } from "./stylish.js";
3
+ describe("formatStylish", () => {
4
+ it("returns empty string for no diagnostics", () => {
5
+ expect(formatStylish([])).toBe("");
6
+ });
7
+ it("groups diagnostics by file", () => {
8
+ const output = formatStylish([
9
+ { rule: "r1", message: "m1", file: "/a/SKILL.md" },
10
+ { rule: "r2", message: "m2", file: "/b/SKILL.md" },
11
+ { rule: "r3", message: "m3", file: "/a/SKILL.md" },
12
+ ]);
13
+ const lines = output.split("\n");
14
+ expect(lines[0]).toBe("/a/SKILL.md");
15
+ expect(lines[1]).toBe(" r1 m1");
16
+ expect(lines[2]).toBe(" r3 m3");
17
+ expect(lines[4]).toBe("/b/SKILL.md");
18
+ expect(lines[5]).toBe(" r2 m2");
19
+ });
20
+ it("shows singular 'problem' for 1 diagnostic", () => {
21
+ const output = formatStylish([
22
+ { rule: "r", message: "m", file: "/f" },
23
+ ]);
24
+ expect(output).toContain("1 problem");
25
+ expect(output).not.toContain("problems");
26
+ });
27
+ it("shows plural 'problems' for multiple diagnostics", () => {
28
+ const output = formatStylish([
29
+ { rule: "r1", message: "m1", file: "/f" },
30
+ { rule: "r2", message: "m2", file: "/f" },
31
+ ]);
32
+ expect(output).toContain("2 problems");
33
+ });
34
+ });
@@ -0,0 +1,3 @@
1
+ import type { Frontmatter, FrontmatterBlock, MarkdownText } from "./types.js";
2
+ export declare function readFrontmatterBlock(text: MarkdownText): FrontmatterBlock;
3
+ export declare function parseFrontmatter(frontmatterText: FrontmatterBlock): Frontmatter;
@@ -0,0 +1,108 @@
1
+ export function readFrontmatterBlock(text) {
2
+ if (!text.startsWith("---\n")) {
3
+ throw new Error("SKILL.md must start with YAML frontmatter (---).");
4
+ }
5
+ const end = text.indexOf("\n---\n", 4);
6
+ if (end === -1) {
7
+ throw new Error("SKILL.md frontmatter is missing closing --- delimiter.");
8
+ }
9
+ return text.slice(4, end);
10
+ }
11
+ export function parseFrontmatter(frontmatterText) {
12
+ const keys = new Set();
13
+ let name;
14
+ let description;
15
+ const lines = frontmatterText.split("\n");
16
+ let index = 0;
17
+ while (index < lines.length) {
18
+ const line = lines[index];
19
+ if (!line.trim() || line.trimStart().startsWith("#")) {
20
+ index++;
21
+ continue;
22
+ }
23
+ const match = line.match(/^([A-Za-z0-9_-]+):(?:\s*(.*))?$/);
24
+ if (!match) {
25
+ throw new Error(`Invalid frontmatter line: ${JSON.stringify(line)}`);
26
+ }
27
+ const key = match[1];
28
+ const rest = (match[2] ?? "").trimEnd();
29
+ keys.add(key);
30
+ // Block scalars for description: `description: >` or `description: |`
31
+ if (key === "description" && [">", "|", ">-", "|-"].includes(rest.trim())) {
32
+ const indicator = rest.trim();
33
+ index++;
34
+ const blockLines = [];
35
+ let indent;
36
+ while (index < lines.length) {
37
+ const raw = lines[index];
38
+ if (raw.trim() === "") {
39
+ blockLines.push("");
40
+ index++;
41
+ continue;
42
+ }
43
+ const leading = raw.length - raw.trimStart().length;
44
+ if (indent === undefined) {
45
+ indent = leading;
46
+ }
47
+ if (leading < (indent ?? 0)) {
48
+ break;
49
+ }
50
+ blockLines.push(raw.slice(indent ?? 0));
51
+ index++;
52
+ }
53
+ if (indicator.startsWith(">")) {
54
+ // Fold: join lines with spaces; preserve blank lines as newlines.
55
+ const folded = [];
56
+ let paragraph = [];
57
+ for (const bl of blockLines) {
58
+ if (bl === "") {
59
+ if (paragraph.length) {
60
+ folded.push(paragraph.join(" ").trim());
61
+ paragraph = [];
62
+ }
63
+ folded.push("");
64
+ }
65
+ else {
66
+ paragraph.push(bl.trimEnd());
67
+ }
68
+ }
69
+ if (paragraph.length) {
70
+ folded.push(paragraph.join(" ").trim());
71
+ }
72
+ description = folded.join("\n").trim();
73
+ }
74
+ else {
75
+ description = blockLines.join("\n").trim();
76
+ }
77
+ continue;
78
+ }
79
+ // Inline scalar
80
+ if (key === "name") {
81
+ let value = rest.trim();
82
+ value = value.replace(/^["']|["']$/g, "").trim();
83
+ if (value) {
84
+ name = value;
85
+ }
86
+ }
87
+ else if (key === "description") {
88
+ let value = rest.trim();
89
+ value = value.replace(/^["']|["']$/g, "").trim();
90
+ if (value) {
91
+ description = value;
92
+ }
93
+ }
94
+ // Mapping key with no scalar (e.g., `metadata:`), skip nested lines.
95
+ if (rest.trim() === "") {
96
+ index++;
97
+ continue;
98
+ }
99
+ index++;
100
+ }
101
+ if (name === undefined) {
102
+ throw new Error("Missing required frontmatter key: name");
103
+ }
104
+ if (description === undefined) {
105
+ throw new Error("Missing required frontmatter key: description");
106
+ }
107
+ return { keys, name: name.trim(), description: description.trim() };
108
+ }
@@ -0,0 +1 @@
1
+ export {};