From 66e4d9d01fd2b86960742dbe2e0e77ba2abb25df Mon Sep 17 00:00:00 2001 From: protosphinx <133899485+protosphinx@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:12:03 +0000 Subject: [PATCH] feat(checks): add model-unknown warn rule for the model frontmatter field Validates the optional model: field against a set of known Claude model IDs. Issues a warn (not error) so CI does not break when new models ship. Also documents empty-body and model-unknown in the README rules table. --- README.md | 2 ++ src/checks.ts | 32 +++++++++++++++++++++++++++++ test/checks.test.ts | 50 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/README.md b/README.md index 6bcc019..17d4d5c 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,8 @@ Exit codes: | `description-collision` | warn | Two skills' descriptions have Jaccard ≥ 0.6 | | `tools-overloaded` | warn | `tools:` lists 10 or more entries; narrow the list to what this skill actually needs | | `duplicate-name` | warn | Two or more skills share the same `name:` value; resolution is ambiguous | +| `empty-body` | warn | Skill body has no instructions; Claude has nothing to follow | +| `model-unknown` | warn | `model:` value is not a recognized Claude model ID; likely a typo | | `parse` | error | The file doesn't have valid frontmatter / YAML | The MCP and built-in tool checks read `~/.claude/settings.json` and diff --git a/src/checks.ts b/src/checks.ts index 39b77e7..56cef75 100644 --- a/src/checks.ts +++ b/src/checks.ts @@ -12,6 +12,22 @@ const MAX_DESCRIPTION_CHARS = 500; const COLLISION_THRESHOLD = 0.6; const MAX_TOOLS_COUNT = 10; +// Known Claude model IDs. Severity is warn (not error) because the list +// evolves with each Anthropic release; update this set when new models ship. +const KNOWN_CLAUDE_MODELS: ReadonlySet = new Set([ + "claude-3-haiku-20240307", + "claude-3-opus-20240229", + "claude-3-opus-latest", + "claude-3-sonnet-20240229", + "claude-3-5-haiku-20241022", + "claude-3-5-haiku-latest", + "claude-3-5-sonnet-20241022", + "claude-3-5-sonnet-latest", + "claude-haiku-4-5-20251001", + "claude-opus-4-8", + "claude-sonnet-4-6", +]); + export function runChecks( parsed: ParsedSkill[], config: SkillcheckConfig, @@ -33,6 +49,7 @@ export function runChecks( diagnostics.push(...checkDescriptionLength(v)); diagnostics.push(...checkNameDrift(v)); diagnostics.push(...checkEmptyBody(v)); + diagnostics.push(...checkModelUnknown(v)); } diagnostics.push(...checkCollisions(validated)); @@ -154,6 +171,21 @@ function checkNameDrift(v: ValidatedSkill): Diagnostic[] { ]; } +function checkModelUnknown(v: ValidatedSkill): Diagnostic[] { + const model = v.frontmatter.model; + if (model === undefined) return []; + if (typeof model !== "string" || model.length === 0) return []; + if (KNOWN_CLAUDE_MODELS.has(model)) return []; + return [ + { + severity: "warn", + rule: "model-unknown", + message: `model '${model}' is not a recognized Claude model; check for typos or update skillcheck`, + file: v.file, + }, + ]; +} + function checkEmptyBody(v: ValidatedSkill): Diagnostic[] { if (v.body.trim().length === 0) { return [ diff --git a/test/checks.test.ts b/test/checks.test.ts index a5963e1..385b9e7 100644 --- a/test/checks.test.ts +++ b/test/checks.test.ts @@ -308,4 +308,54 @@ describe("runChecks", () => { const ds = runChecks([s], config); expect(ds.some((d) => d.rule === "name-drift")).toBe(true); }); + + it("warns on an unrecognized model name", () => { + const s = mkSkill("/test/foo/foo.md", { + name: "foo", + description: "do the foo thing", + model: "claude-sonet-4-6", + }); + const ds = runChecks([s], config); + expect(ds.some((d) => d.rule === "model-unknown")).toBe(true); + }); + + it("does not warn on a recognized model", () => { + const s = mkSkill("/test/foo/foo.md", { + name: "foo", + description: "do the foo thing", + model: "claude-sonnet-4-6", + }); + const ds = runChecks([s], config); + expect(ds.find((d) => d.rule === "model-unknown")).toBeUndefined(); + }); + + it("does not warn when model field is absent", () => { + const s = mkSkill("/test/foo/foo.md", { + name: "foo", + description: "do the foo thing", + }); + const ds = runChecks([s], config); + expect(ds.find((d) => d.rule === "model-unknown")).toBeUndefined(); + }); + + it("model-unknown message includes the offending model name", () => { + const s = mkSkill("/test/foo/foo.md", { + name: "foo", + description: "do the foo thing", + model: "gpt-4o", + }); + const ds = runChecks([s], config); + const d = ds.find((d) => d.rule === "model-unknown"); + expect(d?.message).toContain("gpt-4o"); + }); + + it("warns on model-unknown for each model in the known-3.x series", () => { + const s = mkSkill("/test/foo/foo.md", { + name: "foo", + description: "do the foo thing", + model: "claude-3-5-sonnet-20241022", + }); + const ds = runChecks([s], config); + expect(ds.find((d) => d.rule === "model-unknown")).toBeUndefined(); + }); });