claudecode-linter 2.1.150 → 2.1.153

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,11 +1,14 @@
1
- import { basename } from "node:path";
2
- import { SETTINGS_USER_FIELDS, SETTINGS_PROJECT_FIELDS, TOOLS, PERMISSIONS_FIELDS, PERMISSION_MODES, SANDBOX_FIELDS, SANDBOX_NETWORK_FIELDS, SANDBOX_FILESYSTEM_FIELDS, } from "../contracts.js";
1
+ import { basename, dirname, join } from "node:path";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { SETTINGS_USER_FIELDS, TOOLS, PERMISSIONS_FIELDS, PERMISSION_MODES, SANDBOX_FIELDS, SANDBOX_NETWORK_FIELDS, SANDBOX_FILESYSTEM_FIELDS, } from "../contracts.js";
3
4
  import { formatAjvError, loadSettingsSchema, summarizeErrors, } from "../plugin-schema.js";
4
5
  import { isRuleEnabled, getRuleSeverity } from "../types.js";
5
6
  const RULES = [
6
7
  { id: "settings-json/valid-json", defaultSeverity: "error" },
7
8
  { id: "settings-json/schema-valid", defaultSeverity: "error" },
8
- { id: "settings-json/scope-file-name", defaultSeverity: "error" },
9
+ // settings-json/scope-file-name removed in gitea#4 — .claude/settings.json
10
+ // is a valid project-shared settings source. See misplaced-file/* for
11
+ // wrong-path warnings.
9
12
  { id: "settings-json/scope-field", defaultSeverity: "warning" },
10
13
  { id: "settings-json/no-unknown-fields", defaultSeverity: "warning" },
11
14
  { id: "settings-json/permissions-object", defaultSeverity: "error" },
@@ -28,6 +31,7 @@ const RULES = [
28
31
  { id: "settings-json/plugins-boolean", defaultSeverity: "warning" },
29
32
  { id: "settings-json/plugins-format", defaultSeverity: "warning" },
30
33
  { id: "settings-json/skip-prompt-boolean", defaultSeverity: "error" },
34
+ { id: "settings-json/disable-project-mcpjson-shadow", defaultSeverity: "warning" },
31
35
  ];
32
36
  // Expected type for each known sandbox sub-key, and for the nested
33
37
  // network/filesystem objects. Mirrors Claude Code's Zod schema.
@@ -242,22 +246,47 @@ export const settingsJsonLinter = {
242
246
  }
243
247
  }
244
248
  }
245
- // Scope-aware: settings.json (non-local) should only be at user level
246
- if (!isLocal && scope && scope !== "user") {
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`));
248
- }
249
- // Determine which fields are valid for this scope
250
- const knownFields = (scope === "user" || !scope) ? SETTINGS_USER_FIELDS : SETTINGS_PROJECT_FIELDS;
251
- // unknown/misplaced top-level fields
249
+ // gitea#4: `.claude/settings.json` is a first-class project settings source
250
+ // (`projectSettings`, committed/shared) distinct from `.claude/settings.local.json`
251
+ // (`localSettings`, gitignored). Claude Code's bundle defines both:
252
+ // `case "projectSettings": return Rk.join(".claude","settings.json")`
253
+ // `case "localSettings": return "project, gitignored"`
254
+ // The rule no longer fires for plain project-level settings.json; the
255
+ // `misplaced-file/canonical-location` rule covers files at the wrong path.
256
+ // Determine which fields are valid for this scope. gitea#2: the legacy
257
+ // SETTINGS_PROJECT_FIELDS whitelist is too narrow — Claude Code accepts
258
+ // most user-level fields at project scope too. We now treat all known
259
+ // user fields as valid at project scope, except a small denylist of
260
+ // genuinely user-only fields (auth helpers, plugin enablement, dangerous
261
+ // mode), which still warn via `settings-json/scope-field`.
262
+ const knownFields = SETTINGS_USER_FIELDS;
263
+ // Fields that genuinely only take effect at user scope. Project-scope
264
+ // versions are silently ignored by Claude Code. Source: bundle inspection
265
+ // (auth helpers run from user creds; enabledPlugins / skip-permission
266
+ // flags can't be enabled by a checked-in repo for security reasons).
267
+ const USER_ONLY_FIELDS = new Set([
268
+ "apiKeyHelper",
269
+ "awsAuthRefresh",
270
+ "awsCredentialExport",
271
+ "gcpAuthRefresh",
272
+ "proxyAuthHelper",
273
+ "policyHelper",
274
+ "enabledPlugins",
275
+ "skipDangerousModePermissionPrompt",
276
+ "forceLoginMethod",
277
+ "forceLoginOrgUUID",
278
+ ]);
279
+ // unknown / misplaced top-level fields
252
280
  for (const key of Object.keys(parsed)) {
253
281
  if (!knownFields.has(key)) {
254
282
  const p = findKeyPosition(content, key);
255
- if (SETTINGS_USER_FIELDS.has(key) && scope && scope !== "user") {
256
- push(diag(config, filePath, "settings-json/scope-field", "warning", `"${key}" is a user-level field — it has no effect in project-level settings.local.json`, p?.line, p?.column));
257
- }
258
- else if (!SETTINGS_USER_FIELDS.has(key)) {
259
- push(diag(config, filePath, "settings-json/no-unknown-fields", "warning", `Unknown top-level field "${key}"`, p?.line, p?.column));
260
- }
283
+ push(diag(config, filePath, "settings-json/no-unknown-fields", "warning", `Unknown top-level field "${key}"`, p?.line, p?.column));
284
+ continue;
285
+ }
286
+ // gitea#2: only the genuinely user-only denylist warns at project scope.
287
+ if (USER_ONLY_FIELDS.has(key) && scope && scope !== "user") {
288
+ const p = findKeyPosition(content, key);
289
+ push(diag(config, filePath, "settings-json/scope-field", "warning", `"${key}" only takes effect at user level (~/.claude/settings.json); it is ignored in project settings`, p?.line, p?.column));
261
290
  }
262
291
  }
263
292
  // Validate the sub-keys of a nested object against a known-field set and a
@@ -444,6 +473,50 @@ export const settingsJsonLinter = {
444
473
  }
445
474
  }
446
475
  }
476
+ // gitea#8: when this settings.json sits at <plugin-root>/.claude/settings.json
477
+ // and the plugin also ships a <plugin-root>/.mcp.json, Claude Code loads the
478
+ // plugin's MCP servers twice when launched from the plugin dir (once as the
479
+ // plugin, once as project-scope .mcp.json) and dedupes — triggering /doctor
480
+ // "skipped" warnings. The committed suppression is to list each server name
481
+ // in this file's `disabledMcpjsonServers`.
482
+ if (isRuleEnabled(config, "settings-json/disable-project-mcpjson-shadow") &&
483
+ basename(filePath) === "settings.json" &&
484
+ basename(dirname(filePath)) === ".claude") {
485
+ const pluginRoot = dirname(dirname(filePath));
486
+ const pluginJson = join(pluginRoot, ".claude-plugin", "plugin.json");
487
+ const mcpJson = join(pluginRoot, ".mcp.json");
488
+ if (existsSync(pluginJson) && existsSync(mcpJson)) {
489
+ let mcpServerNames = [];
490
+ try {
491
+ const mcp = JSON.parse(readFileSync(mcpJson, "utf-8"));
492
+ if (isPlainObject(mcp.mcpServers)) {
493
+ mcpServerNames = Object.keys(mcp.mcpServers);
494
+ }
495
+ }
496
+ catch { /* ignore — broken .mcp.json is the mcp-json linter's job */ }
497
+ // settings.local.json with the same disable also satisfies the rule.
498
+ let disabled = Array.isArray(parsed.disabledMcpjsonServers)
499
+ ? parsed.disabledMcpjsonServers
500
+ : [];
501
+ const localPath = join(dirname(filePath), "settings.local.json");
502
+ if (existsSync(localPath)) {
503
+ try {
504
+ const local = JSON.parse(readFileSync(localPath, "utf-8"));
505
+ if (Array.isArray(local.disabledMcpjsonServers)) {
506
+ disabled = disabled.concat(local.disabledMcpjsonServers);
507
+ }
508
+ }
509
+ catch { /* ignore */ }
510
+ }
511
+ const disabledSet = new Set(disabled.filter((x) => typeof x === "string"));
512
+ const dp = findKeyPosition(content, "disabledMcpjsonServers");
513
+ for (const name of mcpServerNames) {
514
+ if (!disabledSet.has(name)) {
515
+ push(diag(config, filePath, "settings-json/disable-project-mcpjson-shadow", "warning", `Server "${name}" is declared in .mcp.json but not in .claude/settings.json#disabledMcpjsonServers — when Claude Code is launched from this plugin directory, the MCP server is loaded twice (plugin + project-level), triggering /doctor "skipped" warnings`, dp?.line, dp?.column));
516
+ }
517
+ }
518
+ }
519
+ }
447
520
  // skipDangerousModePermissionPrompt — user-level only
448
521
  if ("skipDangerousModePermissionPrompt" in parsed) {
449
522
  if (typeof parsed.skipDangerousModePermissionPrompt !== "boolean") {
@@ -52,7 +52,13 @@ function hasTriggerSignal(desc) {
52
52
  // ("Trigger on …", "Trigger when/whenever/if …", "Triggers: …", "TRIGGER
53
53
  // when:") — not when "trigger" merely appears as a noun (e.g. the phrase
54
54
  // "no trigger phrases").
55
- 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;
55
+ // gitea#5: widened to accept more applicability phrasings observed in the
56
+ // wild — "Loaded automatically when …", "when a turn touches …",
57
+ // "applies to …", "for memory operations", "Memory protocol for the X
58
+ // MCP" (a noun-opener that names the domain). The runtime makes
59
+ // `description` optional, so this rule is advisory; aim for low false
60
+ // positive rate rather than complete coverage.
61
+ 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|loaded|loads|reaches for|fires) (when|whenever|if|to|for|automatically|on)\b|\bwhen (the user|you|asked|working|debugging|diagnosing|investigating|reviewing|writing|building|creating|editing|setting up|deploying|the task|a request|a question|a message|a turn|the turn|the session|the conversation|the model|you need|there|something|anything)\b|\bwhen [a-z]+ing\b|\bfor (debugging|diagnosing|tasks|requests|questions|when|any|memory|recall|search|saves?|the )\b|\bprotocol for\b|\barchivist for\b|\bhandler for\b|\bauto(matic|-?)\s+(save|load|invocation)\b/i;
56
62
  if (triggerPhrases.test(d))
57
63
  return true;
58
64
  // First "sentence-ish" chunk — used to detect imperative / gerund openers.
@@ -25,36 +25,87 @@ function getAjv() {
25
25
  addFormats(ajvShared);
26
26
  return ajvShared;
27
27
  }
28
- function loadCompiledSchema(fileName) {
29
- if (compiledCache.has(fileName))
30
- return compiledCache.get(fileName) ?? null;
28
+ function tryReadFile(relPath) {
31
29
  const candidates = [
32
- ...assetCandidates(import.meta.url, ["..", "contracts", fileName]),
33
- ...assetCandidates(import.meta.url, ["..", "..", "contracts", fileName]),
30
+ ...assetCandidates(import.meta.url, ["..", ...relPath]),
31
+ ...assetCandidates(import.meta.url, ["..", "..", ...relPath]),
34
32
  ];
35
- let raw = null;
36
33
  for (const p of candidates) {
37
34
  try {
38
- raw = readFileSync(p, "utf8");
39
- break;
35
+ return readFileSync(p, "utf8");
40
36
  }
41
37
  catch {
42
38
  // try next
43
39
  }
44
40
  }
41
+ return null;
42
+ }
43
+ /**
44
+ * Compile a schema document, handling both wrapper shapes:
45
+ * - extracted (`{ extractedFromClaudeCodeVersion, schema: {...} }`)
46
+ * - bare JSON Schema (the schemastore.org files, no envelope)
47
+ */
48
+ function compileSchemaDoc(raw, sourceLabel) {
49
+ const parsed = JSON.parse(raw);
50
+ let schema;
51
+ let version;
52
+ if ("extractedFromClaudeCodeVersion" in parsed &&
53
+ typeof parsed.extractedFromClaudeCodeVersion === "string" &&
54
+ "schema" in parsed &&
55
+ typeof parsed.schema === "object" &&
56
+ parsed.schema !== null) {
57
+ schema = parsed.schema;
58
+ version = parsed.extractedFromClaudeCodeVersion;
59
+ }
60
+ else {
61
+ // Bare schemastore document. Strip `$id` (Ajv registers it globally
62
+ // and trips `$id already exists` on a second compile) and any
63
+ // `$schema` meta-pointer Ajv2020 can't resolve (draft-07 etc.).
64
+ const { $id: _id, $schema: _meta, ...rest } = parsed;
65
+ void _id;
66
+ void _meta;
67
+ schema = rest;
68
+ version = sourceLabel;
69
+ }
70
+ const propSrc = schema.properties ?? {};
71
+ return {
72
+ validate: getAjv().compile(schema),
73
+ extractedFromVersion: version,
74
+ knownFields: new Set(Object.keys(propSrc)),
75
+ };
76
+ }
77
+ function loadCompiledSchema(fileName) {
78
+ if (compiledCache.has(fileName))
79
+ return compiledCache.get(fileName) ?? null;
80
+ const raw = tryReadFile(["contracts", fileName]);
45
81
  if (!raw) {
46
82
  compiledCache.set(fileName, null);
47
83
  return null;
48
84
  }
49
- const wrapped = JSON.parse(raw);
50
- const compiled = {
51
- validate: getAjv().compile(wrapped.schema),
52
- extractedFromVersion: wrapped.extractedFromClaudeCodeVersion,
53
- knownFields: new Set(Object.keys(wrapped.schema.properties ?? {})),
54
- };
85
+ const compiled = compileSchemaDoc(raw, fileName);
55
86
  compiledCache.set(fileName, compiled);
56
87
  return compiled;
57
88
  }
89
+ /**
90
+ * Load a schemastore.org-curated schema from `contracts/schemastore/`.
91
+ * Schemastore is preferred for top-level artifact validation because it's
92
+ * Anthropic's own published source of truth and stays stable across Claude
93
+ * Code minifier rotations. The Zod-extracted schemas (`loadCompiledSchema`)
94
+ * remain authoritative for sub-shapes schemastore doesn't enumerate.
95
+ */
96
+ function loadSchemastoreSchema(fileName) {
97
+ const cacheKey = `schemastore/${fileName}`;
98
+ if (compiledCache.has(cacheKey))
99
+ return compiledCache.get(cacheKey) ?? null;
100
+ const raw = tryReadFile(["contracts", "schemastore", fileName]);
101
+ if (!raw) {
102
+ compiledCache.set(cacheKey, null);
103
+ return null;
104
+ }
105
+ const compiled = compileSchemaDoc(raw, `schemastore:${fileName}`);
106
+ compiledCache.set(cacheKey, compiled);
107
+ return compiled;
108
+ }
58
109
  export function loadLspSchema() {
59
110
  return loadCompiledSchema("lsp.schema.json");
60
111
  }
@@ -62,8 +113,24 @@ export function loadMonitorsSchema() {
62
113
  return loadCompiledSchema("monitors.schema.json");
63
114
  }
64
115
  export function loadSettingsSchema() {
116
+ // gitea#6: prefer the Zod-extracted schema (runtime truth, e.g.
117
+ // `disableAutoMode` is `boolean` in 2.1.150) over schemastore (which
118
+ // still lists it as a string literal — out of date). Schemastore is
119
+ // only used for artifacts with no Zod source (marketplace, keybindings).
65
120
  return loadCompiledSchema("settings.schema.json");
66
121
  }
122
+ /**
123
+ * Marketplace and keybindings schemas come exclusively from schemastore —
124
+ * there is no Zod source for them in the Claude Code bundle. Returns null
125
+ * if the user's install was shipped without the schemastore bundle (rare;
126
+ * the package.json includes them in `files`).
127
+ */
128
+ export function loadMarketplaceSchema() {
129
+ return loadSchemastoreSchema("marketplace.schema.json");
130
+ }
131
+ export function loadKeybindingsSchema() {
132
+ return loadSchemastoreSchema("keybindings.schema.json");
133
+ }
67
134
  export function loadMcpJsonSchema() {
68
135
  return loadCompiledSchema("mcp.schema.json");
69
136
  }
@@ -82,36 +149,17 @@ export function loadCommandFrontmatterSchema() {
82
149
  export function loadPluginSchema() {
83
150
  if (cached)
84
151
  return cached;
85
- const candidates = [
86
- ...assetCandidates(import.meta.url, ["..", "contracts", "plugin.schema.json"]),
87
- ...assetCandidates(import.meta.url, [
88
- "..",
89
- "..",
90
- "contracts",
91
- "plugin.schema.json",
92
- ]),
93
- ];
94
- let raw = null;
95
- for (const p of candidates) {
96
- try {
97
- raw = readFileSync(p, "utf8");
98
- break;
99
- }
100
- catch {
101
- // try next
102
- }
103
- }
152
+ // gitea#6: prefer the Zod-extracted plugin.schema.json (runtime truth);
153
+ // schemastore copy stays committed at `contracts/schemastore/` for
154
+ // reference and for the marketplace/keybindings artifacts only.
155
+ const raw = tryReadFile(["contracts", "plugin.schema.json"]);
104
156
  if (!raw)
105
157
  return null;
106
- const wrapped = JSON.parse(raw);
107
- const ajv = new Ajv2020({ allErrors: true, strict: false });
108
- addFormats(ajv);
109
- const validate = ajv.compile(wrapped.schema);
110
- const knownFields = new Set(Object.keys(wrapped.schema.properties ?? {}));
158
+ const compiled = compileSchemaDoc(raw, "plugin.schema.json");
111
159
  cached = {
112
- validate,
113
- extractedFromVersion: wrapped.extractedFromClaudeCodeVersion,
114
- knownFields,
160
+ validate: compiled.validate,
161
+ extractedFromVersion: compiled.extractedFromVersion,
162
+ knownFields: compiled.knownFields,
115
163
  };
116
164
  return cached;
117
165
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudecode-linter",
3
- "version": "2.1.150",
3
+ "version": "2.1.153",
4
4
  "description": "Standalone linter for Claude Code plugins and configuration files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,8 +17,9 @@
17
17
  "deps:update": "ncu -u",
18
18
  "knip": "knip --exclude types",
19
19
  "check-deps": "tsx scripts/check-deps.ts",
20
- "extract-contracts": "tsx scripts/extract-contracts.ts && tsx scripts/extract-plugin-schema.ts",
20
+ "extract-contracts": "tsx scripts/extract-contracts.ts && tsx scripts/extract-plugin-schema.ts && tsx scripts/fetch-schemastore.ts",
21
21
  "extract-plugin-schema": "tsx scripts/extract-plugin-schema.ts",
22
+ "fetch-schemastore": "tsx scripts/fetch-schemastore.ts",
22
23
  "generate-contracts": "tsx scripts/generate-contracts.ts"
23
24
  },
24
25
  "files": [
@@ -32,6 +33,7 @@
32
33
  "contracts/command-frontmatter.schema.json",
33
34
  "contracts/mcp.schema.json",
34
35
  "contracts/hooks.schema.json",
36
+ "contracts/schemastore/*.json",
35
37
  ".claudecode-lint.defaults.yaml",
36
38
  "README.md"
37
39
  ],