claudecode-linter 2.1.148 → 2.1.150-patch.1
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 +21 -2
- package/README.md +90 -3
- package/contracts/agent-frontmatter.schema.json +142 -2
- package/contracts/command-frontmatter.schema.json +128 -2
- package/contracts/hooks.schema.json +275 -0
- package/contracts/lsp.schema.json +2 -2
- package/contracts/mcp.schema.json +326 -0
- package/contracts/monitors.schema.json +3 -3
- package/contracts/plugin.schema.json +672 -22
- package/contracts/schemastore/keybindings.schema.json +179 -0
- package/contracts/schemastore/manifest.json +25 -0
- package/contracts/schemastore/marketplace.schema.json +2067 -0
- package/contracts/schemastore/plugin-manifest.schema.json +1834 -0
- package/contracts/schemastore/settings.schema.json +3062 -0
- package/contracts/settings.schema.json +333 -4
- package/contracts/skill-frontmatter.schema.json +198 -2
- package/dist/config.js +13 -8
- package/dist/contracts.js +20 -1
- package/dist/discovery.js +23 -0
- package/dist/formatters/human.js +3 -2
- package/dist/index.js +80 -12
- package/dist/linters/agent-md.js +44 -7
- package/dist/linters/command-md.js +37 -1
- package/dist/linters/hooks-json.js +24 -0
- package/dist/linters/keybindings-json.js +53 -0
- package/dist/linters/marketplace-json.js +55 -0
- package/dist/linters/mcp-json.js +24 -0
- package/dist/linters/settings-json.js +42 -15
- package/dist/linters/skill-md.js +48 -1
- package/dist/plugin-schema.js +96 -39
- package/dist/utils/asset-path.js +56 -0
- package/dist/utils/effort.js +29 -0
- package/dist/utils/frontmatter-keys.js +32 -3
- package/dist/utils/frontmatter.js +1 -1
- package/dist/utils/safe-write.js +36 -0
- package/dist/utils/terminal.js +18 -0
- package/package.json +6 -2
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
|
-
import { SETTINGS_USER_FIELDS,
|
|
2
|
+
import { SETTINGS_USER_FIELDS, TOOLS, PERMISSIONS_FIELDS, PERMISSION_MODES, SANDBOX_FIELDS, SANDBOX_NETWORK_FIELDS, SANDBOX_FILESYSTEM_FIELDS, } from "../contracts.js";
|
|
3
3
|
import { formatAjvError, loadSettingsSchema, summarizeErrors, } from "../plugin-schema.js";
|
|
4
4
|
import { isRuleEnabled, getRuleSeverity } from "../types.js";
|
|
5
5
|
const RULES = [
|
|
6
6
|
{ id: "settings-json/valid-json", defaultSeverity: "error" },
|
|
7
7
|
{ id: "settings-json/schema-valid", defaultSeverity: "error" },
|
|
8
|
-
|
|
8
|
+
// settings-json/scope-file-name removed in gitea#4 — .claude/settings.json
|
|
9
|
+
// is a valid project-shared settings source. See misplaced-file/* for
|
|
10
|
+
// wrong-path warnings.
|
|
9
11
|
{ id: "settings-json/scope-field", defaultSeverity: "warning" },
|
|
10
12
|
{ id: "settings-json/no-unknown-fields", defaultSeverity: "warning" },
|
|
11
13
|
{ id: "settings-json/permissions-object", defaultSeverity: "error" },
|
|
@@ -242,22 +244,47 @@ export const settingsJsonLinter = {
|
|
|
242
244
|
}
|
|
243
245
|
}
|
|
244
246
|
}
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
//
|
|
247
|
+
// gitea#4: `.claude/settings.json` is a first-class project settings source
|
|
248
|
+
// (`projectSettings`, committed/shared) distinct from `.claude/settings.local.json`
|
|
249
|
+
// (`localSettings`, gitignored). Claude Code's bundle defines both:
|
|
250
|
+
// `case "projectSettings": return Rk.join(".claude","settings.json")`
|
|
251
|
+
// `case "localSettings": return "project, gitignored"`
|
|
252
|
+
// The rule no longer fires for plain project-level settings.json; the
|
|
253
|
+
// `misplaced-file/canonical-location` rule covers files at the wrong path.
|
|
254
|
+
// Determine which fields are valid for this scope. gitea#2: the legacy
|
|
255
|
+
// SETTINGS_PROJECT_FIELDS whitelist is too narrow — Claude Code accepts
|
|
256
|
+
// most user-level fields at project scope too. We now treat all known
|
|
257
|
+
// user fields as valid at project scope, except a small denylist of
|
|
258
|
+
// genuinely user-only fields (auth helpers, plugin enablement, dangerous
|
|
259
|
+
// mode), which still warn via `settings-json/scope-field`.
|
|
260
|
+
const knownFields = SETTINGS_USER_FIELDS;
|
|
261
|
+
// Fields that genuinely only take effect at user scope. Project-scope
|
|
262
|
+
// versions are silently ignored by Claude Code. Source: bundle inspection
|
|
263
|
+
// (auth helpers run from user creds; enabledPlugins / skip-permission
|
|
264
|
+
// flags can't be enabled by a checked-in repo for security reasons).
|
|
265
|
+
const USER_ONLY_FIELDS = new Set([
|
|
266
|
+
"apiKeyHelper",
|
|
267
|
+
"awsAuthRefresh",
|
|
268
|
+
"awsCredentialExport",
|
|
269
|
+
"gcpAuthRefresh",
|
|
270
|
+
"proxyAuthHelper",
|
|
271
|
+
"policyHelper",
|
|
272
|
+
"enabledPlugins",
|
|
273
|
+
"skipDangerousModePermissionPrompt",
|
|
274
|
+
"forceLoginMethod",
|
|
275
|
+
"forceLoginOrgUUID",
|
|
276
|
+
]);
|
|
277
|
+
// unknown / misplaced top-level fields
|
|
252
278
|
for (const key of Object.keys(parsed)) {
|
|
253
279
|
if (!knownFields.has(key)) {
|
|
254
280
|
const p = findKeyPosition(content, key);
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
281
|
+
push(diag(config, filePath, "settings-json/no-unknown-fields", "warning", `Unknown top-level field "${key}"`, p?.line, p?.column));
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
// gitea#2: only the genuinely user-only denylist warns at project scope.
|
|
285
|
+
if (USER_ONLY_FIELDS.has(key) && scope && scope !== "user") {
|
|
286
|
+
const p = findKeyPosition(content, key);
|
|
287
|
+
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
288
|
}
|
|
262
289
|
}
|
|
263
290
|
// Validate the sub-keys of a nested object against a known-field set and a
|
package/dist/linters/skill-md.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { AGENT_MODELS, TOOLS } from "../contracts.js";
|
|
1
2
|
import { formatAjvError, loadSkillFrontmatterSchema, 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";
|
|
4
6
|
import { artifactLabel, classifyUnknownFrontmatterKey, } from "../utils/frontmatter-keys.js";
|
|
5
7
|
import { isKebabCase } from "../utils/kebab-case.js";
|
|
@@ -13,6 +15,10 @@ const RULES = [
|
|
|
13
15
|
{ id: "skill-md/description-max-length", defaultSeverity: "error" },
|
|
14
16
|
{ id: "skill-md/description-no-angle-brackets", defaultSeverity: "error" },
|
|
15
17
|
{ id: "skill-md/description-trigger-phrases", defaultSeverity: "warning" },
|
|
18
|
+
{ id: "skill-md/model-valid", defaultSeverity: "warning" },
|
|
19
|
+
{ id: "skill-md/effort-valid", defaultSeverity: "warning" },
|
|
20
|
+
{ id: "skill-md/allowed-tools-valid", defaultSeverity: "warning" },
|
|
21
|
+
{ id: "skill-md/frontmatter-field-type", defaultSeverity: "warning" },
|
|
16
22
|
{ id: "skill-md/no-unknown-frontmatter", defaultSeverity: "info" },
|
|
17
23
|
{ id: "skill-md/body-word-count", defaultSeverity: "info" },
|
|
18
24
|
{ id: "skill-md/body-has-headers", defaultSeverity: "info" },
|
|
@@ -46,7 +52,13 @@ function hasTriggerSignal(desc) {
|
|
|
46
52
|
// ("Trigger on …", "Trigger when/whenever/if …", "Triggers: …", "TRIGGER
|
|
47
53
|
// when:") — not when "trigger" merely appears as a noun (e.g. the phrase
|
|
48
54
|
// "no trigger phrases").
|
|
49
|
-
|
|
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;
|
|
50
62
|
if (triggerPhrases.test(d))
|
|
51
63
|
return true;
|
|
52
64
|
// First "sentence-ish" chunk — used to detect imperative / gerund openers.
|
|
@@ -151,6 +163,41 @@ export const skillMdLinter = {
|
|
|
151
163
|
"(e.g., \"Use when…\", \"Trigger on…\", \"when the user asks to…\", or an imperative verb)"));
|
|
152
164
|
}
|
|
153
165
|
}
|
|
166
|
+
// model — Zod types it as a permissive scalar; accept a named alias or a
|
|
167
|
+
// versioned claude-* model id. Mirrors agent-md/model-valid.
|
|
168
|
+
if ("model" in fm.data && typeof fm.data.model === "string") {
|
|
169
|
+
const model = fm.data.model;
|
|
170
|
+
if (!AGENT_MODELS.has(model) && !model.startsWith("claude-")) {
|
|
171
|
+
push(diag(config, filePath, "skill-md/model-valid", "warning", `"model" must be one of: ${[...AGENT_MODELS].join(", ")} or a versioned claude-* model ID (got "${model}")`));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// effort — Zod types it as a permissive scalar; the field's describe()
|
|
175
|
+
// string restricts it to a named level or an integer.
|
|
176
|
+
if ("effort" in fm.data) {
|
|
177
|
+
const reason = invalidEffortReason(fm.data.effort);
|
|
178
|
+
if (reason) {
|
|
179
|
+
push(diag(config, filePath, "skill-md/effort-valid", "warning", reason));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// allowed-tools — validate built-in tool names. mcp__* patterns are
|
|
183
|
+
// dynamic (resolved at runtime); accept them. Mirrors command-md.
|
|
184
|
+
if ("allowed-tools" in fm.data) {
|
|
185
|
+
const tools = fm.data["allowed-tools"];
|
|
186
|
+
if (Array.isArray(tools)) {
|
|
187
|
+
for (const t of tools) {
|
|
188
|
+
if (typeof t === "string" && !t.startsWith("mcp__") && !TOOLS.has(t)) {
|
|
189
|
+
push(diag(config, filePath, "skill-md/allowed-tools-valid", "warning", `Unknown tool "${t}" in allowed-tools`));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// boolean fields — the Zod schema accepts the string "true"; only a real
|
|
195
|
+
// boolean behaves as expected.
|
|
196
|
+
for (const field of ["disable-model-invocation", "user-invocable"]) {
|
|
197
|
+
if (field in fm.data && typeof fm.data[field] !== "boolean") {
|
|
198
|
+
push(diag(config, filePath, "skill-md/frontmatter-field-type", "warning", `"${field}" must be a boolean (got ${JSON.stringify(fm.data[field])})`));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
154
201
|
// Frontmatter keys: only flag cross-artifact misplacement. A key that is
|
|
155
202
|
// valid for a *different* markdown artifact (e.g. agent's "effort" on a
|
|
156
203
|
// skill) gets an info; a key valid for no artifact stays silent.
|
package/dist/plugin-schema.js
CHANGED
|
@@ -8,11 +8,10 @@
|
|
|
8
8
|
* resolver if the file is being run from an alternate layout (e.g. monorepo).
|
|
9
9
|
*/
|
|
10
10
|
import { readFileSync } from "node:fs";
|
|
11
|
-
import { dirname, resolve } from "node:path";
|
|
12
|
-
import { fileURLToPath } from "node:url";
|
|
13
11
|
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
14
12
|
// biome-ignore lint/style/useImportType: runtime import; types only.
|
|
15
13
|
import * as addFormatsNs from "ajv-formats";
|
|
14
|
+
import { assetCandidates } from "./utils/asset-path.js";
|
|
16
15
|
// ajv-formats ships as CJS with a default function export. Under Node16
|
|
17
16
|
// ESM resolution we have to reach in for `.default`.
|
|
18
17
|
const addFormats = addFormatsNs.default;
|
|
@@ -26,36 +25,87 @@ function getAjv() {
|
|
|
26
25
|
addFormats(ajvShared);
|
|
27
26
|
return ajvShared;
|
|
28
27
|
}
|
|
29
|
-
function
|
|
30
|
-
if (compiledCache.has(fileName))
|
|
31
|
-
return compiledCache.get(fileName) ?? null;
|
|
32
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
function tryReadFile(relPath) {
|
|
33
29
|
const candidates = [
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
...assetCandidates(import.meta.url, ["..", ...relPath]),
|
|
31
|
+
...assetCandidates(import.meta.url, ["..", "..", ...relPath]),
|
|
36
32
|
];
|
|
37
|
-
let raw = null;
|
|
38
33
|
for (const p of candidates) {
|
|
39
34
|
try {
|
|
40
|
-
|
|
41
|
-
break;
|
|
35
|
+
return readFileSync(p, "utf8");
|
|
42
36
|
}
|
|
43
37
|
catch {
|
|
44
38
|
// try next
|
|
45
39
|
}
|
|
46
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]);
|
|
47
81
|
if (!raw) {
|
|
48
82
|
compiledCache.set(fileName, null);
|
|
49
83
|
return null;
|
|
50
84
|
}
|
|
51
|
-
const
|
|
52
|
-
const compiled = {
|
|
53
|
-
validate: getAjv().compile(wrapped.schema),
|
|
54
|
-
extractedFromVersion: wrapped.extractedFromClaudeCodeVersion,
|
|
55
|
-
};
|
|
85
|
+
const compiled = compileSchemaDoc(raw, fileName);
|
|
56
86
|
compiledCache.set(fileName, compiled);
|
|
57
87
|
return compiled;
|
|
58
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
|
+
}
|
|
59
109
|
export function loadLspSchema() {
|
|
60
110
|
return loadCompiledSchema("lsp.schema.json");
|
|
61
111
|
}
|
|
@@ -63,8 +113,30 @@ export function loadMonitorsSchema() {
|
|
|
63
113
|
return loadCompiledSchema("monitors.schema.json");
|
|
64
114
|
}
|
|
65
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).
|
|
66
120
|
return loadCompiledSchema("settings.schema.json");
|
|
67
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
|
+
}
|
|
134
|
+
export function loadMcpJsonSchema() {
|
|
135
|
+
return loadCompiledSchema("mcp.schema.json");
|
|
136
|
+
}
|
|
137
|
+
export function loadHooksJsonSchema() {
|
|
138
|
+
return loadCompiledSchema("hooks.schema.json");
|
|
139
|
+
}
|
|
68
140
|
export function loadSkillFrontmatterSchema() {
|
|
69
141
|
return loadCompiledSchema("skill-frontmatter.schema.json");
|
|
70
142
|
}
|
|
@@ -77,32 +149,17 @@ export function loadCommandFrontmatterSchema() {
|
|
|
77
149
|
export function loadPluginSchema() {
|
|
78
150
|
if (cached)
|
|
79
151
|
return cached;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
];
|
|
85
|
-
let raw = null;
|
|
86
|
-
for (const p of candidates) {
|
|
87
|
-
try {
|
|
88
|
-
raw = readFileSync(p, "utf8");
|
|
89
|
-
break;
|
|
90
|
-
}
|
|
91
|
-
catch {
|
|
92
|
-
// try next
|
|
93
|
-
}
|
|
94
|
-
}
|
|
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"]);
|
|
95
156
|
if (!raw)
|
|
96
157
|
return null;
|
|
97
|
-
const
|
|
98
|
-
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
99
|
-
addFormats(ajv);
|
|
100
|
-
const validate = ajv.compile(wrapped.schema);
|
|
101
|
-
const knownFields = new Set(Object.keys(wrapped.schema.properties ?? {}));
|
|
158
|
+
const compiled = compileSchemaDoc(raw, "plugin.schema.json");
|
|
102
159
|
cached = {
|
|
103
|
-
validate,
|
|
104
|
-
extractedFromVersion:
|
|
105
|
-
knownFields,
|
|
160
|
+
validate: compiled.validate,
|
|
161
|
+
extractedFromVersion: compiled.extractedFromVersion,
|
|
162
|
+
knownFields: compiled.knownFields,
|
|
106
163
|
};
|
|
107
164
|
return cached;
|
|
108
165
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve runtime assets (contracts/*.schema.json, .claudecode-lint.defaults.yaml,
|
|
3
|
+
* package.json) that ship alongside the package on disk.
|
|
4
|
+
*
|
|
5
|
+
* Normally these are found relative to `import.meta.url` — the location of the
|
|
6
|
+
* compiled `.js` file inside `dist/`. That works for the Node build and for the
|
|
7
|
+
* published npm package.
|
|
8
|
+
*
|
|
9
|
+
* Inside a `bun build --compile` single-executable, `import.meta.url` points
|
|
10
|
+
* into Bun's virtual embedded filesystem (`/$bunfs/...`), so disk reads of
|
|
11
|
+
* sibling assets fail. To support that variant, we ALSO emit candidates
|
|
12
|
+
* relative to `process.execPath` (the real on-disk path of the running
|
|
13
|
+
* executable). The compiled-binary image ships `contracts/` and
|
|
14
|
+
* `.claudecode-lint.defaults.yaml` next to the executable, so those fallback
|
|
15
|
+
* candidates resolve there.
|
|
16
|
+
*
|
|
17
|
+
* For the Node runtime, the `process.execPath`-relative candidates simply point
|
|
18
|
+
* at the `node` binary's directory and won't match — harmless extra lookups
|
|
19
|
+
* appended AFTER the existing ones, so Node resolution is byte-for-byte
|
|
20
|
+
* unchanged.
|
|
21
|
+
*/
|
|
22
|
+
import { dirname, resolve } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
/**
|
|
25
|
+
* Build a list of candidate paths for an asset shipped with the package.
|
|
26
|
+
*
|
|
27
|
+
* @param importMetaUrl `import.meta.url` of the calling module.
|
|
28
|
+
* @param segments Path segments, relative to a base directory, that
|
|
29
|
+
* locate the asset (e.g. `["..", "contracts", "x.json"]`
|
|
30
|
+
* for a module under `dist/`).
|
|
31
|
+
* @returns Ordered candidate paths: `import.meta.url`-relative first (existing
|
|
32
|
+
* behavior), then `process.execPath`-relative fallbacks.
|
|
33
|
+
*/
|
|
34
|
+
export function assetCandidates(importMetaUrl, segments) {
|
|
35
|
+
const candidates = [];
|
|
36
|
+
// 1. import.meta.url-relative — the existing, primary resolution.
|
|
37
|
+
const here = dirname(fileURLToPath(importMetaUrl));
|
|
38
|
+
candidates.push(resolve(here, ...segments));
|
|
39
|
+
// 2. process.execPath-relative fallbacks for the compiled single-executable.
|
|
40
|
+
// The binary lives next to `contracts/` and the defaults YAML, so we try
|
|
41
|
+
// both the executable's own directory and one level up (mirroring the
|
|
42
|
+
// dist/ -> package-root step the segments encode).
|
|
43
|
+
try {
|
|
44
|
+
const execDir = dirname(process.execPath);
|
|
45
|
+
// Drop leading ".." segments: assets sit directly beside the executable.
|
|
46
|
+
const beside = segments.filter((s) => s !== "..");
|
|
47
|
+
candidates.push(resolve(execDir, ...beside));
|
|
48
|
+
candidates.push(resolve(execDir, ...segments));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// process.execPath unavailable — ignore.
|
|
52
|
+
}
|
|
53
|
+
// De-duplicate while preserving order.
|
|
54
|
+
return [...new Set(candidates)];
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=asset-path.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { EFFORT_LEVELS } from "../contracts.js";
|
|
2
|
+
/**
|
|
3
|
+
* Validate a frontmatter `effort` value against the Claude Code contract.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code's frontmatter Zod schema types `effort` as a permissive scalar
|
|
6
|
+
* (`anyOf[string,number,boolean,null]`), but the field's own `.describe()`
|
|
7
|
+
* string in the bundle reads:
|
|
8
|
+
* "Thinking effort for the model: `low`, `medium`, `high`, `max`, or an integer."
|
|
9
|
+
*
|
|
10
|
+
* So a valid `effort` is either one of the named levels (EFFORT_LEVELS) or an
|
|
11
|
+
* integer. Anything else — a non-integer number, a boolean, an unknown string,
|
|
12
|
+
* null — is reported. Returns a human-readable reason when invalid, or null
|
|
13
|
+
* when the value is acceptable.
|
|
14
|
+
*/
|
|
15
|
+
export function invalidEffortReason(value) {
|
|
16
|
+
const valid = [...EFFORT_LEVELS].join(", ");
|
|
17
|
+
if (typeof value === "string") {
|
|
18
|
+
if (EFFORT_LEVELS.has(value))
|
|
19
|
+
return null;
|
|
20
|
+
return `"effort" string must be one of: ${valid} (got "${value}")`;
|
|
21
|
+
}
|
|
22
|
+
if (typeof value === "number") {
|
|
23
|
+
if (Number.isInteger(value))
|
|
24
|
+
return null;
|
|
25
|
+
return `"effort" number must be an integer (got ${value})`;
|
|
26
|
+
}
|
|
27
|
+
return `"effort" must be one of: ${valid}, or an integer (got ${JSON.stringify(value)})`;
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=effort.js.map
|
|
@@ -1,9 +1,38 @@
|
|
|
1
1
|
import { SKILL_FRONTMATTER, AGENT_FRONTMATTER, COMMAND_FRONTMATTER, } from "../contracts.js";
|
|
2
|
-
|
|
2
|
+
import { loadSkillFrontmatterSchema, loadAgentFrontmatterSchema, loadCommandFrontmatterSchema, } from "../plugin-schema.js";
|
|
3
|
+
// Census-extraction sets (scripts/extract-contracts.ts). Used only as a
|
|
4
|
+
// fallback when the auto-extracted frontmatter schema is unavailable — the
|
|
5
|
+
// census extractor lags the schema walker (e.g. it was missing `effort`).
|
|
6
|
+
const CENSUS_FALLBACK = {
|
|
3
7
|
skill: SKILL_FRONTMATTER,
|
|
4
8
|
agent: AGENT_FRONTMATTER,
|
|
5
9
|
command: COMMAND_FRONTMATTER,
|
|
6
10
|
};
|
|
11
|
+
const SCHEMA_LOADERS = {
|
|
12
|
+
skill: loadSkillFrontmatterSchema,
|
|
13
|
+
agent: loadAgentFrontmatterSchema,
|
|
14
|
+
command: loadCommandFrontmatterSchema,
|
|
15
|
+
};
|
|
16
|
+
const keyCache = new Map();
|
|
17
|
+
/**
|
|
18
|
+
* Known frontmatter keys for an artifact — the union of the auto-extracted
|
|
19
|
+
* schema's property names (authoritative, complete, kept in sync with Claude
|
|
20
|
+
* Code's Zod) and the census-contract set. Union rather than schema-only so a
|
|
21
|
+
* key known to *either* extraction is never mis-flagged: `no-unknown-
|
|
22
|
+
* frontmatter` is advisory and a false positive is the only real harm.
|
|
23
|
+
*/
|
|
24
|
+
function knownKeys(kind) {
|
|
25
|
+
const cached = keyCache.get(kind);
|
|
26
|
+
if (cached)
|
|
27
|
+
return cached;
|
|
28
|
+
const merged = new Set(CENSUS_FALLBACK[kind]);
|
|
29
|
+
const schema = SCHEMA_LOADERS[kind]();
|
|
30
|
+
if (schema)
|
|
31
|
+
for (const k of schema.knownFields)
|
|
32
|
+
merged.add(k);
|
|
33
|
+
keyCache.set(kind, merged);
|
|
34
|
+
return merged;
|
|
35
|
+
}
|
|
7
36
|
const ARTIFACT_LABEL = {
|
|
8
37
|
skill: "skill",
|
|
9
38
|
agent: "agent",
|
|
@@ -18,13 +47,13 @@ const ARTIFACT_LABEL = {
|
|
|
18
47
|
*/
|
|
19
48
|
export function classifyUnknownFrontmatterKey(key, self, extraKnown = new Set()) {
|
|
20
49
|
// Known for the current artifact — not unknown at all.
|
|
21
|
-
if (
|
|
50
|
+
if (knownKeys(self).has(key) || extraKnown.has(key))
|
|
22
51
|
return null;
|
|
23
52
|
// Valid for some *other* markdown artifact → misplacement.
|
|
24
53
|
for (const kind of ["skill", "agent", "command"]) {
|
|
25
54
|
if (kind === self)
|
|
26
55
|
continue;
|
|
27
|
-
if (
|
|
56
|
+
if (knownKeys(kind).has(key)) {
|
|
28
57
|
return { kind: "owned-by-other", owner: kind };
|
|
29
58
|
}
|
|
30
59
|
}
|
|
@@ -32,7 +32,7 @@ export function parseFrontmatter(content) {
|
|
|
32
32
|
const body = lines.slice(closingIndex + 1).join("\n");
|
|
33
33
|
const bodyStartLine = closingIndex + 2; // 1-based
|
|
34
34
|
try {
|
|
35
|
-
const data = parseYaml(frontmatterRaw);
|
|
35
|
+
const data = parseYaml(frontmatterRaw, { maxAliasCount: 100 });
|
|
36
36
|
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
37
37
|
return {
|
|
38
38
|
data: {},
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
2
|
+
import { sep } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Decide whether writing to `filePath` (a fix/format target) is safe.
|
|
5
|
+
*
|
|
6
|
+
* The linter may write fixes back to artifact paths supplied by a plugin.
|
|
7
|
+
* A malicious plugin can ship an artifact path that is actually a symlink
|
|
8
|
+
* pointing outside the target tree (`~/.bashrc`, an SSH key, a CI secret);
|
|
9
|
+
* a bare `writeFileSync` would then clobber the symlink's target.
|
|
10
|
+
*
|
|
11
|
+
* Returns a human-readable reason to REFUSE the write, or `null` if the
|
|
12
|
+
* write is safe. The check fails closed: if anything throws (path missing,
|
|
13
|
+
* permission error, etc.) a refusal reason is returned.
|
|
14
|
+
*
|
|
15
|
+
* Refuses when:
|
|
16
|
+
* - `filePath` itself is a symbolic link, or
|
|
17
|
+
* - the real path of `filePath` is not `rootDir` itself and not located
|
|
18
|
+
* under `rootDir + path.sep`.
|
|
19
|
+
*/
|
|
20
|
+
export function writeBlockedReason(filePath, rootDir) {
|
|
21
|
+
try {
|
|
22
|
+
if (lstatSync(filePath).isSymbolicLink()) {
|
|
23
|
+
return "path is a symlink";
|
|
24
|
+
}
|
|
25
|
+
const realRoot = realpathSync(rootDir);
|
|
26
|
+
const realPath = realpathSync(filePath);
|
|
27
|
+
if (realPath !== realRoot && !realPath.startsWith(realRoot + sep)) {
|
|
28
|
+
return "path resolves outside the target directory";
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return "path could not be safely resolved";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=safe-write.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip C0 control characters and DEL from a string before it is written to
|
|
3
|
+
* a terminal, while preserving tab and newline.
|
|
4
|
+
*
|
|
5
|
+
* Diagnostic messages, file paths and `--fix-dry-run` diffs embed untrusted
|
|
6
|
+
* strings (rule content, field values, file content, plugin-controlled file
|
|
7
|
+
* and directory names). Without sanitization an attacker-supplied artifact
|
|
8
|
+
* could smuggle ANSI/control sequences into the user's terminal.
|
|
9
|
+
*
|
|
10
|
+
* Strips U+0000-U+0008, U+000B-U+001F and U+007F (DEL) - every C0 control
|
|
11
|
+
* char and DEL except U+0009 (tab) and U+000A (newline), which are kept.
|
|
12
|
+
* The stripped set includes U+000D (CR) and U+001B (ESC) by design.
|
|
13
|
+
*/
|
|
14
|
+
export function sanitizeForTerminal(s) {
|
|
15
|
+
// eslint-disable-next-line no-control-regex
|
|
16
|
+
return s.replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, "");
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=terminal.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudecode-linter",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.150-patch.1",
|
|
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": [
|
|
@@ -30,6 +31,9 @@
|
|
|
30
31
|
"contracts/skill-frontmatter.schema.json",
|
|
31
32
|
"contracts/agent-frontmatter.schema.json",
|
|
32
33
|
"contracts/command-frontmatter.schema.json",
|
|
34
|
+
"contracts/mcp.schema.json",
|
|
35
|
+
"contracts/hooks.schema.json",
|
|
36
|
+
"contracts/schemastore/*.json",
|
|
33
37
|
".claudecode-lint.defaults.yaml",
|
|
34
38
|
"README.md"
|
|
35
39
|
],
|