@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.
- package/bin/skillcheck.js +5 -0
- package/dist/cli.d.ts +20 -0
- package/dist/cli.js +101 -0
- package/dist/cli.test.d.ts +1 -0
- package/dist/cli.test.js +202 -0
- package/dist/discover.d.ts +5 -0
- package/dist/discover.js +51 -0
- package/dist/discover.test.d.ts +1 -0
- package/dist/discover.test.js +30 -0
- package/dist/formatters/json.d.ts +2 -0
- package/dist/formatters/json.js +8 -0
- package/dist/formatters/json.test.d.ts +1 -0
- package/dist/formatters/json.test.js +23 -0
- package/dist/formatters/stylish.d.ts +2 -0
- package/dist/formatters/stylish.js +25 -0
- package/dist/formatters/stylish.test.d.ts +1 -0
- package/dist/formatters/stylish.test.js +34 -0
- package/dist/frontmatter.d.ts +3 -0
- package/dist/frontmatter.js +108 -0
- package/dist/frontmatter.test.d.ts +1 -0
- package/dist/frontmatter.test.js +55 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +107 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +52 -0
- package/dist/rules/description-constraints.d.ts +2 -0
- package/dist/rules/description-constraints.js +29 -0
- package/dist/rules/frontmatter-keys.d.ts +2 -0
- package/dist/rules/frontmatter-keys.js +26 -0
- package/dist/rules/frontmatter-valid.d.ts +2 -0
- package/dist/rules/frontmatter-valid.js +16 -0
- package/dist/rules/index.d.ts +3 -0
- package/dist/rules/index.js +32 -0
- package/dist/rules/line-count.d.ts +2 -0
- package/dist/rules/line-count.js +16 -0
- package/dist/rules/links-resolve.d.ts +3 -0
- package/dist/rules/links-resolve.js +44 -0
- package/dist/rules/links-within-skill-dir.d.ts +2 -0
- package/dist/rules/links-within-skill-dir.js +19 -0
- package/dist/rules/name-constraints.d.ts +2 -0
- package/dist/rules/name-constraints.js +38 -0
- package/dist/rules/name-matches-dir.d.ts +2 -0
- package/dist/rules/name-matches-dir.js +16 -0
- package/dist/rules/no-deep-chaining.d.ts +3 -0
- package/dist/rules/no-deep-chaining.js +39 -0
- package/dist/rules/no-deep-chaining.test.d.ts +1 -0
- package/dist/rules/no-deep-chaining.test.js +50 -0
- package/dist/rules/no-placeholders.d.ts +2 -0
- package/dist/rules/no-placeholders.js +20 -0
- package/dist/rules/openai-yaml-sanity.d.ts +3 -0
- package/dist/rules/openai-yaml-sanity.js +22 -0
- package/dist/rules/openai-yaml-sanity.test.d.ts +1 -0
- package/dist/rules/openai-yaml-sanity.test.js +65 -0
- package/dist/rules/rubric-evidence.d.ts +2 -0
- package/dist/rules/rubric-evidence.js +14 -0
- package/dist/rules/rubric-grade-bands.d.ts +5 -0
- package/dist/rules/rubric-grade-bands.js +43 -0
- package/dist/rules/rubric-priorities.d.ts +4 -0
- package/dist/rules/rubric-priorities.js +41 -0
- package/dist/rules/rubric-rules.test.d.ts +1 -0
- package/dist/rules/rubric-rules.test.js +112 -0
- package/dist/rules/skill-md-exists.d.ts +2 -0
- package/dist/rules/skill-md-exists.js +13 -0
- package/dist/rules/skill-rules.test.d.ts +1 -0
- package/dist/rules/skill-rules.test.js +229 -0
- package/dist/types.d.ts +67 -0
- package/dist/types.js +1 -0
- package/package.json +32 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { readFrontmatterBlock, parseFrontmatter } from "./frontmatter.js";
|
|
3
|
+
describe("readFrontmatterBlock", () => {
|
|
4
|
+
it("extracts frontmatter between --- delimiters", () => {
|
|
5
|
+
const text = "---\nname: foo\n---\n\n# Body";
|
|
6
|
+
expect(readFrontmatterBlock(text)).toBe("name: foo");
|
|
7
|
+
});
|
|
8
|
+
it("throws if no opening ---", () => {
|
|
9
|
+
expect(() => readFrontmatterBlock("name: foo\n---\n")).toThrow("must start with YAML frontmatter");
|
|
10
|
+
});
|
|
11
|
+
it("throws if no closing ---", () => {
|
|
12
|
+
expect(() => readFrontmatterBlock("---\nname: foo\n")).toThrow("missing closing --- delimiter");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
describe("parseFrontmatter", () => {
|
|
16
|
+
it("parses inline name and description", () => {
|
|
17
|
+
const fm = parseFrontmatter("name: my-skill\ndescription: A test skill.");
|
|
18
|
+
expect(fm.name).toBe("my-skill");
|
|
19
|
+
expect(fm.description).toBe("A test skill.");
|
|
20
|
+
expect(fm.keys).toEqual(new Set(["name", "description"]));
|
|
21
|
+
});
|
|
22
|
+
it("parses block scalar description (folded >)", () => {
|
|
23
|
+
const fm = parseFrontmatter("name: my-skill\ndescription: >\n Line one\n line two.");
|
|
24
|
+
expect(fm.name).toBe("my-skill");
|
|
25
|
+
expect(fm.description).toBe("Line one line two.");
|
|
26
|
+
});
|
|
27
|
+
it("parses block scalar description (literal |)", () => {
|
|
28
|
+
const fm = parseFrontmatter("name: my-skill\ndescription: |\n Line one\n line two.");
|
|
29
|
+
expect(fm.name).toBe("my-skill");
|
|
30
|
+
expect(fm.description).toBe("Line one\nline two.");
|
|
31
|
+
});
|
|
32
|
+
it("strips quotes from inline values", () => {
|
|
33
|
+
const fm = parseFrontmatter('name: "my-skill"\ndescription: \'A test.\'');
|
|
34
|
+
expect(fm.name).toBe("my-skill");
|
|
35
|
+
expect(fm.description).toBe("A test.");
|
|
36
|
+
});
|
|
37
|
+
it("throws on missing name", () => {
|
|
38
|
+
expect(() => parseFrontmatter("description: foo")).toThrow("Missing required frontmatter key: name");
|
|
39
|
+
});
|
|
40
|
+
it("throws on missing description", () => {
|
|
41
|
+
expect(() => parseFrontmatter("name: foo")).toThrow("Missing required frontmatter key: description");
|
|
42
|
+
});
|
|
43
|
+
it("throws on invalid line", () => {
|
|
44
|
+
expect(() => parseFrontmatter("name: foo\n bad indent\ndescription: bar")).toThrow("Invalid frontmatter line");
|
|
45
|
+
});
|
|
46
|
+
it("collects all keys", () => {
|
|
47
|
+
const fm = parseFrontmatter("name: foo\ndescription: bar\nlicense: MIT");
|
|
48
|
+
expect(fm.keys).toEqual(new Set(["name", "description", "license"]));
|
|
49
|
+
});
|
|
50
|
+
it("skips empty lines and comments", () => {
|
|
51
|
+
const fm = parseFrontmatter("name: foo\n\n# comment\ndescription: bar");
|
|
52
|
+
expect(fm.name).toBe("foo");
|
|
53
|
+
expect(fm.description).toBe("bar");
|
|
54
|
+
});
|
|
55
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { LintOptions, LintResult, LintDiagnostic, Frontmatter } from "./types.js";
|
|
2
|
+
export type { LintOptions, LintResult, LintDiagnostic, Frontmatter };
|
|
3
|
+
export { readFrontmatterBlock, parseFrontmatter } from "./frontmatter.js";
|
|
4
|
+
export { discover } from "./discover.js";
|
|
5
|
+
export declare function applyFixes(diagnostics: LintDiagnostic[]): number;
|
|
6
|
+
export declare function lint(paths: string[], options?: LintOptions): LintResult;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve, extname } from "node:path";
|
|
3
|
+
import { discover } from "./discover.js";
|
|
4
|
+
import { readFrontmatterBlock, parseFrontmatter } from "./frontmatter.js";
|
|
5
|
+
import { skillRules, rubricRules } from "./rules/index.js";
|
|
6
|
+
export { readFrontmatterBlock, parseFrontmatter } from "./frontmatter.js";
|
|
7
|
+
export { discover } from "./discover.js";
|
|
8
|
+
const DEFAULT_MAX_LINES = 500;
|
|
9
|
+
function collectFiles(dir, files, markdownFiles) {
|
|
10
|
+
let entries;
|
|
11
|
+
try {
|
|
12
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
for (const entry of entries) {
|
|
18
|
+
const fullPath = join(dir, entry.name);
|
|
19
|
+
if (entry.isDirectory()) {
|
|
20
|
+
collectFiles(fullPath, files, markdownFiles);
|
|
21
|
+
}
|
|
22
|
+
else if (entry.isFile()) {
|
|
23
|
+
files.add(fullPath);
|
|
24
|
+
const ext = extname(entry.name).toLowerCase();
|
|
25
|
+
if (ext === ".md" || ext === ".markdown") {
|
|
26
|
+
try {
|
|
27
|
+
markdownFiles.set(fullPath, readFileSync(fullPath, "utf-8"));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// skip unreadable files
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function applyFixes(diagnostics) {
|
|
37
|
+
let count = 0;
|
|
38
|
+
for (const d of diagnostics) {
|
|
39
|
+
if (d.fix) {
|
|
40
|
+
d.fix();
|
|
41
|
+
count++;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return count;
|
|
45
|
+
}
|
|
46
|
+
function buildSkillContext(dir, maxLines) {
|
|
47
|
+
const resolvedDir = resolve(dir);
|
|
48
|
+
const skillMdPath = join(resolvedDir, "SKILL.md");
|
|
49
|
+
let text = "";
|
|
50
|
+
let frontmatter;
|
|
51
|
+
if (existsSync(skillMdPath)) {
|
|
52
|
+
text = readFileSync(skillMdPath, "utf-8");
|
|
53
|
+
try {
|
|
54
|
+
const block = readFrontmatterBlock(text);
|
|
55
|
+
frontmatter = parseFrontmatter(block);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// frontmatter stays undefined — frontmatter-valid rule will flag it
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
let openaiYaml;
|
|
62
|
+
try {
|
|
63
|
+
openaiYaml = readFileSync(join(resolvedDir, "agents", "openai.yaml"), "utf-8");
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// undefined if absent
|
|
67
|
+
}
|
|
68
|
+
const files = new Set();
|
|
69
|
+
const markdownFiles = new Map();
|
|
70
|
+
collectFiles(resolvedDir, files, markdownFiles);
|
|
71
|
+
return { dir: resolvedDir, text, frontmatter, maxLines, openaiYaml, files, markdownFiles };
|
|
72
|
+
}
|
|
73
|
+
function buildRubricContext(file, fix) {
|
|
74
|
+
const resolvedFile = resolve(file);
|
|
75
|
+
const text = readFileSync(resolvedFile, "utf-8");
|
|
76
|
+
const writeFile = fix ? (content) => writeFileSync(resolvedFile, content, "utf-8") : undefined;
|
|
77
|
+
return { file: resolvedFile, text, fix, writeFile };
|
|
78
|
+
}
|
|
79
|
+
export function lint(paths, options = {}) {
|
|
80
|
+
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
|
|
81
|
+
const fix = options.fix ?? false;
|
|
82
|
+
const diagnostics = [];
|
|
83
|
+
let skillCount = 0;
|
|
84
|
+
let rubricCount = 0;
|
|
85
|
+
for (const p of paths) {
|
|
86
|
+
const { skillDirs, rubricFiles } = discover(p);
|
|
87
|
+
if (!options.rubricsOnly) {
|
|
88
|
+
for (const dir of skillDirs) {
|
|
89
|
+
skillCount++;
|
|
90
|
+
const ctx = buildSkillContext(dir, maxLines);
|
|
91
|
+
for (const rule of skillRules)
|
|
92
|
+
diagnostics.push(...rule(ctx));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!options.skillsOnly) {
|
|
96
|
+
for (const file of rubricFiles) {
|
|
97
|
+
rubricCount++;
|
|
98
|
+
const ctx = buildRubricContext(file, fix);
|
|
99
|
+
for (const rule of rubricRules)
|
|
100
|
+
diagnostics.push(...rule(ctx));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (fix)
|
|
105
|
+
applyFixes(diagnostics);
|
|
106
|
+
return { diagnostics, skillCount, rubricCount };
|
|
107
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { lint, applyFixes } from "./index.js";
|
|
4
|
+
const FIXTURES = resolve(import.meta.dirname, "../fixtures");
|
|
5
|
+
describe("lint", () => {
|
|
6
|
+
it("returns 0 diagnostics for valid-skill", () => {
|
|
7
|
+
const result = lint([resolve(FIXTURES, "valid-skill")]);
|
|
8
|
+
expect(result.diagnostics).toEqual([]);
|
|
9
|
+
expect(result.skillCount).toBe(1);
|
|
10
|
+
});
|
|
11
|
+
it("returns diagnostics for invalid-skill", () => {
|
|
12
|
+
const result = lint([resolve(FIXTURES, "invalid-skill")]);
|
|
13
|
+
expect(result.diagnostics.length).toBeGreaterThan(0);
|
|
14
|
+
expect(result.skillCount).toBe(1);
|
|
15
|
+
});
|
|
16
|
+
it("discovers skills from parent dir", () => {
|
|
17
|
+
const result = lint([FIXTURES], { skillsOnly: true });
|
|
18
|
+
expect(result.skillCount).toBeGreaterThanOrEqual(2);
|
|
19
|
+
});
|
|
20
|
+
it("skips skills with rubricsOnly", () => {
|
|
21
|
+
const result = lint([FIXTURES], { rubricsOnly: true });
|
|
22
|
+
expect(result.skillCount).toBe(0);
|
|
23
|
+
});
|
|
24
|
+
it("skips rubrics with skillsOnly", () => {
|
|
25
|
+
const result = lint([resolve(FIXTURES, "valid-rubric")], { skillsOnly: true });
|
|
26
|
+
expect(result.rubricCount).toBe(0);
|
|
27
|
+
});
|
|
28
|
+
it("counts rubrics", () => {
|
|
29
|
+
const result = lint([resolve(FIXTURES, "valid-rubric")], { rubricsOnly: true });
|
|
30
|
+
expect(result.rubricCount).toBe(1);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe("applyFixes", () => {
|
|
34
|
+
it("calls fix functions and returns count", () => {
|
|
35
|
+
const fix1 = vi.fn();
|
|
36
|
+
const fix2 = vi.fn();
|
|
37
|
+
const diagnostics = [
|
|
38
|
+
{ rule: "r1", message: "m1", file: "/f1", fix: fix1 },
|
|
39
|
+
{ rule: "r2", message: "m2", file: "/f2" },
|
|
40
|
+
{ rule: "r3", message: "m3", file: "/f3", fix: fix2 },
|
|
41
|
+
];
|
|
42
|
+
expect(applyFixes(diagnostics)).toBe(2);
|
|
43
|
+
expect(fix1).toHaveBeenCalledOnce();
|
|
44
|
+
expect(fix2).toHaveBeenCalledOnce();
|
|
45
|
+
});
|
|
46
|
+
it("returns 0 for no fixable diagnostics", () => {
|
|
47
|
+
expect(applyFixes([{ rule: "r", message: "m", file: "/f" }])).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
it("returns 0 for empty list", () => {
|
|
50
|
+
expect(applyFixes([])).toBe(0);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
export function descriptionConstraints(ctx) {
|
|
3
|
+
if (!ctx.frontmatter)
|
|
4
|
+
return [];
|
|
5
|
+
const desc = ctx.frontmatter.description;
|
|
6
|
+
const file = join(ctx.dir, "SKILL.md");
|
|
7
|
+
if (!desc.trim()) {
|
|
8
|
+
return [{ rule: "description-constraints", message: "Frontmatter description is empty.", file }];
|
|
9
|
+
}
|
|
10
|
+
if (desc.length > 1024) {
|
|
11
|
+
return [
|
|
12
|
+
{
|
|
13
|
+
rule: "description-constraints",
|
|
14
|
+
message: `Frontmatter description too long (${desc.length} > 1024).`,
|
|
15
|
+
file,
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
if (desc.includes("<") || desc.includes(">")) {
|
|
20
|
+
return [
|
|
21
|
+
{
|
|
22
|
+
rule: "description-constraints",
|
|
23
|
+
message: "Frontmatter description cannot contain '<' or '>'.",
|
|
24
|
+
file,
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
const ALLOWED_FRONTMATTER_KEYS = new Set([
|
|
3
|
+
"name",
|
|
4
|
+
"description",
|
|
5
|
+
"license",
|
|
6
|
+
"compatibility",
|
|
7
|
+
"metadata",
|
|
8
|
+
"allowed-tools",
|
|
9
|
+
]);
|
|
10
|
+
export function frontmatterKeys(ctx) {
|
|
11
|
+
if (!ctx.frontmatter)
|
|
12
|
+
return [];
|
|
13
|
+
const unexpected = [...ctx.frontmatter.keys]
|
|
14
|
+
.filter((k) => !ALLOWED_FRONTMATTER_KEYS.has(k))
|
|
15
|
+
.sort();
|
|
16
|
+
if (unexpected.length === 0)
|
|
17
|
+
return [];
|
|
18
|
+
const allowed = [...ALLOWED_FRONTMATTER_KEYS].sort().join(", ");
|
|
19
|
+
return [
|
|
20
|
+
{
|
|
21
|
+
rule: "frontmatter-keys",
|
|
22
|
+
message: `Unexpected frontmatter key(s): ${unexpected.join(", ")}. Allowed: ${allowed}`,
|
|
23
|
+
file: join(ctx.dir, "SKILL.md"),
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
export function frontmatterValid(ctx) {
|
|
3
|
+
if (!ctx.text)
|
|
4
|
+
return [];
|
|
5
|
+
if (ctx.frontmatter)
|
|
6
|
+
return [];
|
|
7
|
+
// If frontmatter is undefined but text exists, parsing must have failed.
|
|
8
|
+
// The error message is set by the caller during context construction.
|
|
9
|
+
return [
|
|
10
|
+
{
|
|
11
|
+
rule: "frontmatter-valid",
|
|
12
|
+
message: "Frontmatter parsing failed",
|
|
13
|
+
file: join(ctx.dir, "SKILL.md"),
|
|
14
|
+
},
|
|
15
|
+
];
|
|
16
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { skillMdExists } from "./skill-md-exists.js";
|
|
2
|
+
import { frontmatterValid } from "./frontmatter-valid.js";
|
|
3
|
+
import { frontmatterKeys } from "./frontmatter-keys.js";
|
|
4
|
+
import { nameConstraints } from "./name-constraints.js";
|
|
5
|
+
import { nameMatchesDir } from "./name-matches-dir.js";
|
|
6
|
+
import { descriptionConstraints } from "./description-constraints.js";
|
|
7
|
+
import { lineCount } from "./line-count.js";
|
|
8
|
+
import { noPlaceholders } from "./no-placeholders.js";
|
|
9
|
+
import { linksResolve } from "./links-resolve.js";
|
|
10
|
+
import { noDeepChaining } from "./no-deep-chaining.js";
|
|
11
|
+
import { openaiYamlSanity } from "./openai-yaml-sanity.js";
|
|
12
|
+
import { rubricGradeBands } from "./rubric-grade-bands.js";
|
|
13
|
+
import { rubricPriorities } from "./rubric-priorities.js";
|
|
14
|
+
import { rubricEvidence } from "./rubric-evidence.js";
|
|
15
|
+
export const skillRules = [
|
|
16
|
+
skillMdExists,
|
|
17
|
+
frontmatterValid,
|
|
18
|
+
frontmatterKeys,
|
|
19
|
+
nameConstraints,
|
|
20
|
+
nameMatchesDir,
|
|
21
|
+
descriptionConstraints,
|
|
22
|
+
lineCount,
|
|
23
|
+
noPlaceholders,
|
|
24
|
+
linksResolve,
|
|
25
|
+
noDeepChaining,
|
|
26
|
+
openaiYamlSanity,
|
|
27
|
+
];
|
|
28
|
+
export const rubricRules = [
|
|
29
|
+
rubricGradeBands,
|
|
30
|
+
rubricPriorities,
|
|
31
|
+
rubricEvidence,
|
|
32
|
+
];
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
export function lineCount(ctx) {
|
|
3
|
+
if (!ctx.text)
|
|
4
|
+
return [];
|
|
5
|
+
const count = ctx.text.split("\n").length;
|
|
6
|
+
if (count > ctx.maxLines) {
|
|
7
|
+
return [
|
|
8
|
+
{
|
|
9
|
+
rule: "line-count",
|
|
10
|
+
message: `SKILL.md too long: ${count} lines (max ${ctx.maxLines}).`,
|
|
11
|
+
file: join(ctx.dir, "SKILL.md"),
|
|
12
|
+
},
|
|
13
|
+
];
|
|
14
|
+
}
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
export function extractLocalMarkdownLinks(text) {
|
|
3
|
+
const links = new Set();
|
|
4
|
+
const re = /\]\(([^)]+)\)/g;
|
|
5
|
+
let match;
|
|
6
|
+
while ((match = re.exec(text)) !== null) {
|
|
7
|
+
let target = match[1].trim();
|
|
8
|
+
if (!target)
|
|
9
|
+
continue;
|
|
10
|
+
if (target.includes("://") || target.startsWith("mailto:"))
|
|
11
|
+
continue;
|
|
12
|
+
target = target.split("#", 1)[0].trim();
|
|
13
|
+
if (!target || target.startsWith("/"))
|
|
14
|
+
continue;
|
|
15
|
+
links.add(target);
|
|
16
|
+
}
|
|
17
|
+
return links;
|
|
18
|
+
}
|
|
19
|
+
export function linksResolve(ctx) {
|
|
20
|
+
if (!ctx.text)
|
|
21
|
+
return [];
|
|
22
|
+
const diagnostics = [];
|
|
23
|
+
const links = extractLocalMarkdownLinks(ctx.text);
|
|
24
|
+
const file = join(ctx.dir, "SKILL.md");
|
|
25
|
+
for (const link of [...links].sort()) {
|
|
26
|
+
const resolved = resolve(ctx.dir, link);
|
|
27
|
+
if (!resolved.startsWith(ctx.dir)) {
|
|
28
|
+
diagnostics.push({
|
|
29
|
+
rule: "links-resolve",
|
|
30
|
+
message: `SKILL.md links outside skill dir: ${link}`,
|
|
31
|
+
file,
|
|
32
|
+
});
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (!ctx.files.has(resolved)) {
|
|
36
|
+
diagnostics.push({
|
|
37
|
+
rule: "links-resolve",
|
|
38
|
+
message: `Broken link target in SKILL.md: ${link}`,
|
|
39
|
+
file,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return diagnostics;
|
|
44
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import { extractLocalMarkdownLinks } from "./links-resolve.js";
|
|
3
|
+
export function linksWithinSkillDir(ctx) {
|
|
4
|
+
if (!ctx.text)
|
|
5
|
+
return [];
|
|
6
|
+
const diagnostics = [];
|
|
7
|
+
const links = extractLocalMarkdownLinks(ctx.text);
|
|
8
|
+
for (const link of [...links].sort()) {
|
|
9
|
+
const resolved = resolve(ctx.dir, link);
|
|
10
|
+
if (!resolved.startsWith(ctx.dir)) {
|
|
11
|
+
diagnostics.push({
|
|
12
|
+
rule: "links-within-skill-dir",
|
|
13
|
+
message: `SKILL.md links outside skill dir: ${link}`,
|
|
14
|
+
file: join(ctx.dir, "SKILL.md"),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return diagnostics;
|
|
19
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
export function nameConstraints(ctx) {
|
|
3
|
+
if (!ctx.frontmatter)
|
|
4
|
+
return [];
|
|
5
|
+
const name = ctx.frontmatter.name;
|
|
6
|
+
const file = join(ctx.dir, "SKILL.md");
|
|
7
|
+
if (!name) {
|
|
8
|
+
return [{ rule: "name-constraints", message: "Frontmatter name is empty.", file }];
|
|
9
|
+
}
|
|
10
|
+
if (name.length > 64) {
|
|
11
|
+
return [
|
|
12
|
+
{
|
|
13
|
+
rule: "name-constraints",
|
|
14
|
+
message: `Frontmatter name too long (${name.length} > 64).`,
|
|
15
|
+
file,
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
if (!/^[a-z0-9-]+$/.test(name)) {
|
|
20
|
+
return [
|
|
21
|
+
{
|
|
22
|
+
rule: "name-constraints",
|
|
23
|
+
message: "Frontmatter name must match ^[a-z0-9-]+$.",
|
|
24
|
+
file,
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
if (name.startsWith("-") || name.endsWith("-") || name.includes("--")) {
|
|
29
|
+
return [
|
|
30
|
+
{
|
|
31
|
+
rule: "name-constraints",
|
|
32
|
+
message: "Frontmatter name cannot start/end with '-' or contain '--'.",
|
|
33
|
+
file,
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { basename, join } from "node:path";
|
|
2
|
+
export function nameMatchesDir(ctx) {
|
|
3
|
+
if (!ctx.frontmatter)
|
|
4
|
+
return [];
|
|
5
|
+
const dirName = basename(ctx.dir);
|
|
6
|
+
if (ctx.frontmatter.name !== dirName) {
|
|
7
|
+
return [
|
|
8
|
+
{
|
|
9
|
+
rule: "name-matches-dir",
|
|
10
|
+
message: `Frontmatter name '${ctx.frontmatter.name}' must match directory name '${dirName}'.`,
|
|
11
|
+
file: join(ctx.dir, "SKILL.md"),
|
|
12
|
+
},
|
|
13
|
+
];
|
|
14
|
+
}
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { SkillContext, LintDiagnostic, MarkdownText, RelativeLink, ChainDescription } from "../types.js";
|
|
2
|
+
export declare function findDeepChains(dir: string, links: Set<RelativeLink>, readFile: (path: string) => MarkdownText | null): ChainDescription[];
|
|
3
|
+
export declare function noDeepChaining(ctx: SkillContext): LintDiagnostic[];
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { join, resolve, relative, extname, dirname } from "node:path";
|
|
2
|
+
import { extractLocalMarkdownLinks } from "./links-resolve.js";
|
|
3
|
+
function isProbablyMarkdown(filePath) {
|
|
4
|
+
const ext = extname(filePath).toLowerCase();
|
|
5
|
+
return ext === ".md" || ext === ".markdown";
|
|
6
|
+
}
|
|
7
|
+
export function findDeepChains(dir, links, readFile) {
|
|
8
|
+
const results = [];
|
|
9
|
+
for (const link of [...links].sort()) {
|
|
10
|
+
const resolved = resolve(dir, link);
|
|
11
|
+
if (!resolved.startsWith(dir) || !isProbablyMarkdown(resolved))
|
|
12
|
+
continue;
|
|
13
|
+
const content = readFile(resolved);
|
|
14
|
+
if (content === null)
|
|
15
|
+
continue;
|
|
16
|
+
const refLinks = extractLocalMarkdownLinks(content);
|
|
17
|
+
for (const rl of [...refLinks].sort()) {
|
|
18
|
+
const candidate = resolve(dirname(resolved), rl);
|
|
19
|
+
const candidateContent = readFile(candidate);
|
|
20
|
+
if (candidateContent !== null && isProbablyMarkdown(candidate)) {
|
|
21
|
+
results.push(`${relative(dir, resolved)} links to ${rl}`);
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return results;
|
|
27
|
+
}
|
|
28
|
+
export function noDeepChaining(ctx) {
|
|
29
|
+
if (!ctx.text)
|
|
30
|
+
return [];
|
|
31
|
+
const readFile = (path) => ctx.markdownFiles.get(path) ?? null;
|
|
32
|
+
const links = extractLocalMarkdownLinks(ctx.text);
|
|
33
|
+
const chains = findDeepChains(ctx.dir, links, readFile);
|
|
34
|
+
return chains.map((chain) => ({
|
|
35
|
+
rule: "no-deep-chaining",
|
|
36
|
+
message: `Deep reference chain: ${chain}. List all required references directly in SKILL.md instead.`,
|
|
37
|
+
file: join(ctx.dir, "SKILL.md"),
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { findDeepChains } from "./no-deep-chaining.js";
|
|
3
|
+
describe("findDeepChains", () => {
|
|
4
|
+
it("returns empty when no chains exist", () => {
|
|
5
|
+
const readFile = (path) => {
|
|
6
|
+
if (path.endsWith("ref.md"))
|
|
7
|
+
return "No links here.";
|
|
8
|
+
return null;
|
|
9
|
+
};
|
|
10
|
+
const links = new Set(["ref.md"]);
|
|
11
|
+
expect(findDeepChains("/skill", links, readFile)).toEqual([]);
|
|
12
|
+
});
|
|
13
|
+
it("detects a chain", () => {
|
|
14
|
+
const readFile = (path) => {
|
|
15
|
+
if (path === "/skill/ref.md")
|
|
16
|
+
return "See [other](other.md).";
|
|
17
|
+
if (path === "/skill/other.md")
|
|
18
|
+
return "Final content.";
|
|
19
|
+
return null;
|
|
20
|
+
};
|
|
21
|
+
const links = new Set(["ref.md"]);
|
|
22
|
+
const result = findDeepChains("/skill", links, readFile);
|
|
23
|
+
expect(result).toHaveLength(1);
|
|
24
|
+
expect(result[0]).toContain("ref.md links to other.md");
|
|
25
|
+
});
|
|
26
|
+
it("ignores links outside skill dir", () => {
|
|
27
|
+
const readFile = () => "content";
|
|
28
|
+
const links = new Set(["../escape.md"]);
|
|
29
|
+
expect(findDeepChains("/skill", links, readFile)).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
it("ignores non-markdown files", () => {
|
|
32
|
+
const readFile = () => "content";
|
|
33
|
+
const links = new Set(["data.json"]);
|
|
34
|
+
expect(findDeepChains("/skill", links, readFile)).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
it("ignores unreadable files", () => {
|
|
37
|
+
const readFile = () => null;
|
|
38
|
+
const links = new Set(["missing.md"]);
|
|
39
|
+
expect(findDeepChains("/skill", links, readFile)).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
it("ignores chains to non-markdown targets", () => {
|
|
42
|
+
const readFile = (path) => {
|
|
43
|
+
if (path === "/skill/ref.md")
|
|
44
|
+
return "See [data](data.json).";
|
|
45
|
+
return null;
|
|
46
|
+
};
|
|
47
|
+
const links = new Set(["ref.md"]);
|
|
48
|
+
expect(findDeepChains("/skill", links, readFile)).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
const PLACEHOLDER_PATTERNS = [
|
|
3
|
+
/\[TODO[^\]]*\]/,
|
|
4
|
+
/\[TBD[^\]]*\]/,
|
|
5
|
+
/(?:^|\n)\s*(TODO|TBD)\s*:/,
|
|
6
|
+
];
|
|
7
|
+
export function noPlaceholders(ctx) {
|
|
8
|
+
if (!ctx.text)
|
|
9
|
+
return [];
|
|
10
|
+
if (PLACEHOLDER_PATTERNS.some((p) => p.test(ctx.text))) {
|
|
11
|
+
return [
|
|
12
|
+
{
|
|
13
|
+
rule: "no-placeholders",
|
|
14
|
+
message: "SKILL.md contains TODO/TBD placeholders (e.g., [TODO] or TODO:).",
|
|
15
|
+
file: join(ctx.dir, "SKILL.md"),
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
return [];
|
|
20
|
+
}
|