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.
- package/.claudecode-lint.defaults.yaml +27 -1
- package/README.md +24 -6
- package/contracts/agent-frontmatter.schema.json +287 -0
- package/contracts/command-frontmatter.schema.json +219 -0
- package/contracts/hooks.schema.json +275 -0
- package/contracts/mcp.schema.json +302 -0
- package/contracts/settings.schema.json +2495 -0
- package/contracts/skill-frontmatter.schema.json +378 -0
- package/dist/contracts.js +77 -1
- package/dist/discovery.js +26 -2
- package/dist/fixers/settings-json.js +7 -6
- package/dist/index.js +23 -7
- package/dist/linters/agent-md.js +71 -8
- package/dist/linters/claude-md.js +15 -3
- package/dist/linters/command-md.js +67 -5
- package/dist/linters/hooks-json.js +24 -0
- package/dist/linters/mcp-json.js +33 -2
- package/dist/linters/settings-json.js +316 -29
- package/dist/linters/skill-md.js +142 -11
- package/dist/plugin-schema.js +19 -0
- package/dist/utils/effort.js +29 -0
- package/dist/utils/frontmatter-keys.js +67 -0
- package/package.json +9 -3
package/dist/linters/agent-md.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
-
import {
|
|
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-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
//
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
|
54
|
-
|
|
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 {
|
|
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
|
-
//
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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) {
|
package/dist/linters/mcp-json.js
CHANGED
|
@@ -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
|
-
|
|
113
|
-
|
|
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
|