claudecode-linter 2.1.138 → 2.1.140

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.
@@ -0,0 +1,43 @@
1
+ export const CANONICAL_ARTIFACTS = [
2
+ {
3
+ basename: "plugin.json",
4
+ expectedPath: ".claude-plugin/plugin.json",
5
+ description: "plugin manifest",
6
+ },
7
+ {
8
+ basename: "marketplace.json",
9
+ expectedPath: ".claude-plugin/marketplace.json",
10
+ description: "marketplace manifest",
11
+ },
12
+ {
13
+ basename: "hooks.json",
14
+ expectedPath: "hooks/hooks.json",
15
+ description: "plugin hooks config",
16
+ },
17
+ {
18
+ basename: "SKILL.md",
19
+ expectedPattern: "skills/*/SKILL.md",
20
+ description: "skill manifest",
21
+ },
22
+ {
23
+ basename: ".mcp.json",
24
+ expectedPath: ".mcp.json",
25
+ description: "plugin MCP server definitions",
26
+ },
27
+ {
28
+ basename: ".lsp.json",
29
+ expectedPath: ".lsp.json",
30
+ description: "plugin LSP server configurations",
31
+ },
32
+ {
33
+ basename: "monitors.json",
34
+ expectedPath: "monitors/monitors.json",
35
+ description: "plugin background-monitor declarations",
36
+ },
37
+ {
38
+ basename: "settings.json",
39
+ expectedPath: "settings.json",
40
+ description: "plugin default settings",
41
+ },
42
+ ];
43
+ //# sourceMappingURL=canonical-paths.js.map
package/dist/contracts.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Auto-generated from contracts/claude-code-contracts.json
2
- // Claude Code v2.1.138 — extracted 2026-05-09T06:52:55.909Z
2
+ // Claude Code v2.1.140 — extracted 2026-05-13T01:02:43.174Z
3
3
  // Do not edit manually. Run: npm run generate-contracts
4
4
  export const TOOLS = new Set([
5
5
  "Agent",
@@ -222,12 +222,14 @@ export const SETTINGS_USER_FIELDS = new Set([
222
222
  "awsCredentialExport",
223
223
  "blockedMarketplaces",
224
224
  "channelsEnabled",
225
+ "claudeMd",
225
226
  "claudeMdExcludes",
226
227
  "cleanupPeriodDays",
227
228
  "companyAnnouncements",
228
229
  "daemonColdStart",
229
230
  "defaultShell",
230
231
  "deniedMcpServers",
232
+ "disableAgentView",
231
233
  "disableAllHooks",
232
234
  "disableAutoMode",
233
235
  "disableBackgroundAgents",
@@ -314,4 +316,12 @@ export const SETTINGS_USER_FIELDS = new Set([
314
316
  export const SETTINGS_PROJECT_FIELDS = new Set([
315
317
  "permissions",
316
318
  ]);
319
+ // Hand-curated denylist of tools declared in agent frontmatter that never
320
+ // reach plugin-defined subagents at runtime. Source: tracked upstream bugs
321
+ // https://github.com/anthropics/claude-code/issues/52055
322
+ // https://github.com/anthropics/claude-code/issues/52004
323
+ export const PLUGIN_SUBAGENT_BLOCKED_TOOLS = new Set([
324
+ "Glob",
325
+ "Grep",
326
+ ]);
317
327
  //# sourceMappingURL=contracts.js.map
package/dist/discovery.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { statSync, existsSync, readFileSync } from "node:fs";
2
- import { basename, dirname, resolve, join } from "node:path";
2
+ import { basename, dirname, resolve, join, relative } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { globSync } from "tinyglobby";
5
5
  import { minimatch } from "minimatch";
6
+ import { CANONICAL_ARTIFACTS } from "./canonical-paths.js";
6
7
  const CLAUDE_USER_DIR = join(homedir(), ".claude");
7
8
  function loadIgnoreFile(dir) {
8
9
  const ignoreFile = join(dir, ".claudecode-lint-ignore");
@@ -172,6 +173,19 @@ function discoverInDirectory(dir) {
172
173
  for (const f of hooks) {
173
174
  artifacts.push({ filePath: f, artifactType: "hooks-json" });
174
175
  }
176
+ // .lsp.json (flat record at plugin root; Claude Code reads from <plugin>/.lsp.json)
177
+ const lspDot = join(dir, ".lsp.json");
178
+ if (existsSync(lspDot)) {
179
+ artifacts.push({ filePath: lspDot, artifactType: "lsp-json" });
180
+ }
181
+ // monitors/monitors.json
182
+ const monitors = globSync("monitors/monitors.json", {
183
+ cwd: dir,
184
+ absolute: true,
185
+ });
186
+ for (const f of monitors) {
187
+ artifacts.push({ filePath: f, artifactType: "monitors-json" });
188
+ }
175
189
  // Claude config files — settings
176
190
  for (const name of ["settings.json", "settings.local.json"]) {
177
191
  // Direct in dir (handles both ~/.claude/settings.json and project root)
@@ -221,8 +235,58 @@ function discoverInDirectory(dir) {
221
235
  scope: detectScope(claudeMd),
222
236
  });
223
237
  }
238
+ // Misplaced-file scan: only meaningful inside a plugin tree
239
+ // (we use `.claude-plugin/` as the marker). Outside plugin
240
+ // trees, a stray `plugin.json` / `hooks.json` could legitimately
241
+ // belong to some other tool and we don't want to false-positive.
242
+ if (existsSync(join(dir, ".claude-plugin"))) {
243
+ for (const m of findMisplacedFiles(dir)) {
244
+ artifacts.push(m);
245
+ }
246
+ }
224
247
  return artifacts;
225
248
  }
249
+ /**
250
+ * Walk `pluginRoot` looking for files whose basename is reserved
251
+ * for a Claude Code artifact and which Claude Code reads only from
252
+ * a single canonical path. Anything matching a basename but
253
+ * sitting at a non-canonical location is returned as a
254
+ * `misplaced-file` artifact for the misplaced-file linter to flag.
255
+ *
256
+ * Ignores typical noise dirs (`node_modules`, `.git`, `dist`, the
257
+ * plugin install cache's `.in_use` / `.orphaned_at` markers).
258
+ */
259
+ function findMisplacedFiles(pluginRoot) {
260
+ const out = [];
261
+ for (const entry of CANONICAL_ARTIFACTS) {
262
+ const found = globSync(`**/${entry.basename}`, {
263
+ cwd: pluginRoot,
264
+ absolute: true,
265
+ // dot: walk into dot-directories like `.claude-plugin/`
266
+ // — that's exactly where the most common misplacement
267
+ // (hooks.json under `.claude-plugin/` instead of plugin
268
+ // root's `hooks/`) lives.
269
+ dot: true,
270
+ ignore: [
271
+ "**/node_modules/**",
272
+ "**/.git/**",
273
+ "**/dist/**",
274
+ "**/.in_use/**",
275
+ "**/.orphaned_at/**",
276
+ ],
277
+ });
278
+ for (const filePath of found) {
279
+ const rel = relative(pluginRoot, filePath);
280
+ const isCanonical = entry.expectedPattern
281
+ ? minimatch(rel, entry.expectedPattern)
282
+ : rel === entry.expectedPath;
283
+ if (!isCanonical) {
284
+ out.push({ filePath, artifactType: "misplaced-file" });
285
+ }
286
+ }
287
+ }
288
+ return out;
289
+ }
226
290
  function classifyFile(filePath) {
227
291
  const name = basename(filePath);
228
292
  const parent = basename(dirname(filePath));
@@ -232,6 +296,10 @@ function classifyFile(filePath) {
232
296
  return "skill-md";
233
297
  if (name === "hooks.json" && parent === "hooks")
234
298
  return "hooks-json";
299
+ if (name === ".lsp.json")
300
+ return "lsp-json";
301
+ if (name === "monitors.json" && parent === "monitors")
302
+ return "monitors-json";
235
303
  if (name.endsWith(".md") && parent === "agents")
236
304
  return "agent-md";
237
305
  if (name.endsWith(".md") && parent === "commands")
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { readFileSync, writeFileSync, copyFileSync, existsSync } from "node:fs";
3
3
  import { resolve, relative, dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { Command } from "commander";
5
+ import sade from "sade";
6
6
  import pc from "picocolors";
7
7
  import { loadConfig, mergeCliRules } from "./config.js";
8
8
  import { discoverArtifacts } from "./discovery.js";
@@ -16,6 +16,9 @@ import { hooksJsonLinter } from "./linters/hooks-json.js";
16
16
  import { settingsJsonLinter } from "./linters/settings-json.js";
17
17
  import { mcpJsonLinter } from "./linters/mcp-json.js";
18
18
  import { claudeMdLinter } from "./linters/claude-md.js";
19
+ import { lspJsonLinter } from "./linters/lsp-json.js";
20
+ import { monitorsJsonLinter } from "./linters/monitors-json.js";
21
+ import { misplacedFileLinter, MISPLACED_FILE_RULES, } from "./linters/misplaced-file.js";
19
22
  import { pluginJsonFixer } from "./fixers/plugin-json.js";
20
23
  import { frontmatterFixer } from "./fixers/frontmatter.js";
21
24
  import { hooksJsonFixer } from "./fixers/hooks-json.js";
@@ -30,6 +33,8 @@ import { HOOKS_JSON_RULES } from "./linters/hooks-json.js";
30
33
  import { SETTINGS_JSON_RULES } from "./linters/settings-json.js";
31
34
  import { MCP_JSON_RULES } from "./linters/mcp-json.js";
32
35
  import { CLAUDE_MD_RULES } from "./linters/claude-md.js";
36
+ import { LSP_JSON_RULES } from "./linters/lsp-json.js";
37
+ import { MONITORS_JSON_RULES } from "./linters/monitors-json.js";
33
38
  const LINTERS = {
34
39
  "plugin-json": pluginJsonLinter,
35
40
  "skill-md": skillMdLinter,
@@ -39,6 +44,9 @@ const LINTERS = {
39
44
  "settings-json": settingsJsonLinter,
40
45
  "mcp-json": mcpJsonLinter,
41
46
  "claude-md": claudeMdLinter,
47
+ "lsp-json": lspJsonLinter,
48
+ "monitors-json": monitorsJsonLinter,
49
+ "misplaced-file": misplacedFileLinter,
42
50
  };
43
51
  const FIXERS = {
44
52
  "plugin-json": pluginJsonFixer,
@@ -59,6 +67,9 @@ const ALL_RULES = [
59
67
  ...SETTINGS_JSON_RULES,
60
68
  ...MCP_JSON_RULES,
61
69
  ...CLAUDE_MD_RULES,
70
+ ...LSP_JSON_RULES,
71
+ ...MONITORS_JSON_RULES,
72
+ ...MISPLACED_FILE_RULES,
62
73
  ];
63
74
  function simpleDiff(oldContent, newContent, filePath) {
64
75
  if (oldContent === newContent)
@@ -95,27 +106,30 @@ function simpleDiff(oldContent, newContent, filePath) {
95
106
  }
96
107
  return lines.join("\n");
97
108
  }
98
- const program = new Command();
99
- program
100
- .name("claudecode-linter")
101
- .description("Linter for Claude Code plugin artifacts")
102
- .version(JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version)
103
- .argument("[paths...]", "Plugin directories or individual files", ["."])
109
+ const pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
110
+ sade("claudecode-linter", true)
111
+ .version(pkgVersion)
112
+ .describe("Linter for Claude Code plugin artifacts")
104
113
  .option("--lint", "Lint artifacts and report issues (default)")
105
114
  .option("--fix", "Auto-fix lint violations, then report remaining issues")
106
115
  .option("--format", "Format all artifacts for consistent style (no lint output)")
107
- .option("--output <type>", "Output format: human | json", "human")
108
- .option("--config <path>", "Config file path")
109
- .option("--scope <scope>", "Filter by scope: user | project | subdirectory")
110
- .option("--ignore <patterns>", "Comma-separated glob patterns to ignore")
116
+ .option("--output", "Output format: human | json", "human")
117
+ .option("--config", "Config file path")
118
+ .option("--scope", "Filter by scope: user | project | subdirectory")
119
+ .option("--ignore", "Comma-separated glob patterns to ignore")
111
120
  .option("--quiet", "Only show errors")
112
- .option("--enable <rules>", "Comma-separated rule IDs to enable")
113
- .option("--disable <rules>", "Comma-separated rule IDs to disable")
114
- .option("--rule <rule>", "Run only this single rule ID")
121
+ .option("--enable", "Comma-separated rule IDs to enable")
122
+ .option("--disable", "Comma-separated rule IDs to disable")
123
+ .option("--rule", "Run only this single rule ID")
115
124
  .option("--list-rules", "Print all rules with their default severity and exit")
116
125
  .option("--fix-dry-run", "Run fixers but print diff instead of writing")
117
- .option("--init [path]", "Copy default config to path (default: current directory)")
118
- .action(async (paths, opts) => {
126
+ .option("--init", "Copy default config to path (default: current directory)")
127
+ .action(async (opts) => {
128
+ // sade accepts variadic positional via opts._; default to ["."] when empty.
129
+ // Multi-word flags like --list-rules arrive in kebab-case form on opts.
130
+ const paths = Array.isArray(opts._) && opts._.length > 0 ? opts._ : ["."];
131
+ const listRules = !!opts["list-rules"];
132
+ const fixDryRun = !!opts["fix-dry-run"];
119
133
  try {
120
134
  if (opts.init !== undefined) {
121
135
  const __filename = fileURLToPath(import.meta.url);
@@ -131,7 +145,7 @@ program
131
145
  process.stdout.write(pc.green(`Created ${targetFile}\n`));
132
146
  process.exit(0);
133
147
  }
134
- if (opts.listRules) {
148
+ if (listRules) {
135
149
  const sevColor = { error: pc.red, warning: pc.yellow, info: pc.blue };
136
150
  for (const rule of ALL_RULES) {
137
151
  const color = sevColor[rule.defaultSeverity];
@@ -197,7 +211,7 @@ program
197
211
  }
198
212
  }
199
213
  }
200
- else if (opts.fixDryRun) {
214
+ else if (fixDryRun) {
201
215
  const fixer = FIXERS[artifact.artifactType];
202
216
  if (fixer) {
203
217
  const fixedContent = await fixer.fix(artifact.filePath, content, config);
@@ -231,7 +245,7 @@ program
231
245
  }
232
246
  process.exit(0);
233
247
  }
234
- if (!opts.fixDryRun) {
248
+ if (!fixDryRun) {
235
249
  const output = opts.output === "json"
236
250
  ? formatJson(results, !!opts.quiet)
237
251
  : formatHuman(results, !!opts.quiet);
@@ -244,6 +258,6 @@ program
244
258
  process.stderr.write(pc.red(`Fatal error: ${err.message}\n`));
245
259
  process.exit(2);
246
260
  }
247
- });
248
- program.parse();
261
+ })
262
+ .parse(process.argv);
249
263
  //# sourceMappingURL=index.js.map
@@ -1,6 +1,59 @@
1
- import { AGENT_FRONTMATTER, AGENT_MODELS, AGENT_COLORS } from "../contracts.js";
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { AGENT_FRONTMATTER, AGENT_MODELS, AGENT_COLORS, TOOLS, PLUGIN_SUBAGENT_BLOCKED_TOOLS, } from "../contracts.js";
2
4
  import { isRuleEnabled, getRuleSeverity } from "../types.js";
3
5
  import { parseFrontmatter } from "../utils/frontmatter.js";
6
+ function findPluginRoot(agentFilePath) {
7
+ let dir = dirname(agentFilePath);
8
+ for (let i = 0; i < 10; i++) {
9
+ if (existsSync(join(dir, ".claude-plugin", "plugin.json")))
10
+ return dir;
11
+ const parent = dirname(dir);
12
+ if (parent === dir)
13
+ break;
14
+ dir = parent;
15
+ }
16
+ return null;
17
+ }
18
+ function loadJson(p) {
19
+ try {
20
+ return JSON.parse(readFileSync(p, "utf-8"));
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ function loadPluginName(pluginRoot) {
27
+ const j = loadJson(join(pluginRoot, ".claude-plugin", "plugin.json"));
28
+ if (j && typeof j === "object" && j !== null && "name" in j) {
29
+ const n = j.name;
30
+ return typeof n === "string" ? n : null;
31
+ }
32
+ return null;
33
+ }
34
+ function loadMcpServerNames(pluginRoot) {
35
+ const j = loadJson(join(pluginRoot, ".mcp.json"));
36
+ const out = new Set();
37
+ if (j &&
38
+ typeof j === "object" &&
39
+ j !== null &&
40
+ "mcpServers" in j &&
41
+ typeof j.mcpServers === "object" &&
42
+ j.mcpServers !== null) {
43
+ for (const k of Object.keys(j.mcpServers)) {
44
+ out.add(k);
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+ function parseToolsField(v) {
50
+ if (typeof v !== "string")
51
+ return [];
52
+ return v
53
+ .split(",")
54
+ .map((s) => s.trim())
55
+ .filter(Boolean);
56
+ }
4
57
  const RULES = [
5
58
  { id: "agent-md/valid-frontmatter", defaultSeverity: "error" },
6
59
  { id: "agent-md/name-required", defaultSeverity: "error" },
@@ -9,12 +62,15 @@ const RULES = [
9
62
  { id: "agent-md/description-examples", defaultSeverity: "warning" },
10
63
  { id: "agent-md/model-required", defaultSeverity: "error" },
11
64
  { id: "agent-md/model-valid", defaultSeverity: "warning" },
12
- { id: "agent-md/color-required", defaultSeverity: "error" },
65
+ { id: "agent-md/color-required", defaultSeverity: "warning" },
13
66
  { id: "agent-md/color-valid", defaultSeverity: "warning" },
14
67
  { id: "agent-md/system-prompt-present", defaultSeverity: "error" },
15
68
  { id: "agent-md/system-prompt-length", defaultSeverity: "warning" },
16
69
  { id: "agent-md/system-prompt-second-person", defaultSeverity: "info" },
17
70
  { id: "agent-md/no-unknown-frontmatter", defaultSeverity: "info" },
71
+ { id: "agent-md/known-tools", defaultSeverity: "warning" },
72
+ { id: "agent-md/mcp-tools-resolve", defaultSeverity: "error" },
73
+ { id: "agent-md/plugin-subagent-blocked-tools", defaultSeverity: "warning" },
18
74
  ];
19
75
  function diag(config, filePath, ruleId, defaultSeverity, message, line, column) {
20
76
  if (!isRuleEnabled(config, ruleId))
@@ -47,10 +103,10 @@ export const agentMdLinter = {
47
103
  }
48
104
  else {
49
105
  const name = fm.data.name;
50
- if (!/^[a-z][a-z0-9-]*[a-z0-9]$/.test(name) ||
51
- name.length < 3 ||
106
+ if (!/^[a-z][a-z0-9-]*[a-z0-9]?$/.test(name) ||
107
+ name.length < 2 ||
52
108
  name.length > 50) {
53
- push(diag(config, filePath, "agent-md/name-format", "error", `"name" must be 3-50 chars, lowercase alphanumeric + hyphens (got "${name}")`));
109
+ push(diag(config, filePath, "agent-md/name-format", "error", `"name" must be 2-50 chars, lowercase alphanumeric + hyphens (got "${name}")`));
54
110
  }
55
111
  }
56
112
  // description
@@ -73,7 +129,7 @@ export const agentMdLinter = {
73
129
  }
74
130
  // color
75
131
  if (!("color" in fm.data) || typeof fm.data.color !== "string") {
76
- push(diag(config, filePath, "agent-md/color-required", "error", '"color" is required in frontmatter'));
132
+ push(diag(config, filePath, "agent-md/color-required", "warning", '"color" is required in frontmatter'));
77
133
  }
78
134
  else if (!AGENT_COLORS.has(fm.data.color)) {
79
135
  push(diag(config, filePath, "agent-md/color-valid", "warning", `"color" must be one of: ${[...AGENT_COLORS].join(", ")} (got "${fm.data.color}")`));
@@ -98,6 +154,50 @@ export const agentMdLinter = {
98
154
  push(diag(config, filePath, "agent-md/system-prompt-second-person", "info", 'System prompt should use second person ("You are...", "You will...")', fm.bodyStartLine));
99
155
  }
100
156
  }
157
+ // tools
158
+ const declaredTools = parseToolsField(fm.data.tools);
159
+ if (declaredTools.length > 0) {
160
+ const pluginRoot = findPluginRoot(filePath);
161
+ const pluginName = pluginRoot ? loadPluginName(pluginRoot) : null;
162
+ const mcpServers = pluginRoot
163
+ ? loadMcpServerNames(pluginRoot)
164
+ : new Set();
165
+ for (const t of declaredTools) {
166
+ if (t.startsWith("mcp__")) {
167
+ // Two valid shapes:
168
+ // mcp__plugin_<plugin>_<server>__<tool> plugin-namespaced
169
+ // mcp__<server>__<tool> user-config (non-plugin)
170
+ const ns = t.match(/^mcp__plugin_([a-z0-9-]+)_([a-z0-9-]+)__(.+)$/);
171
+ const bare = t.match(/^mcp__([a-z0-9-]+)__(.+)$/);
172
+ if (ns) {
173
+ const declaredPlugin = ns[1];
174
+ const declaredServer = ns[2];
175
+ if (pluginName && declaredPlugin !== pluginName) {
176
+ push(diag(config, filePath, "agent-md/mcp-tools-resolve", "error", `Tool "${t}" references plugin "${declaredPlugin}", but this plugin is named "${pluginName}".`));
177
+ }
178
+ if (pluginRoot &&
179
+ mcpServers.size > 0 &&
180
+ !mcpServers.has(declaredServer)) {
181
+ push(diag(config, filePath, "agent-md/mcp-tools-resolve", "error", `Tool "${t}" references MCP server "${declaredServer}", but .mcp.json declares: [${[...mcpServers].join(", ")}].`));
182
+ }
183
+ }
184
+ else if (bare && pluginRoot) {
185
+ const server = bare[1];
186
+ const rest = bare[2];
187
+ if (mcpServers.has(server)) {
188
+ push(diag(config, filePath, "agent-md/mcp-tools-resolve", "error", `Tool "${t}" uses bare "mcp__<server>__<tool>" form, but this is a plugin (${pluginName ?? "unknown name"}). Plugin agents must use the namespaced form: "mcp__plugin_${pluginName ?? "<plugin>"}_${server}__${rest}".`));
189
+ }
190
+ }
191
+ continue;
192
+ }
193
+ if (!TOOLS.has(t)) {
194
+ push(diag(config, filePath, "agent-md/known-tools", "warning", `Unknown built-in tool "${t}" in tools: field. Valid: ${[...TOOLS].sort().join(", ")}.`));
195
+ }
196
+ if (pluginRoot && PLUGIN_SUBAGENT_BLOCKED_TOOLS.has(t)) {
197
+ push(diag(config, filePath, "agent-md/plugin-subagent-blocked-tools", "warning", `Tool "${t}" is declared but does NOT reach plugin subagents at runtime — known Claude Code bug (https://github.com/anthropics/claude-code/issues/52055). The agent will silently lack this tool. For local file ops, use Bash with rg/find, or dispatch the built-in Explore subagent.`));
198
+ }
199
+ }
200
+ }
101
201
  return diagnostics;
102
202
  },
103
203
  };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Lints standalone `.lsp.json` files (a flat map of server-name → LSP server
3
+ * config). Validates against the schema auto-extracted from Claude Code's
4
+ * runtime parser `E.record(E.string(), RSH()).safeParse(content)` — see
5
+ * scripts/extract-plugin-schema.ts → buildLspSchema().
6
+ *
7
+ * The most common mistake (observed in cluster plugin 0.29.0 → 0.30.3) is
8
+ * wrapping the contents under a top-level `lspServers` key. That wrapper only
9
+ * belongs inside plugin.json; a dedicated `.lsp.json` file must be flat.
10
+ * lsp-json/no-lsp-servers-wrapper catches this with a friendlier message
11
+ * than the generic "missing required field command" Ajv would otherwise emit.
12
+ */
13
+ import { formatAjvError, loadLspSchema, summarizeErrors, } from "../plugin-schema.js";
14
+ import { isRuleEnabled, getRuleSeverity } from "../types.js";
15
+ const RULES = [
16
+ { id: "lsp-json/valid-json", defaultSeverity: "error" },
17
+ { id: "lsp-json/no-lsp-servers-wrapper", defaultSeverity: "error" },
18
+ { id: "lsp-json/schema-valid", defaultSeverity: "error" },
19
+ ];
20
+ function diag(config, filePath, ruleId, defaultSeverity, message, line, column) {
21
+ if (!isRuleEnabled(config, ruleId))
22
+ return null;
23
+ return {
24
+ rule: ruleId,
25
+ severity: getRuleSeverity(config, ruleId, defaultSeverity),
26
+ message,
27
+ file: filePath,
28
+ line,
29
+ column,
30
+ };
31
+ }
32
+ function findKeyPosition(content, key) {
33
+ const re = new RegExp(`"${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"\\s*:`);
34
+ const match = re.exec(content);
35
+ if (!match)
36
+ return undefined;
37
+ const before = content.slice(0, match.index);
38
+ const line = before.split("\n").length;
39
+ const lastNl = before.lastIndexOf("\n");
40
+ const column = match.index - lastNl;
41
+ return { line, column };
42
+ }
43
+ export const lspJsonLinter = {
44
+ artifactType: "lsp-json",
45
+ lint(filePath, content, config) {
46
+ const diagnostics = [];
47
+ const push = (d) => {
48
+ if (d)
49
+ diagnostics.push(d);
50
+ };
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(content);
54
+ }
55
+ catch (e) {
56
+ push(diag(config, filePath, "lsp-json/valid-json", "error", `Invalid JSON: ${e.message}`));
57
+ return diagnostics;
58
+ }
59
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
60
+ push(diag(config, filePath, "lsp-json/valid-json", "error", ".lsp.json must be a JSON object (map of server-name → config)"));
61
+ return diagnostics;
62
+ }
63
+ const obj = parsed;
64
+ // Special-case the cluster-plugin bug: wrapping content under "lspServers".
65
+ // That key is only valid inline in plugin.json. The dedicated file uses a
66
+ // flat map, so an "lspServers" wrapper is silently mis-validated by Claude
67
+ // Code at session start (each server entry fails RSH validation since it
68
+ // looks like one server config with sub-server keys).
69
+ if ("lspServers" in obj) {
70
+ const p = findKeyPosition(content, "lspServers");
71
+ push(diag(config, filePath, "lsp-json/no-lsp-servers-wrapper", "error", '.lsp.json must not have a top-level "lspServers" key — the file is itself the map of server-name → config. The "lspServers" wrapper only belongs in plugin.json under the lspServers field.', p?.line, p?.column));
72
+ }
73
+ // Schema validation — defers to the extracted Zod-equivalent JSON Schema.
74
+ if (isRuleEnabled(config, "lsp-json/schema-valid")) {
75
+ const ctx = loadLspSchema();
76
+ if (ctx) {
77
+ const ok = ctx.validate(parsed);
78
+ if (!ok && ctx.validate.errors) {
79
+ const filtered = summarizeErrors(ctx.validate.errors);
80
+ for (const err of filtered) {
81
+ const firstSeg = err.instancePath
82
+ .split("/")
83
+ .filter(Boolean)[0];
84
+ const p = firstSeg ? findKeyPosition(content, firstSeg) : undefined;
85
+ push(diag(config, filePath, "lsp-json/schema-valid", "error", formatAjvError(err), p?.line, p?.column));
86
+ }
87
+ }
88
+ }
89
+ }
90
+ return diagnostics;
91
+ },
92
+ };
93
+ export { RULES as LSP_JSON_RULES };
94
+ //# sourceMappingURL=lsp-json.js.map
@@ -0,0 +1,45 @@
1
+ import { basename } from "node:path";
2
+ import { CANONICAL_ARTIFACTS } from "../canonical-paths.js";
3
+ import { isRuleEnabled, getRuleSeverity } from "../types.js";
4
+ export const MISPLACED_FILE_RULES = [
5
+ { id: "misplaced-file/canonical-location", defaultSeverity: "warning" },
6
+ ];
7
+ /**
8
+ * Flag files whose basename is reserved for a Claude Code artifact
9
+ * but which sit at a non-canonical path. Claude Code reads each
10
+ * artifact only from its canonical location and silently ignores
11
+ * copies elsewhere, so this class of mistake is easy to make and
12
+ * hard to debug — `/reload-plugins` reports `0 hooks` (or skills,
13
+ * etc.) with no other signal.
14
+ *
15
+ * Discovery (see discovery.ts) populates this linter's inputs by
16
+ * walking plugin trees and matching basenames; this linter just
17
+ * emits one diagnostic per misplaced file with the expected
18
+ * location.
19
+ */
20
+ export const misplacedFileLinter = {
21
+ artifactType: "misplaced-file",
22
+ lint(filePath, _content, config) {
23
+ const ruleId = "misplaced-file/canonical-location";
24
+ if (!isRuleEnabled(config, ruleId))
25
+ return [];
26
+ const base = basename(filePath);
27
+ const entry = CANONICAL_ARTIFACTS.find((a) => a.basename === base);
28
+ if (!entry)
29
+ return [];
30
+ const expected = entry.expectedPath ?? entry.expectedPattern ?? "";
31
+ return [
32
+ {
33
+ rule: ruleId,
34
+ severity: getRuleSeverity(config, ruleId, "warning"),
35
+ message: `${base} is at a non-canonical path; ` +
36
+ `Claude Code reads ${entry.description} only from ` +
37
+ `\`${expected}\` (relative to plugin root) and ` +
38
+ `silently ignores copies elsewhere. ` +
39
+ `Move the file there or rename it.`,
40
+ file: filePath,
41
+ },
42
+ ];
43
+ },
44
+ };
45
+ //# sourceMappingURL=misplaced-file.js.map