claudecode-linter 2.1.144 → 2.1.148-patch.2

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,8 +1,11 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
- import { AGENT_FRONTMATTER, AGENT_MODELS, AGENT_COLORS, TOOLS, PLUGIN_SUBAGENT_BLOCKED_TOOLS, } from "../contracts.js";
3
+ import { AGENT_MODELS, AGENT_COLORS, TOOLS, PERMISSION_MODES, PLUGIN_SUBAGENT_BLOCKED_TOOLS, } from "../contracts.js";
4
+ import { invalidEffortReason } from "../utils/effort.js";
5
+ import { formatAjvError, loadAgentFrontmatterSchema, summarizeErrors, } from "../plugin-schema.js";
4
6
  import { isRuleEnabled, getRuleSeverity } from "../types.js";
5
7
  import { parseFrontmatter } from "../utils/frontmatter.js";
8
+ import { artifactLabel, classifyUnknownFrontmatterKey, } from "../utils/frontmatter-keys.js";
6
9
  function findPluginRoot(agentFilePath) {
7
10
  let dir = dirname(agentFilePath);
8
11
  for (let i = 0; i < 10; i++) {
@@ -56,12 +59,16 @@ function parseToolsField(v) {
56
59
  }
57
60
  const RULES = [
58
61
  { id: "agent-md/valid-frontmatter", defaultSeverity: "error" },
62
+ { id: "agent-md/schema-valid", defaultSeverity: "error" },
59
63
  { id: "agent-md/name-required", defaultSeverity: "error" },
60
64
  { id: "agent-md/name-format", defaultSeverity: "error" },
61
65
  { id: "agent-md/description-required", defaultSeverity: "error" },
62
- { id: "agent-md/description-examples", defaultSeverity: "warning" },
66
+ { id: "agent-md/description-routing-guidance", defaultSeverity: "warning" },
63
67
  { id: "agent-md/model-required", defaultSeverity: "error" },
64
68
  { id: "agent-md/model-valid", defaultSeverity: "warning" },
69
+ { id: "agent-md/permission-mode-valid", defaultSeverity: "warning" },
70
+ { id: "agent-md/effort-valid", defaultSeverity: "warning" },
71
+ { id: "agent-md/max-turns-valid", defaultSeverity: "warning" },
65
72
  { id: "agent-md/color-required", defaultSeverity: "warning" },
66
73
  { id: "agent-md/color-valid", defaultSeverity: "warning" },
67
74
  { id: "agent-md/system-prompt-present", defaultSeverity: "error" },
@@ -97,6 +104,26 @@ export const agentMdLinter = {
97
104
  push(diag(config, filePath, "agent-md/valid-frontmatter", "error", fm.error ?? "Invalid frontmatter"));
98
105
  return diagnostics;
99
106
  }
107
+ // schema-valid — structural validation against the JSON Schema
108
+ // auto-extracted from Claude Code's agent frontmatter Zod validator.
109
+ // The hand-written rules below add Claude-Code-specific advice with
110
+ // friendlier messages (model-valid / color-valid re-check fields the
111
+ // schema also covers — that minimal double-reporting is acceptable).
112
+ // The extracted schema is intentionally permissive about unknown
113
+ // frontmatter keys (Claude Code still loads the agent), so those are
114
+ // NOT reported here — agent-md/no-unknown-frontmatter handles them.
115
+ // Skipped silently if the schema bundle isn't shipped with this install.
116
+ if (isRuleEnabled(config, "agent-md/schema-valid")) {
117
+ const compiled = loadAgentFrontmatterSchema();
118
+ if (compiled) {
119
+ const ok = compiled.validate(fm.data);
120
+ if (!ok && compiled.validate.errors) {
121
+ for (const err of summarizeErrors(compiled.validate.errors)) {
122
+ push(diag(config, filePath, "agent-md/schema-valid", "error", formatAjvError(err)));
123
+ }
124
+ }
125
+ }
126
+ }
100
127
  // name
101
128
  if (!("name" in fm.data) || typeof fm.data.name !== "string") {
102
129
  push(diag(config, filePath, "agent-md/name-required", "error", '"name" is required in frontmatter'));
@@ -115,8 +142,16 @@ export const agentMdLinter = {
115
142
  push(diag(config, filePath, "agent-md/description-required", "error", '"description" is required in frontmatter'));
116
143
  }
117
144
  else {
118
- if (!/<example>/i.test(fm.data.description)) {
119
- push(diag(config, filePath, "agent-md/description-examples", "warning", "Description should include <example> blocks for triggering"));
145
+ // An agent description must convey *when to route to it*. Three
146
+ // forms satisfy that: <example> blocks (the upstream few-shot
147
+ // convention), an explicit routing-triggers block, or prose that
148
+ // states the triggers. Warn only when the description does none.
149
+ const desc = fm.data.description;
150
+ const hasExamples = /<example>/i.test(desc);
151
+ const hasRoutingTriggers = /ROUTING TRIGGERS/i.test(desc);
152
+ const hasTriggerProse = /\b(use (it |this )?(for|when|to)\b|use this (agent|skill)|trigger(s|ed)? (on|by|when)|when the user|for any (task|request|work)|invoke (this|when))/i.test(desc);
153
+ if (!hasExamples && !hasRoutingTriggers && !hasTriggerProse) {
154
+ push(diag(config, filePath, "agent-md/description-routing-guidance", "warning", "Description should convey when to route to this agent — via <example> blocks, a routing-triggers block (<!-- BEGIN ROUTING TRIGGERS -->…<!-- END ROUTING TRIGGERS -->), or prose stating the triggers"));
120
155
  }
121
156
  }
122
157
  // model
@@ -127,6 +162,30 @@ export const agentMdLinter = {
127
162
  !fm.data.model.startsWith("claude-")) {
128
163
  push(diag(config, filePath, "agent-md/model-valid", "warning", `"model" must be one of: ${[...AGENT_MODELS].join(", ")} or a versioned claude-* model ID (got "${fm.data.model}")`));
129
164
  }
165
+ // permissionMode — typed as a permissive scalar by the Zod schema;
166
+ // at runtime only the PERMISSION_MODES values take effect.
167
+ if ("permissionMode" in fm.data) {
168
+ const pm = fm.data.permissionMode;
169
+ if (typeof pm !== "string" || !PERMISSION_MODES.has(pm)) {
170
+ push(diag(config, filePath, "agent-md/permission-mode-valid", "warning", `"permissionMode" must be one of: ${[...PERMISSION_MODES].join(", ")} (got ${JSON.stringify(pm)})`));
171
+ }
172
+ }
173
+ // effort — Zod types it as a permissive scalar; the field's describe()
174
+ // string restricts it to a named level or an integer.
175
+ if ("effort" in fm.data) {
176
+ const reason = invalidEffortReason(fm.data.effort);
177
+ if (reason) {
178
+ push(diag(config, filePath, "agent-md/effort-valid", "warning", reason));
179
+ }
180
+ }
181
+ // maxTurns — Zod accepts a string / float; only a positive integer
182
+ // is a meaningful turn budget.
183
+ if ("maxTurns" in fm.data) {
184
+ const mt = fm.data.maxTurns;
185
+ if (typeof mt !== "number" || !Number.isInteger(mt) || mt <= 0) {
186
+ push(diag(config, filePath, "agent-md/max-turns-valid", "warning", `"maxTurns" must be a positive integer (got ${JSON.stringify(mt)})`));
187
+ }
188
+ }
130
189
  // color
131
190
  if (!("color" in fm.data) || typeof fm.data.color !== "string") {
132
191
  push(diag(config, filePath, "agent-md/color-required", "warning", '"color" is required in frontmatter'));
@@ -134,11 +193,15 @@ export const agentMdLinter = {
134
193
  else if (!AGENT_COLORS.has(fm.data.color)) {
135
194
  push(diag(config, filePath, "agent-md/color-valid", "warning", `"color" must be one of: ${[...AGENT_COLORS].join(", ")} (got "${fm.data.color}")`));
136
195
  }
137
- // unknown frontmatter fields
138
- const knownFields = new Set([...AGENT_FRONTMATTER, "name", "color"]);
196
+ // Frontmatter keys: only flag cross-artifact misplacement. A key valid
197
+ // for a *different* markdown artifact (e.g. a skill-only key on an
198
+ // agent) gets an info; a key valid for no artifact stays silent.
199
+ // "name"/"color" are agent-valid but absent from the contract set.
200
+ const extraKnown = new Set(["name", "color"]);
139
201
  for (const key of Object.keys(fm.data)) {
140
- if (!knownFields.has(key)) {
141
- push(diag(config, filePath, "agent-md/no-unknown-frontmatter", "info", `Unknown frontmatter field "${key}"`));
202
+ const cls = classifyUnknownFrontmatterKey(key, "agent", extraKnown);
203
+ if (cls?.kind === "owned-by-other" && cls.owner) {
204
+ push(diag(config, filePath, "agent-md/no-unknown-frontmatter", "info", `"${key}" is ${artifactLabel(cls.owner)} frontmatter — Claude Code ignores it on an agent`));
142
205
  }
143
206
  }
144
207
  // system prompt (body)
@@ -50,10 +50,22 @@ export const claudeMdLinter = {
50
50
  push(diag(config, filePath, "claude-md/user-level-concise", "info", `User-level CLAUDE.md is ${lines.length} lines — keep global rules concise, put project-specific content in project CLAUDE.md files`));
51
51
  }
52
52
  if (scope === "project") {
53
- // Project CLAUDE.md should have a project description
54
- const hasProjectOverview = lines.some((l) => /^#+ .*(overview|project|about|description|what this is|introduction|summary)/i.test(l));
53
+ // Project CLAUDE.md should describe the project. This is satisfied by
54
+ // either an explicit overview-style heading OR descriptive prose near
55
+ // the top — a CLAUDE.md that opens with a title followed by a sentence
56
+ // of project description does not need a literal "Overview" heading.
57
+ const hasOverviewHeading = lines.some((l) => /^#+ .*(overview|project|about|description|what this is|introduction|summary)/i.test(l));
58
+ // Descriptive opening prose: a non-heading, non-list, non-blank line
59
+ // of reasonable length appearing before the first H2 section.
60
+ const firstH2 = lines.findIndex((l) => /^## /.test(l));
61
+ const preambleEnd = firstH2 >= 0 ? firstH2 : lines.length;
62
+ const hasOpeningProse = lines.slice(0, preambleEnd).some((l) => {
63
+ const t = l.trim();
64
+ return t.length >= 25 && !t.startsWith("#") && !/^[-*>|]/.test(t);
65
+ });
66
+ const hasProjectOverview = hasOverviewHeading || hasOpeningProse;
55
67
  if (!hasProjectOverview && h2Count > 0) {
56
- push(diag(config, filePath, "claude-md/project-has-overview", "info", "Project CLAUDE.md should include a project overview section"));
68
+ push(diag(config, filePath, "claude-md/project-has-overview", "info", "Project CLAUDE.md should include a project overview — an \"Overview\" section or descriptive opening prose"));
57
69
  }
58
70
  }
59
71
  // detect potential secrets
@@ -1,9 +1,17 @@
1
- import { COMMAND_FRONTMATTER, TOOLS } from "../contracts.js";
1
+ import { AGENT_MODELS, TOOLS } from "../contracts.js";
2
+ import { formatAjvError, loadCommandFrontmatterSchema, summarizeErrors, } from "../plugin-schema.js";
2
3
  import { isRuleEnabled, getRuleSeverity } from "../types.js";
4
+ import { invalidEffortReason } from "../utils/effort.js";
3
5
  import { parseFrontmatter } from "../utils/frontmatter.js";
6
+ import { artifactLabel, classifyUnknownFrontmatterKey, } from "../utils/frontmatter-keys.js";
4
7
  const RULES = [
5
8
  { id: "command-md/valid-frontmatter", defaultSeverity: "error" },
9
+ { id: "command-md/schema-valid", defaultSeverity: "error" },
6
10
  { id: "command-md/description-required", defaultSeverity: "error" },
11
+ { id: "command-md/name-format", defaultSeverity: "warning" },
12
+ { id: "command-md/model-valid", defaultSeverity: "warning" },
13
+ { id: "command-md/effort-valid", defaultSeverity: "warning" },
14
+ { id: "command-md/frontmatter-field-type", defaultSeverity: "warning" },
7
15
  { id: "command-md/allowed-tools-valid", defaultSeverity: "warning" },
8
16
  { id: "command-md/body-present", defaultSeverity: "warning" },
9
17
  { id: "command-md/no-unknown-frontmatter", defaultSeverity: "info" },
@@ -31,10 +39,60 @@ export const commandMdLinter = {
31
39
  push(diag(config, filePath, "command-md/valid-frontmatter", "error", fm.error ?? "Invalid frontmatter"));
32
40
  return diagnostics;
33
41
  }
42
+ // schema-valid — structural validation against the JSON Schema
43
+ // auto-extracted from Claude Code's command frontmatter Zod validator.
44
+ // The hand-written rules below add Claude-Code-specific advice (known
45
+ // tool names, body presence). The extracted schema is intentionally
46
+ // permissive about unknown frontmatter keys (Claude Code still loads the
47
+ // command), so those are NOT reported here — command-md/no-unknown-
48
+ // frontmatter handles them. Skipped silently if the schema bundle isn't
49
+ // shipped with this install.
50
+ if (isRuleEnabled(config, "command-md/schema-valid")) {
51
+ const compiled = loadCommandFrontmatterSchema();
52
+ if (compiled) {
53
+ const ok = compiled.validate(fm.data);
54
+ if (!ok && compiled.validate.errors) {
55
+ for (const err of summarizeErrors(compiled.validate.errors)) {
56
+ push(diag(config, filePath, "command-md/schema-valid", "error", formatAjvError(err)));
57
+ }
58
+ }
59
+ }
60
+ }
34
61
  // description
35
62
  if (!("description" in fm.data) || typeof fm.data.description !== "string") {
36
63
  push(diag(config, filePath, "command-md/description-required", "error", "\"description\" is required in frontmatter"));
37
64
  }
65
+ // name — optional display-name override. Zod types it as a permissive
66
+ // scalar; if present it must be a non-empty string.
67
+ if ("name" in fm.data) {
68
+ const name = fm.data.name;
69
+ if (typeof name !== "string" || name.trim() === "") {
70
+ push(diag(config, filePath, "command-md/name-format", "warning", `"name" must be a non-empty string (got ${JSON.stringify(name)})`));
71
+ }
72
+ }
73
+ // model — Zod types it as a permissive scalar; accept a named alias or a
74
+ // versioned claude-* model id. Mirrors agent-md/model-valid.
75
+ if ("model" in fm.data && typeof fm.data.model === "string") {
76
+ const model = fm.data.model;
77
+ if (!AGENT_MODELS.has(model) && !model.startsWith("claude-")) {
78
+ push(diag(config, filePath, "command-md/model-valid", "warning", `"model" must be one of: ${[...AGENT_MODELS].join(", ")} or a versioned claude-* model ID (got "${model}")`));
79
+ }
80
+ }
81
+ // effort — Zod types it as a permissive scalar; the field's describe()
82
+ // string restricts it to a named level or an integer.
83
+ if ("effort" in fm.data) {
84
+ const reason = invalidEffortReason(fm.data.effort);
85
+ if (reason) {
86
+ push(diag(config, filePath, "command-md/effort-valid", "warning", reason));
87
+ }
88
+ }
89
+ // boolean fields — the Zod schema accepts the string "true"; only a real
90
+ // boolean behaves as expected.
91
+ for (const field of ["disable-model-invocation", "user-invocable"]) {
92
+ if (field in fm.data && typeof fm.data[field] !== "boolean") {
93
+ push(diag(config, filePath, "command-md/frontmatter-field-type", "warning", `"${field}" must be a boolean (got ${JSON.stringify(fm.data[field])})`));
94
+ }
95
+ }
38
96
  // allowed-tools
39
97
  if ("allowed-tools" in fm.data) {
40
98
  const tools = fm.data["allowed-tools"];
@@ -46,11 +104,15 @@ export const commandMdLinter = {
46
104
  }
47
105
  }
48
106
  }
49
- // unknown frontmatter fields
50
- const knownFields = new Set([...COMMAND_FRONTMATTER, "allowed-tools", "argument-hint"]);
107
+ // Frontmatter keys: only flag cross-artifact misplacement. A key valid
108
+ // for a *different* markdown artifact gets an info; a key valid for no
109
+ // artifact stays silent. The hyphenated "allowed-tools"/"argument-hint"
110
+ // are command-valid aliases of the camelCase contract keys.
111
+ const extraKnown = new Set(["allowed-tools", "argument-hint"]);
51
112
  for (const key of Object.keys(fm.data)) {
52
- if (!knownFields.has(key)) {
53
- push(diag(config, filePath, "command-md/no-unknown-frontmatter", "info", `Unknown frontmatter field "${key}"`));
113
+ const cls = classifyUnknownFrontmatterKey(key, "command", extraKnown);
114
+ if (cls?.kind === "owned-by-other" && cls.owner) {
115
+ push(diag(config, filePath, "command-md/no-unknown-frontmatter", "info", `"${key}" is ${artifactLabel(cls.owner)} frontmatter — Claude Code ignores it on a command`));
54
116
  }
55
117
  }
56
118
  // body
@@ -1,7 +1,9 @@
1
1
  import { HOOK_EVENTS, HOOK_TYPES, PROMPT_EVENTS } from "../contracts.js";
2
+ import { formatAjvError, loadHooksJsonSchema, summarizeErrors, } from "../plugin-schema.js";
2
3
  import { isRuleEnabled, getRuleSeverity } from "../types.js";
3
4
  const RULES = [
4
5
  { id: "hooks-json/valid-json", defaultSeverity: "error" },
6
+ { id: "hooks-json/schema-valid", defaultSeverity: "error" },
5
7
  { id: "hooks-json/root-hooks-key", defaultSeverity: "error" },
6
8
  { id: "hooks-json/valid-event-names", defaultSeverity: "error" },
7
9
  { id: "hooks-json/hook-type-required", defaultSeverity: "error" },
@@ -52,6 +54,28 @@ export const hooksJsonLinter = {
52
54
  push(diag(config, filePath, "hooks-json/valid-json", "error", "hooks.json must be a JSON object"));
53
55
  return diagnostics;
54
56
  }
57
+ // schema-valid — structural validation against the JSON Schema
58
+ // auto-extracted from Claude Code's hooks-config Zod validator (the
59
+ // hook-event → matcher-array map, each matcher holding a discriminated
60
+ // union of hook definitions). The hand-written rules below add
61
+ // Claude-Code-specific advice the schema can't express (hardcoded-path
62
+ // warnings, prompt-event-support hints, …). The extracted schema is
63
+ // intentionally permissive about unknown hook fields (Claude Code's hook
64
+ // objects are not .strict()). Skipped silently if the schema bundle isn't
65
+ // shipped with this install.
66
+ if (isRuleEnabled(config, "hooks-json/schema-valid")) {
67
+ const compiled = loadHooksJsonSchema();
68
+ if (compiled) {
69
+ const ok = compiled.validate(parsed);
70
+ if (!ok && compiled.validate.errors) {
71
+ for (const err of summarizeErrors(compiled.validate.errors)) {
72
+ const firstSeg = err.instancePath.split("/").filter(Boolean)[0];
73
+ const p = firstSeg ? findKeyPosition(content, firstSeg) : undefined;
74
+ push(diag(config, filePath, "hooks-json/schema-valid", "error", formatAjvError(err), p?.line, p?.column));
75
+ }
76
+ }
77
+ }
78
+ }
55
79
  const root = parsed;
56
80
  // root "hooks" key
57
81
  if (!("hooks" in root) || typeof root.hooks !== "object" || root.hooks === null) {
@@ -1,10 +1,16 @@
1
1
  import { basename } from "node:path";
2
2
  import { MCP_SERVER_FIELDS } from "../contracts.js";
3
+ import { formatAjvError, loadMcpJsonSchema, summarizeErrors, } from "../plugin-schema.js";
3
4
  import { isRuleEnabled, getRuleSeverity } from "../types.js";
4
5
  import { isKebabCase } from "../utils/kebab-case.js";
6
+ // Valid `type` values for a URL-based HTTP MCP server. Claude Code v2.1.146
7
+ // accepts both — its HTTP transport schema is
8
+ // `enum(["http","streamable-http"]).transform(()=>"http")`.
9
+ const HTTP_TRANSPORT_TYPES = new Set(["http", "streamable-http"]);
5
10
  const RULES = [
6
11
  { id: "mcp-json/scope-file-name", defaultSeverity: "warning" },
7
12
  { id: "mcp-json/valid-json", defaultSeverity: "error" },
13
+ { id: "mcp-json/schema-valid", defaultSeverity: "error" },
8
14
  { id: "mcp-json/servers-required", defaultSeverity: "error" },
9
15
  { id: "mcp-json/servers-object", defaultSeverity: "error" },
10
16
  { id: "mcp-json/server-name-kebab", defaultSeverity: "info" },
@@ -69,6 +75,28 @@ export const mcpJsonLinter = {
69
75
  push(diag(config, filePath, "mcp-json/valid-json", "error", "mcp.json must be a JSON object"));
70
76
  return diagnostics;
71
77
  }
78
+ // schema-valid — structural validation against the JSON Schema
79
+ // auto-extracted from Claude Code's .mcp.json Zod validator (the
80
+ // `mcpServers` record of discriminated transport configs). The hand-written
81
+ // rules below add Claude-Code-specific advice the schema can't express
82
+ // (kebab-case names, ${CLAUDE_PLUGIN_ROOT} hints, …). The extracted schema
83
+ // is intentionally permissive about unknown server fields (Claude Code's
84
+ // server union is not .strict()), so those are NOT reported here —
85
+ // mcp-json/no-unknown-server-fields handles them. Skipped silently if the
86
+ // schema bundle isn't shipped with this install.
87
+ if (isRuleEnabled(config, "mcp-json/schema-valid")) {
88
+ const compiled = loadMcpJsonSchema();
89
+ if (compiled) {
90
+ const ok = compiled.validate(parsed);
91
+ if (!ok && compiled.validate.errors) {
92
+ for (const err of summarizeErrors(compiled.validate.errors)) {
93
+ const firstSeg = err.instancePath.split("/").filter(Boolean)[0];
94
+ const p = firstSeg ? findKeyPosition(content, firstSeg) : undefined;
95
+ push(diag(config, filePath, "mcp-json/schema-valid", "error", formatAjvError(err), p?.line, p?.column));
96
+ }
97
+ }
98
+ }
99
+ }
72
100
  // mcpServers required
73
101
  if (!("mcpServers" in parsed)) {
74
102
  push(diag(config, filePath, "mcp-json/servers-required", "error", "\"mcpServers\" field is required"));
@@ -109,8 +137,11 @@ export const mcpJsonLinter = {
109
137
  catch {
110
138
  push(diag(config, filePath, "mcp-json/url-valid", "error", `Server "${name}" has invalid URL: "${url}"`, sp?.line, sp?.column));
111
139
  }
112
- if ("type" in server && server.type !== "http") {
113
- push(diag(config, filePath, "mcp-json/type-matches-transport", "warning", `Server "${name}" has URL but type is "${server.type}" (expected "http")`, sp?.line, sp?.column));
140
+ // A URL-based HTTP server may declare "http" or "streamable-http".
141
+ // Claude Code v2.1.146 treats them identically its HTTP transport
142
+ // schema is `enum(["http","streamable-http"]).transform(()=>"http")`.
143
+ if ("type" in server && !HTTP_TRANSPORT_TYPES.has(server.type)) {
144
+ push(diag(config, filePath, "mcp-json/type-matches-transport", "warning", `Server "${name}" has URL but type is "${server.type}" (expected one of ${[...HTTP_TRANSPORT_TYPES].join(", ")})`, sp?.line, sp?.column));
114
145
  }
115
146
  }
116
147
  // stdio server checks