claudecode-linter 2.1.143 → 2.1.148

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.
@@ -1,15 +1,27 @@
1
1
  import { basename } from "node:path";
2
- import { SETTINGS_USER_FIELDS, SETTINGS_PROJECT_FIELDS, TOOLS } from "../contracts.js";
2
+ import { SETTINGS_USER_FIELDS, SETTINGS_PROJECT_FIELDS, TOOLS, PERMISSIONS_FIELDS, PERMISSION_MODES, SANDBOX_FIELDS, SANDBOX_NETWORK_FIELDS, SANDBOX_FILESYSTEM_FIELDS, } from "../contracts.js";
3
+ import { formatAjvError, loadSettingsSchema, summarizeErrors, } from "../plugin-schema.js";
3
4
  import { isRuleEnabled, getRuleSeverity } from "../types.js";
4
5
  const RULES = [
5
6
  { id: "settings-json/valid-json", defaultSeverity: "error" },
7
+ { id: "settings-json/schema-valid", defaultSeverity: "error" },
6
8
  { id: "settings-json/scope-file-name", defaultSeverity: "error" },
7
9
  { id: "settings-json/scope-field", defaultSeverity: "warning" },
8
10
  { id: "settings-json/no-unknown-fields", defaultSeverity: "warning" },
9
11
  { id: "settings-json/permissions-object", defaultSeverity: "error" },
12
+ { id: "settings-json/permissions-unknown-field", defaultSeverity: "warning" },
13
+ { id: "settings-json/permissions-default-mode", defaultSeverity: "warning" },
14
+ { id: "settings-json/permissions-disable-bypass", defaultSeverity: "warning" },
15
+ { id: "settings-json/permissions-field-type", defaultSeverity: "warning" },
10
16
  { id: "settings-json/allow-array", defaultSeverity: "error" },
11
17
  { id: "settings-json/allow-known-tools", defaultSeverity: "warning" },
12
18
  { id: "settings-json/deny-array", defaultSeverity: "error" },
19
+ { id: "settings-json/ask-array", defaultSeverity: "error" },
20
+ { id: "settings-json/permission-rule-syntax", defaultSeverity: "error" },
21
+ { id: "settings-json/permission-rule-pattern", defaultSeverity: "error" },
22
+ { id: "settings-json/sandbox-object", defaultSeverity: "error" },
23
+ { id: "settings-json/sandbox-unknown-field", defaultSeverity: "warning" },
24
+ { id: "settings-json/sandbox-field-type", defaultSeverity: "warning" },
13
25
  { id: "settings-json/env-object", defaultSeverity: "error" },
14
26
  { id: "settings-json/env-string-values", defaultSeverity: "warning" },
15
27
  { id: "settings-json/plugins-object", defaultSeverity: "error" },
@@ -17,6 +29,154 @@ const RULES = [
17
29
  { id: "settings-json/plugins-format", defaultSeverity: "warning" },
18
30
  { id: "settings-json/skip-prompt-boolean", defaultSeverity: "error" },
19
31
  ];
32
+ // Expected type for each known sandbox sub-key, and for the nested
33
+ // network/filesystem objects. Mirrors Claude Code's Zod schema.
34
+ const SANDBOX_FIELD_TYPES = {
35
+ enabled: "boolean",
36
+ failIfUnavailable: "boolean",
37
+ autoAllowBashIfSandboxed: "boolean",
38
+ allowUnsandboxedCommands: "boolean",
39
+ enableWeakerNestedSandbox: "boolean",
40
+ enableWeakerNetworkIsolation: "boolean",
41
+ excludedCommands: "string-array",
42
+ network: "object",
43
+ filesystem: "object",
44
+ ripgrep: "object",
45
+ ignoreViolations: "object",
46
+ bwrapPath: "string",
47
+ };
48
+ const NETWORK_FIELD_TYPES = {
49
+ allowedDomains: "string-array",
50
+ deniedDomains: "string-array",
51
+ allowManagedDomainsOnly: "boolean",
52
+ allowUnixSockets: "string-array",
53
+ allowAllUnixSockets: "boolean",
54
+ allowLocalBinding: "boolean",
55
+ allowMachLookup: "string-array",
56
+ httpProxyPort: "number",
57
+ socksProxyPort: "number",
58
+ tlsTerminate: "object",
59
+ };
60
+ const FILESYSTEM_FIELD_TYPES = {
61
+ allowWrite: "string-array",
62
+ denyWrite: "string-array",
63
+ denyRead: "string-array",
64
+ allowRead: "string-array",
65
+ allowManagedReadPathsOnly: "boolean",
66
+ };
67
+ // sandbox.network.tlsTerminate — a small nested object of optional paths.
68
+ const TLS_TERMINATE_FIELD_TYPES = {
69
+ caCertPath: "string",
70
+ caKeyPath: "string",
71
+ };
72
+ // Tool classes that drive per-rule pattern validation (Claude Code's `Fx$`).
73
+ const FILE_PATTERN_TOOLS = new Set(["Read", "Write", "Edit", "Glob", "NotebookRead", "NotebookEdit"]);
74
+ const BASH_PREFIX_TOOLS = new Set(["Bash"]);
75
+ function isStringArray(v) {
76
+ return Array.isArray(v) && v.every((x) => typeof x === "string");
77
+ }
78
+ function isPlainObject(v) {
79
+ return typeof v === "object" && v !== null && !Array.isArray(v);
80
+ }
81
+ function typeMatches(expected, val) {
82
+ switch (expected) {
83
+ case "boolean": return typeof val === "boolean";
84
+ case "string": return typeof val === "string";
85
+ case "number": return typeof val === "number";
86
+ case "string-array": return isStringArray(val);
87
+ case "object": return isPlainObject(val);
88
+ }
89
+ }
90
+ function articleType(expected) {
91
+ switch (expected) {
92
+ case "boolean": return "a boolean";
93
+ case "string": return "a string";
94
+ case "number": return "a number";
95
+ case "string-array": return "an array of strings";
96
+ case "object": return "an object";
97
+ }
98
+ }
99
+ /**
100
+ * Lightweight permission-rule syntax check, mirroring the syntactic rejections
101
+ * of Claude Code's runtime rule validator (the per-tool pattern grammar is
102
+ * deliberately not reproduced). Returns an error description, or null if the
103
+ * rule is syntactically well-formed.
104
+ */
105
+ function ruleSyntaxError(rule) {
106
+ if (rule.trim() === "")
107
+ return "permission rule cannot be empty";
108
+ const opens = (rule.match(/\(/g) ?? []).length;
109
+ const closes = (rule.match(/\)/g) ?? []).length;
110
+ if (opens !== closes)
111
+ return "mismatched parentheses";
112
+ if (/\(\s*\)/.test(rule)) {
113
+ const before = rule.slice(0, rule.indexOf("(")).trim();
114
+ return before === ""
115
+ ? "empty parentheses with no tool name"
116
+ : `empty parentheses — use "${before}" alone, or specify a pattern`;
117
+ }
118
+ const parenIdx = rule.indexOf("(");
119
+ const toolName = (parenIdx === -1 ? rule : rule.slice(0, parenIdx)).trim();
120
+ if (toolName === "")
121
+ return "tool name cannot be empty";
122
+ if (rule.startsWith("mcp__")) {
123
+ return parenIdx === -1
124
+ ? null
125
+ : "MCP rules do not support patterns in parentheses";
126
+ }
127
+ if (!toolName.includes("_") && toolName[0] !== toolName[0].toUpperCase()) {
128
+ return "tool names must start with an uppercase letter";
129
+ }
130
+ return null;
131
+ }
132
+ /**
133
+ * Split a syntactically valid rule into its tool name and parenthesized
134
+ * pattern. `content` is null for a bare tool or a `()` / `(*)` wildcard.
135
+ */
136
+ function parseRule(rule) {
137
+ const open = rule.indexOf("(");
138
+ if (open === -1)
139
+ return { toolName: rule.trim(), content: null };
140
+ const inner = rule.slice(open + 1, rule.lastIndexOf(")"));
141
+ return {
142
+ toolName: rule.slice(0, open).trim(),
143
+ content: inner === "" || inner === "*" ? null : inner,
144
+ };
145
+ }
146
+ /**
147
+ * Per-tool pattern validation, mirroring Claude Code's `Fx$` rule table:
148
+ * WebSearch/WebFetch custom validators, the Bash `:*` prefix marker, and the
149
+ * file-pattern tools that reject `:*`. Returns an error description or null.
150
+ */
151
+ function ruleContentError(toolName, content) {
152
+ if (toolName === "WebSearch") {
153
+ if (content.includes("*") || content.includes("?")) {
154
+ return "WebSearch rules do not support wildcards (* or ?)";
155
+ }
156
+ }
157
+ else if (toolName === "WebFetch") {
158
+ if (content.includes("://") || content.startsWith("http")) {
159
+ return "WebFetch rules use \"domain:hostname\" format, not URLs";
160
+ }
161
+ if (!content.startsWith("domain:")) {
162
+ return "WebFetch rules must use the \"domain:\" prefix";
163
+ }
164
+ }
165
+ else if (BASH_PREFIX_TOOLS.has(toolName)) {
166
+ if (content.includes(":*") && !content.endsWith(":*")) {
167
+ return "the \":*\" prefix marker must be at the end of the pattern";
168
+ }
169
+ if (content === ":*") {
170
+ return "the command prefix before \":*\" cannot be empty";
171
+ }
172
+ }
173
+ else if (FILE_PATTERN_TOOLS.has(toolName)) {
174
+ if (content.includes(":*")) {
175
+ return "the \":*\" syntax is Bash-only — use glob patterns (* or **) for files";
176
+ }
177
+ }
178
+ return null;
179
+ }
20
180
  function findKeyPosition(content, key) {
21
181
  const re = new RegExp(`"${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"\\s*:`);
22
182
  const match = re.exec(content);
@@ -60,6 +220,28 @@ export const settingsJsonLinter = {
60
220
  push(diag(config, filePath, "settings-json/valid-json", "error", "settings.json must be a JSON object"));
61
221
  return diagnostics;
62
222
  }
223
+ // schema-valid — defer to the JSON Schema auto-extracted from Claude Code's
224
+ // settings validator for the ~113 structurally-unchecked top-level fields.
225
+ // The hand-written rules below cover Claude-Code-specific advice the schema
226
+ // can't express (permission-rule syntax, scope semantics, …) with friendlier
227
+ // messages. The extracted schema is intentionally permissive at the top
228
+ // level (Claude Code uses .passthrough()), so unknown keys are NOT reported
229
+ // here — settings-json/no-unknown-fields handles those. Skipped silently if
230
+ // the schema bundle isn't shipped with this install.
231
+ if (isRuleEnabled(config, "settings-json/schema-valid")) {
232
+ const compiled = loadSettingsSchema();
233
+ if (compiled) {
234
+ const ok = compiled.validate(parsed);
235
+ if (!ok && compiled.validate.errors) {
236
+ const filtered = summarizeErrors(compiled.validate.errors);
237
+ for (const err of filtered) {
238
+ const firstSeg = err.instancePath.split("/").filter(Boolean)[0];
239
+ const p = firstSeg ? findKeyPosition(content, firstSeg) : undefined;
240
+ push(diag(config, filePath, "settings-json/schema-valid", "error", formatAjvError(err), p?.line, p?.column));
241
+ }
242
+ }
243
+ }
244
+ }
63
245
  // Scope-aware: settings.json (non-local) should only be at user level
64
246
  if (!isLocal && scope && scope !== "user") {
65
247
  push(diag(config, filePath, "settings-json/scope-file-name", "error", `"settings.json" should only exist at user level (~/.claude/). Use "settings.local.json" for project-level settings`));
@@ -78,46 +260,151 @@ export const settingsJsonLinter = {
78
260
  }
79
261
  }
80
262
  }
263
+ // Validate the sub-keys of a nested object against a known-field set and a
264
+ // type map (used for sandbox.network and sandbox.filesystem).
265
+ const checkNested = (obj, prefix, known, types) => {
266
+ for (const [key, val] of Object.entries(obj)) {
267
+ const kp = findKeyPosition(content, key);
268
+ if (!known.has(key)) {
269
+ push(diag(config, filePath, "settings-json/sandbox-unknown-field", "warning", `Unknown field "${prefix}.${key}"`, kp?.line, kp?.column));
270
+ continue;
271
+ }
272
+ const expected = types[key];
273
+ if (expected && !typeMatches(expected, val)) {
274
+ push(diag(config, filePath, "settings-json/sandbox-field-type", "warning", `"${prefix}.${key}" should be ${articleType(expected)}`, kp?.line, kp?.column));
275
+ }
276
+ }
277
+ };
81
278
  // permissions
82
279
  if ("permissions" in parsed) {
83
280
  const perms = parsed.permissions;
84
281
  const pp = findKeyPosition(content, "permissions");
85
- if (typeof perms !== "object" || perms === null || Array.isArray(perms)) {
282
+ if (!isPlainObject(perms)) {
86
283
  push(diag(config, filePath, "settings-json/permissions-object", "error", "\"permissions\" must be an object", pp?.line, pp?.column));
87
284
  }
88
285
  else {
89
286
  const p = perms;
90
- // allow list
91
- if ("allow" in p) {
92
- const ap = findKeyPosition(content, "allow");
93
- if (!Array.isArray(p.allow)) {
94
- push(diag(config, filePath, "settings-json/allow-array", "error", "\"permissions.allow\" must be an array of strings", ap?.line, ap?.column));
287
+ // unknown permissions sub-keys
288
+ for (const key of Object.keys(p)) {
289
+ if (!PERMISSIONS_FIELDS.has(key)) {
290
+ const kp = findKeyPosition(content, key);
291
+ push(diag(config, filePath, "settings-json/permissions-unknown-field", "warning", `Unknown field "permissions.${key}"`, kp?.line, kp?.column));
95
292
  }
96
- else {
97
- for (const entry of p.allow) {
98
- if (typeof entry !== "string") {
99
- push(diag(config, filePath, "settings-json/allow-array", "error", `"permissions.allow" entries must be strings (got ${typeof entry})`, ap?.line, ap?.column));
293
+ }
294
+ // allow / deny / ask — each an array of permission-rule strings
295
+ const checkRuleArray = (name, arrayRuleId) => {
296
+ if (!(name in p))
297
+ return;
298
+ const kp = findKeyPosition(content, name);
299
+ if (!Array.isArray(p[name])) {
300
+ push(diag(config, filePath, arrayRuleId, "error", `"permissions.${name}" must be an array of strings`, kp?.line, kp?.column));
301
+ return;
302
+ }
303
+ for (const entry of p[name]) {
304
+ if (typeof entry !== "string") {
305
+ push(diag(config, filePath, arrayRuleId, "error", `"permissions.${name}" entries must be strings (got ${typeof entry})`, kp?.line, kp?.column));
306
+ continue;
307
+ }
308
+ // Syntax first — a malformed rule string is rejected at runtime.
309
+ const syntaxErr = ruleSyntaxError(entry);
310
+ if (syntaxErr) {
311
+ push(diag(config, filePath, "settings-json/permission-rule-syntax", "error", `Invalid permission rule "${entry}" in permissions.${name}: ${syntaxErr}`, kp?.line, kp?.column));
312
+ continue;
313
+ }
314
+ // Per-tool pattern grammar (Bash :* placement, WebFetch domain:, …).
315
+ const { toolName, content: ruleContent } = parseRule(entry);
316
+ if (ruleContent !== null) {
317
+ const patternErr = ruleContentError(toolName, ruleContent);
318
+ if (patternErr) {
319
+ push(diag(config, filePath, "settings-json/permission-rule-pattern", "error", `Invalid permission rule "${entry}" in permissions.${name}: ${patternErr}`, kp?.line, kp?.column));
100
320
  continue;
101
321
  }
102
- // Extract base tool name from scoped pattern like "Bash(cmd:*)"
103
- const toolMatch = entry.match(/^([A-Za-z]+)(\(.*\))?$/);
104
- if (toolMatch) {
105
- const toolName = toolMatch[1];
106
- if (!TOOLS.has(toolName)) {
107
- // Allow MCP tool patterns (mcp__*)
108
- if (!entry.startsWith("mcp__")) {
109
- push(diag(config, filePath, "settings-json/allow-known-tools", "warning", `Unknown tool "${toolName}" in permissions.allow`, ap?.line, ap?.column));
110
- }
111
- }
112
- }
113
322
  }
323
+ // Rules are "Tool" or "Tool(specifier)". Warn on a base tool name
324
+ // Claude Code does not expose (mcp__* is dynamic).
325
+ const toolMatch = entry.match(/^([A-Za-z]+)(\(.*\))?$/);
326
+ if (toolMatch && !TOOLS.has(toolMatch[1]) && !entry.startsWith("mcp__")) {
327
+ push(diag(config, filePath, "settings-json/allow-known-tools", "warning", `Unknown tool "${toolMatch[1]}" in permissions.${name}`, kp?.line, kp?.column));
328
+ }
329
+ }
330
+ };
331
+ checkRuleArray("allow", "settings-json/allow-array");
332
+ checkRuleArray("deny", "settings-json/deny-array");
333
+ checkRuleArray("ask", "settings-json/ask-array");
334
+ // defaultMode — must be a known permission mode
335
+ if ("defaultMode" in p) {
336
+ const mp = findKeyPosition(content, "defaultMode");
337
+ if (typeof p.defaultMode !== "string" || !PERMISSION_MODES.has(p.defaultMode)) {
338
+ push(diag(config, filePath, "settings-json/permissions-default-mode", "warning", `"permissions.defaultMode" should be one of: ${[...PERMISSION_MODES].join(", ")}`, mp?.line, mp?.column));
114
339
  }
115
340
  }
116
- // deny list
117
- if ("deny" in p) {
118
- const dp = findKeyPosition(content, "deny");
119
- if (!Array.isArray(p.deny)) {
120
- push(diag(config, filePath, "settings-json/deny-array", "error", "\"permissions.deny\" must be an array of strings", dp?.line, dp?.column));
341
+ // disableBypassPermissionsMode — the only accepted value is "disable"
342
+ if ("disableBypassPermissionsMode" in p && p.disableBypassPermissionsMode !== "disable") {
343
+ const dp = findKeyPosition(content, "disableBypassPermissionsMode");
344
+ push(diag(config, filePath, "settings-json/permissions-disable-bypass", "warning", "\"permissions.disableBypassPermissionsMode\" only accepts the string \"disable\"", dp?.line, dp?.column));
345
+ }
346
+ // additionalDirectories — array of strings
347
+ if ("additionalDirectories" in p && !isStringArray(p.additionalDirectories)) {
348
+ const ap = findKeyPosition(content, "additionalDirectories");
349
+ push(diag(config, filePath, "settings-json/permissions-field-type", "warning", "\"permissions.additionalDirectories\" should be an array of strings", ap?.line, ap?.column));
350
+ }
351
+ // disableAutoMode — boolean
352
+ if ("disableAutoMode" in p && typeof p.disableAutoMode !== "boolean") {
353
+ const ap = findKeyPosition(content, "disableAutoMode");
354
+ push(diag(config, filePath, "settings-json/permissions-field-type", "warning", "\"permissions.disableAutoMode\" should be a boolean", ap?.line, ap?.column));
355
+ }
356
+ }
357
+ }
358
+ // sandbox
359
+ if ("sandbox" in parsed) {
360
+ const sandbox = parsed.sandbox;
361
+ const sp = findKeyPosition(content, "sandbox");
362
+ if (!isPlainObject(sandbox)) {
363
+ push(diag(config, filePath, "settings-json/sandbox-object", "error", "\"sandbox\" must be an object", sp?.line, sp?.column));
364
+ }
365
+ else {
366
+ for (const [key, val] of Object.entries(sandbox)) {
367
+ const kp = findKeyPosition(content, key);
368
+ if (!SANDBOX_FIELDS.has(key)) {
369
+ push(diag(config, filePath, "settings-json/sandbox-unknown-field", "warning", `Unknown field "sandbox.${key}"`, kp?.line, kp?.column));
370
+ continue;
371
+ }
372
+ const expected = SANDBOX_FIELD_TYPES[key];
373
+ if (expected && !typeMatches(expected, val)) {
374
+ push(diag(config, filePath, "settings-json/sandbox-field-type", "warning", `"sandbox.${key}" should be ${articleType(expected)}`, kp?.line, kp?.column));
375
+ continue;
376
+ }
377
+ // Nested object validation
378
+ if (key === "network" && isPlainObject(val)) {
379
+ checkNested(val, "sandbox.network", SANDBOX_NETWORK_FIELDS, NETWORK_FIELD_TYPES);
380
+ if (isPlainObject(val.tlsTerminate)) {
381
+ checkNested(val.tlsTerminate, "sandbox.network.tlsTerminate", new Set(Object.keys(TLS_TERMINATE_FIELD_TYPES)), TLS_TERMINATE_FIELD_TYPES);
382
+ }
383
+ }
384
+ else if (key === "filesystem" && isPlainObject(val)) {
385
+ checkNested(val, "sandbox.filesystem", SANDBOX_FILESYSTEM_FIELDS, FILESYSTEM_FIELD_TYPES);
386
+ }
387
+ else if (key === "ripgrep" && isPlainObject(val)) {
388
+ for (const rk of Object.keys(val)) {
389
+ if (rk !== "command" && rk !== "args") {
390
+ const rp = findKeyPosition(content, rk);
391
+ push(diag(config, filePath, "settings-json/sandbox-unknown-field", "warning", `Unknown field "sandbox.ripgrep.${rk}"`, rp?.line, rp?.column));
392
+ }
393
+ }
394
+ if (typeof val.command !== "string") {
395
+ push(diag(config, filePath, "settings-json/sandbox-field-type", "warning", "\"sandbox.ripgrep.command\" is required and must be a string", kp?.line, kp?.column));
396
+ }
397
+ if ("args" in val && !isStringArray(val.args)) {
398
+ push(diag(config, filePath, "settings-json/sandbox-field-type", "warning", "\"sandbox.ripgrep.args\" should be an array of strings", kp?.line, kp?.column));
399
+ }
400
+ }
401
+ else if (key === "ignoreViolations" && isPlainObject(val)) {
402
+ for (const [vk, vv] of Object.entries(val)) {
403
+ if (!isStringArray(vv)) {
404
+ const vp = findKeyPosition(content, vk);
405
+ push(diag(config, filePath, "settings-json/sandbox-field-type", "warning", `"sandbox.ignoreViolations.${vk}" should be an array of strings`, vp?.line, vp?.column));
406
+ }
407
+ }
121
408
  }
122
409
  }
123
410
  }
@@ -126,7 +413,7 @@ export const settingsJsonLinter = {
126
413
  if ("env" in parsed) {
127
414
  const env = parsed.env;
128
415
  const envp = findKeyPosition(content, "env");
129
- if (typeof env !== "object" || env === null || Array.isArray(env)) {
416
+ if (!isPlainObject(env)) {
130
417
  push(diag(config, filePath, "settings-json/env-object", "error", "\"env\" must be an object of string key-value pairs", envp?.line, envp?.column));
131
418
  }
132
419
  else {
@@ -142,7 +429,7 @@ export const settingsJsonLinter = {
142
429
  if ("enabledPlugins" in parsed) {
143
430
  const plugins = parsed.enabledPlugins;
144
431
  const plp = findKeyPosition(content, "enabledPlugins");
145
- if (typeof plugins !== "object" || plugins === null || Array.isArray(plugins)) {
432
+ if (!isPlainObject(plugins)) {
146
433
  push(diag(config, filePath, "settings-json/plugins-object", "error", "\"enabledPlugins\" must be an object", plp?.line, plp?.column));
147
434
  }
148
435
  else {
@@ -1,9 +1,11 @@
1
- import { SKILL_FRONTMATTER } from "../contracts.js";
1
+ import { formatAjvError, loadSkillFrontmatterSchema, summarizeErrors, } from "../plugin-schema.js";
2
2
  import { isRuleEnabled, getRuleSeverity } from "../types.js";
3
3
  import { parseFrontmatter } from "../utils/frontmatter.js";
4
+ import { artifactLabel, classifyUnknownFrontmatterKey, } from "../utils/frontmatter-keys.js";
4
5
  import { isKebabCase } from "../utils/kebab-case.js";
5
6
  const RULES = [
6
7
  { id: "skill-md/valid-frontmatter", defaultSeverity: "error" },
8
+ { id: "skill-md/schema-valid", defaultSeverity: "error" },
7
9
  { id: "skill-md/name-required", defaultSeverity: "error" },
8
10
  { id: "skill-md/name-kebab-case", defaultSeverity: "error" },
9
11
  { id: "skill-md/name-max-length", defaultSeverity: "error" },
@@ -11,10 +13,72 @@ const RULES = [
11
13
  { id: "skill-md/description-max-length", defaultSeverity: "error" },
12
14
  { id: "skill-md/description-no-angle-brackets", defaultSeverity: "error" },
13
15
  { id: "skill-md/description-trigger-phrases", defaultSeverity: "warning" },
14
- { id: "skill-md/no-unknown-frontmatter", defaultSeverity: "warning" },
15
- { id: "skill-md/body-word-count", defaultSeverity: "warning" },
16
+ { id: "skill-md/no-unknown-frontmatter", defaultSeverity: "info" },
17
+ { id: "skill-md/body-word-count", defaultSeverity: "info" },
16
18
  { id: "skill-md/body-has-headers", defaultSeverity: "info" },
17
19
  ];
20
+ /**
21
+ * Decide whether a SKILL.md `description` communicates *when* the skill
22
+ * applies. A description that gives Claude Code any concrete applicability
23
+ * cue is fine — only purely declarative descriptions (a flat statement of
24
+ * what the skill is, with no routing signal at all) should be flagged.
25
+ *
26
+ * Recognized signals, in rough order of how common they are in real skills:
27
+ * - explicit trigger sections: "Trigger on …", "Trigger when/whenever …",
28
+ * "Triggers: …", "TRIGGER when:", routing-marker comments
29
+ * - "use when / use for / use whenever / use this skill when / use to"
30
+ * - "should be used when …", "applies when …", "invoke when …"
31
+ * - "when the user asks/wants/mentions/needs …" and bare "when <verb>ing"
32
+ * - imperative-verb openers ("Diagnose …", "Deploy …", "Author …") — an
33
+ * imperative description states the task the skill performs, which is
34
+ * itself an applicability cue
35
+ * - gerund openers ("Building …", "Searching …")
36
+ */
37
+ function hasTriggerSignal(desc) {
38
+ const d = desc.trim();
39
+ if (!d)
40
+ return false;
41
+ // Routing-marker comments authors drop in to delimit trigger lists.
42
+ if (/BEGIN ROUTING TRIGGERS|ROUTING TRIGGERS/i.test(d))
43
+ return true;
44
+ // Explicit trigger / use-when phrasing anywhere in the description.
45
+ // "Trigger" only counts as a routing cue when used as a directive verb
46
+ // ("Trigger on …", "Trigger when/whenever/if …", "Triggers: …", "TRIGGER
47
+ // when:") — not when "trigger" merely appears as a noun (e.g. the phrase
48
+ // "no trigger phrases").
49
+ const triggerPhrases = /\btrigger(s)?\b\s*(on|when|whenever|if|upon|for)\b|\btriggers?\s*:|\buse (this skill |it )?(when|whenever|for|to|on|if)\b|\bshould be used (when|whenever|for|to|if)\b|\b(applies|invoke|reach for|call this|run this|use this) (when|whenever|if|to|for)\b|\bwhen (the user|you|asked|working|debugging|diagnosing|investigating|reviewing|writing|building|creating|editing|setting up|deploying|the task|a request|a question|you need|there|something|anything)\b|\bwhen [a-z]+ing\b|\bfor (debugging|diagnosing|tasks|requests|questions|when|any)\b/i;
50
+ if (triggerPhrases.test(d))
51
+ return true;
52
+ // First "sentence-ish" chunk — used to detect imperative / gerund openers.
53
+ const opener = d.split(/[.\n—:(]/, 1)[0].trim();
54
+ const firstWord = opener.split(/\s+/, 1)[0].replace(/[^A-Za-z-]/g, "");
55
+ // Gerund opener ("Building a …", "Searching the …").
56
+ if (/^[A-Z][a-z]+ing\b/.test(opener))
57
+ return true;
58
+ // Imperative-verb opener. A skill description that starts with a bare
59
+ // verb is describing the action the skill performs, which signals when
60
+ // to route to it. Accept a curated set of common imperative verbs so we
61
+ // don't accidentally pass a noun-opener declarative description.
62
+ const imperativeVerbs = new Set([
63
+ "add", "analyze", "audit", "author", "automate", "build", "check",
64
+ "clean", "configure", "convert", "create", "debug", "delete", "deploy",
65
+ "diagnose", "drive", "edit", "enforce", "ensure", "estimate",
66
+ "evaluate", "execute", "extract", "fetch", "find", "fix", "format",
67
+ "generate", "guide", "handle", "help", "identify", "implement",
68
+ "initialize", "inspect", "install", "investigate", "lint", "list",
69
+ "manage", "migrate", "mint", "monitor", "open", "optimize", "parse",
70
+ "perform", "pin", "plan", "prepare", "preview", "produce", "profile",
71
+ "publish", "pull", "push", "query", "reclaim", "refactor", "render",
72
+ "report", "resolve", "restore", "review", "rollout", "run", "scaffold",
73
+ "scan", "search", "set", "ship", "size", "summarize", "sweep", "sync",
74
+ "test", "trace", "track", "transform", "transition", "translate",
75
+ "troubleshoot", "tune", "update", "upgrade", "validate", "verify",
76
+ "watch", "write",
77
+ ]);
78
+ if (imperativeVerbs.has(firstWord.toLowerCase()))
79
+ return true;
80
+ return false;
81
+ }
18
82
  function diag(config, filePath, ruleId, defaultSeverity, message, line, column) {
19
83
  if (!isRuleEnabled(config, ruleId))
20
84
  return null;
@@ -38,6 +102,25 @@ export const skillMdLinter = {
38
102
  push(diag(config, filePath, "skill-md/valid-frontmatter", "error", fm.error ?? "Invalid frontmatter"));
39
103
  return diagnostics;
40
104
  }
105
+ // schema-valid — structural validation against the JSON Schema
106
+ // auto-extracted from Claude Code's skill frontmatter Zod validator. The
107
+ // hand-written rules below add Claude-Code-specific advice the schema
108
+ // can't express (kebab-case names, trigger phrasing, …). The extracted
109
+ // schema is intentionally permissive about unknown frontmatter keys
110
+ // (Claude Code still loads the skill), so those are NOT reported here —
111
+ // skill-md/no-unknown-frontmatter handles them. Skipped silently if the
112
+ // schema bundle isn't shipped with this install.
113
+ if (isRuleEnabled(config, "skill-md/schema-valid")) {
114
+ const compiled = loadSkillFrontmatterSchema();
115
+ if (compiled) {
116
+ const ok = compiled.validate(fm.data);
117
+ if (!ok && compiled.validate.errors) {
118
+ for (const err of summarizeErrors(compiled.validate.errors)) {
119
+ push(diag(config, filePath, "skill-md/schema-valid", "error", formatAjvError(err)));
120
+ }
121
+ }
122
+ }
123
+ }
41
124
  // name
42
125
  if (!("name" in fm.data) || typeof fm.data.name !== "string") {
43
126
  push(diag(config, filePath, "skill-md/name-required", "error", "\"name\" is required in frontmatter"));
@@ -63,25 +146,32 @@ export const skillMdLinter = {
63
146
  if (/<|>/.test(desc)) {
64
147
  push(diag(config, filePath, "skill-md/description-no-angle-brackets", "error", "\"description\" must not contain angle brackets (< or >)"));
65
148
  }
66
- if (!/when the user|should be used when/i.test(desc)) {
67
- push(diag(config, filePath, "skill-md/description-trigger-phrases", "warning", "Description should contain trigger phrases (e.g., \"when the user asks to...\")"));
149
+ if (!hasTriggerSignal(desc)) {
150
+ push(diag(config, filePath, "skill-md/description-trigger-phrases", "warning", "Description has no applicability signal say when the skill applies " +
151
+ "(e.g., \"Use when…\", \"Trigger on…\", \"when the user asks to…\", or an imperative verb)"));
68
152
  }
69
153
  }
70
- // unknown frontmatter keys
154
+ // Frontmatter keys: only flag cross-artifact misplacement. A key that is
155
+ // valid for a *different* markdown artifact (e.g. agent's "effort" on a
156
+ // skill) gets an info; a key valid for no artifact stays silent.
71
157
  for (const key of Object.keys(fm.data)) {
72
- if (!SKILL_FRONTMATTER.has(key)) {
73
- push(diag(config, filePath, "skill-md/no-unknown-frontmatter", "warning", `Unknown frontmatter key "${key}" (known: ${[...SKILL_FRONTMATTER].join(", ")})`));
158
+ const cls = classifyUnknownFrontmatterKey(key, "skill");
159
+ if (cls?.kind === "owned-by-other" && cls.owner) {
160
+ push(diag(config, filePath, "skill-md/no-unknown-frontmatter", "info", `"${key}" is ${artifactLabel(cls.owner)} frontmatter — Claude Code ignores it on a skill`));
74
161
  }
75
162
  }
76
163
  // body checks
77
164
  const body = fm.body.trim();
78
165
  if (body) {
166
+ // Word-count is a soft style hint: a concise, well-scoped skill body is
167
+ // legitimate. Only flag genuinely thin bodies (< 150 words) or very
168
+ // large ones (> 5000) that likely belong split into references/.
79
169
  const words = body.split(/\s+/).length;
80
- if (words < 500) {
81
- push(diag(config, filePath, "skill-md/body-word-count", "warning", `Body has ${words} words (recommended: 500-5000)`, fm.bodyStartLine));
170
+ if (words < 150) {
171
+ push(diag(config, filePath, "skill-md/body-word-count", "info", `Body has ${words} words — consider adding more detail (recommended: 150-5000)`, fm.bodyStartLine));
82
172
  }
83
173
  else if (words > 5000) {
84
- push(diag(config, filePath, "skill-md/body-word-count", "warning", `Body has ${words} words — consider moving detail to references/ (recommended: 500-5000)`, fm.bodyStartLine));
174
+ push(diag(config, filePath, "skill-md/body-word-count", "info", `Body has ${words} words — consider moving detail to references/ (recommended: 150-5000)`, fm.bodyStartLine));
85
175
  }
86
176
  if (!/^##\s/m.test(body)) {
87
177
  push(diag(config, filePath, "skill-md/body-has-headers", "info", "Body should use H2 (##) sections for organization", fm.bodyStartLine));
@@ -62,6 +62,18 @@ export function loadLspSchema() {
62
62
  export function loadMonitorsSchema() {
63
63
  return loadCompiledSchema("monitors.schema.json");
64
64
  }
65
+ export function loadSettingsSchema() {
66
+ return loadCompiledSchema("settings.schema.json");
67
+ }
68
+ export function loadSkillFrontmatterSchema() {
69
+ return loadCompiledSchema("skill-frontmatter.schema.json");
70
+ }
71
+ export function loadAgentFrontmatterSchema() {
72
+ return loadCompiledSchema("agent-frontmatter.schema.json");
73
+ }
74
+ export function loadCommandFrontmatterSchema() {
75
+ return loadCompiledSchema("command-frontmatter.schema.json");
76
+ }
65
77
  export function loadPluginSchema() {
66
78
  if (cached)
67
79
  return cached;
@@ -0,0 +1,38 @@
1
+ import { SKILL_FRONTMATTER, AGENT_FRONTMATTER, COMMAND_FRONTMATTER, } from "../contracts.js";
2
+ const KEY_SETS = {
3
+ skill: SKILL_FRONTMATTER,
4
+ agent: AGENT_FRONTMATTER,
5
+ command: COMMAND_FRONTMATTER,
6
+ };
7
+ const ARTIFACT_LABEL = {
8
+ skill: "skill",
9
+ agent: "agent",
10
+ command: "command",
11
+ };
12
+ /**
13
+ * Classify `key` relative to `self` (the artifact being linted).
14
+ *
15
+ * `extraKnown` lets a caller treat a few extra keys as known-for-self (some
16
+ * linters historically accept hyphenated aliases the contract set lists in a
17
+ * canonicalized form, e.g. `allowed-tools` / `argument-hint`).
18
+ */
19
+ export function classifyUnknownFrontmatterKey(key, self, extraKnown = new Set()) {
20
+ // Known for the current artifact — not unknown at all.
21
+ if (KEY_SETS[self].has(key) || extraKnown.has(key))
22
+ return null;
23
+ // Valid for some *other* markdown artifact → misplacement.
24
+ for (const kind of ["skill", "agent", "command"]) {
25
+ if (kind === self)
26
+ continue;
27
+ if (KEY_SETS[kind].has(key)) {
28
+ return { kind: "owned-by-other", owner: kind };
29
+ }
30
+ }
31
+ // Valid nowhere → genuinely unfamiliar; callers stay silent.
32
+ return { kind: "unknown-everywhere" };
33
+ }
34
+ /** Human-readable label for an artifact kind ("skill", "agent", "command"). */
35
+ export function artifactLabel(kind) {
36
+ return ARTIFACT_LABEL[kind];
37
+ }
38
+ //# sourceMappingURL=frontmatter-keys.js.map