vigiles 2.0.0
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/.claude/settings.json +46 -0
- package/.claude/settings.local.json +8 -0
- package/.claude-plugin/hooks/post-edit.sh +34 -0
- package/.claude-plugin/hooks/pre-edit.sh +40 -0
- package/.claude-plugin/hooks/session-start.sh +38 -0
- package/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +47 -0
- package/.github/workflows/ci.yml +81 -0
- package/.prettierignore +1 -0
- package/.vigiles/generated.d.ts +205 -0
- package/CLAUDE.md +95 -0
- package/CLAUDE.md.spec.ts +142 -0
- package/CONTRIBUTING.md +121 -0
- package/LICENSE +21 -0
- package/README.md +377 -0
- package/action.yml +25 -0
- package/dist/action.d.ts +7 -0
- package/dist/action.d.ts.map +1 -0
- package/dist/action.js +180 -0
- package/dist/action.js.map +1 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1267 -0
- package/dist/cli.js.map +1 -0
- package/dist/cli.test.d.ts +2 -0
- package/dist/cli.test.d.ts.map +1 -0
- package/dist/cli.test.js +650 -0
- package/dist/cli.test.js.map +1 -0
- package/dist/compile.d.ts +101 -0
- package/dist/compile.d.ts.map +1 -0
- package/dist/compile.js +503 -0
- package/dist/compile.js.map +1 -0
- package/dist/evolve.d.ts +132 -0
- package/dist/evolve.d.ts.map +1 -0
- package/dist/evolve.js +599 -0
- package/dist/evolve.js.map +1 -0
- package/dist/freshness.d.ts +67 -0
- package/dist/freshness.d.ts.map +1 -0
- package/dist/freshness.js +244 -0
- package/dist/freshness.js.map +1 -0
- package/dist/freshness.test.d.ts +2 -0
- package/dist/freshness.test.d.ts.map +1 -0
- package/dist/freshness.test.js +356 -0
- package/dist/freshness.test.js.map +1 -0
- package/dist/generate-types.d.ts +34 -0
- package/dist/generate-types.d.ts.map +1 -0
- package/dist/generate-types.js +381 -0
- package/dist/generate-types.js.map +1 -0
- package/dist/inline.d.ts +58 -0
- package/dist/inline.d.ts.map +1 -0
- package/dist/inline.js +142 -0
- package/dist/inline.js.map +1 -0
- package/dist/inline.test.d.ts +5 -0
- package/dist/inline.test.d.ts.map +1 -0
- package/dist/inline.test.js +152 -0
- package/dist/inline.test.js.map +1 -0
- package/dist/linters.d.ts +38 -0
- package/dist/linters.d.ts.map +1 -0
- package/dist/linters.js +588 -0
- package/dist/linters.js.map +1 -0
- package/dist/proofs.d.ts +272 -0
- package/dist/proofs.d.ts.map +1 -0
- package/dist/proofs.js +622 -0
- package/dist/proofs.js.map +1 -0
- package/dist/proofs.test.d.ts +9 -0
- package/dist/proofs.test.d.ts.map +1 -0
- package/dist/proofs.test.js +952 -0
- package/dist/proofs.test.js.map +1 -0
- package/dist/spec.d.ts +258 -0
- package/dist/spec.d.ts.map +1 -0
- package/dist/spec.js +113 -0
- package/dist/spec.js.map +1 -0
- package/dist/spec.test.d.ts +2 -0
- package/dist/spec.test.d.ts.map +1 -0
- package/dist/spec.test.js +1222 -0
- package/dist/spec.test.js.map +1 -0
- package/dist/types.d.ts +101 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/validate.d.ts +10 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +286 -0
- package/dist/validate.js.map +1 -0
- package/dist/validate.test.d.ts +2 -0
- package/dist/validate.test.d.ts.map +1 -0
- package/dist/validate.test.js +531 -0
- package/dist/validate.test.js.map +1 -0
- package/docs/agent-setup.md +85 -0
- package/docs/agent-workflows.md +103 -0
- package/docs/comparison.md +71 -0
- package/docs/freshness.md +124 -0
- package/docs/inline-mode.md +119 -0
- package/docs/linter-support.md +166 -0
- package/docs/spec-format.md +194 -0
- package/eslint.config.mjs +79 -0
- package/examples/CLAUDE.md +54 -0
- package/examples/CLAUDE.md.spec.ts +65 -0
- package/examples/SKILL.md +50 -0
- package/examples/SKILL.md.spec.ts +57 -0
- package/fixtures/example-project/CLAUDE.md +11 -0
- package/fixtures/example-project/package.json +9 -0
- package/fixtures/example-project/src/index.ts +3 -0
- package/fixtures/example-project/src/utils.test.ts +2 -0
- package/fixtures/example-project/src/utils.ts +3 -0
- package/logo.png +0 -0
- package/package.json +42 -0
- package/research/adoption-strategy.md +111 -0
- package/research/agent-integration.md +145 -0
- package/research/ai-code-quality.md +197 -0
- package/research/code-search-for-agents.md +313 -0
- package/research/competitive-landscape.md +163 -0
- package/research/doc-freshness.md +516 -0
- package/research/executable-specs.md +368 -0
- package/research/feature-ideas.md +464 -0
- package/research/formal-proofs-for-agents.md +338 -0
- package/research/fp-for-agent-harness.md +150 -0
- package/research/fp-for-deterministic-ai.md +131 -0
- package/research/self-evolving-specs.md +298 -0
- package/schemas/claude-md-strict.yml +18 -0
- package/schemas/claude-md.yml +6 -0
- package/schemas/skill-strict.yml +12 -0
- package/schemas/skill.yml +5 -0
- package/skills/audit-feedback-loop/SKILL.md +76 -0
- package/skills/edit-spec/SKILL.md +131 -0
- package/skills/enforce-rules-format/SKILL.md +71 -0
- package/skills/generate-logo/SKILL.md +102 -0
- package/skills/generate-rule/SKILL.md +90 -0
- package/skills/linter-docs/clippy.md +241 -0
- package/skills/linter-docs/eslint.md +384 -0
- package/skills/linter-docs/pylint.md +288 -0
- package/skills/linter-docs/rubocop.md +277 -0
- package/skills/linter-docs/ruff.md +187 -0
- package/skills/linter-docs/stylelint.md +247 -0
- package/skills/migrate-to-spec/SKILL.md +124 -0
- package/skills/pr-to-lint-rule/SKILL.md +97 -0
- package/skills/strengthen/SKILL.md +168 -0
- package/src/action.ts +214 -0
- package/src/cli.test.ts +914 -0
- package/src/cli.ts +1631 -0
- package/src/compile.ts +691 -0
- package/src/evolve.ts +781 -0
- package/src/freshness.test.ts +449 -0
- package/src/freshness.ts +299 -0
- package/src/generate-types.ts +448 -0
- package/src/inline.test.ts +206 -0
- package/src/inline.ts +164 -0
- package/src/linters.ts +739 -0
- package/src/proofs.test.ts +1314 -0
- package/src/proofs.ts +849 -0
- package/src/spec.test.ts +1471 -0
- package/src/spec.ts +427 -0
- package/src/types.ts +117 -0
- package/src/validate.test.ts +701 -0
- package/src/validate.ts +381 -0
- package/tsconfig.json +23 -0
package/src/linters.ts
ADDED
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linter cross-referencing engine.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that linter rule references (e.g., "eslint/no-console") point to
|
|
5
|
+
* real rules that exist and are enabled in project config. Supports:
|
|
6
|
+
* ESLint, Stylelint (Node API), Ruff, Clippy, Pylint, RuboCop (CLI).
|
|
7
|
+
*
|
|
8
|
+
* This is the core moat — no other tool resolves rules against 6 linter APIs
|
|
9
|
+
* and checks config-enabled status.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
13
|
+
import { resolve } from "node:path";
|
|
14
|
+
import { execSync } from "node:child_process";
|
|
15
|
+
import { createRequire } from "node:module";
|
|
16
|
+
import { globSync } from "glob";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Types
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export type ConfigEnabledStatus = "enabled" | "disabled" | "unknown";
|
|
23
|
+
|
|
24
|
+
export interface LinterCheckResult {
|
|
25
|
+
exists: boolean;
|
|
26
|
+
enabled: ConfigEnabledStatus;
|
|
27
|
+
linter: string;
|
|
28
|
+
rule: string;
|
|
29
|
+
error?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DetectedLinter {
|
|
33
|
+
name: string;
|
|
34
|
+
ruleCount?: number;
|
|
35
|
+
via?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Extended Set with eslint metadata. */
|
|
39
|
+
interface EslintRuleSet extends Set<string> {
|
|
40
|
+
_basePath?: string;
|
|
41
|
+
_isEslint?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Parsing enforcement references
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/** @internal */ export function extractLinterName(enforcedBy: string): string {
|
|
49
|
+
const colonIdx = enforcedBy.indexOf("::");
|
|
50
|
+
const slashIdx = enforcedBy.indexOf("/");
|
|
51
|
+
if (colonIdx === -1 && slashIdx === -1) return enforcedBy;
|
|
52
|
+
if (colonIdx === -1) return enforcedBy.substring(0, slashIdx);
|
|
53
|
+
if (slashIdx === -1) return enforcedBy.substring(0, colonIdx);
|
|
54
|
+
return enforcedBy.substring(0, Math.min(slashIdx, colonIdx));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @internal */ export function extractRuleName(
|
|
58
|
+
enforcedBy: string,
|
|
59
|
+
): string | null {
|
|
60
|
+
const colonIdx = enforcedBy.indexOf("::");
|
|
61
|
+
const slashIdx = enforcedBy.indexOf("/");
|
|
62
|
+
if (colonIdx === -1 && slashIdx === -1) return null;
|
|
63
|
+
if (colonIdx === -1) return enforcedBy.substring(slashIdx + 1);
|
|
64
|
+
if (slashIdx === -1) return enforcedBy.substring(colonIdx + 2);
|
|
65
|
+
const idx = Math.min(slashIdx, colonIdx);
|
|
66
|
+
const sep = idx === colonIdx ? 2 : 1;
|
|
67
|
+
return enforcedBy.substring(idx + sep);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const SAFE_RULE_NAME_RE = /^[a-zA-Z0-9_\-/.:#]+$/;
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// ESLint plugin resolution
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
function eslintPluginPkgNames(pluginName: string): string[] {
|
|
77
|
+
if (pluginName.startsWith("@")) {
|
|
78
|
+
const parts = pluginName.split("/");
|
|
79
|
+
if (parts.length === 1) return [`${parts[0]}/eslint-plugin`];
|
|
80
|
+
return [
|
|
81
|
+
`${parts[0]}/eslint-plugin-${parts[1]}`,
|
|
82
|
+
`${parts[0]}/eslint-plugin`,
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
return [`eslint-plugin-${pluginName}`];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function tryResolvePlugin(
|
|
89
|
+
req: NodeJS.Require,
|
|
90
|
+
pkg: string,
|
|
91
|
+
): Set<string> | null {
|
|
92
|
+
try {
|
|
93
|
+
const plugin = req(pkg) as {
|
|
94
|
+
rules?: Record<string, unknown>;
|
|
95
|
+
default?: { rules?: Record<string, unknown> };
|
|
96
|
+
};
|
|
97
|
+
const rules = plugin.rules ?? plugin.default?.rules;
|
|
98
|
+
if (rules) return new Set(Object.keys(rules));
|
|
99
|
+
return null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resolveEslintPluginRules(
|
|
106
|
+
pluginName: string,
|
|
107
|
+
basePath: string,
|
|
108
|
+
): Set<string> | null {
|
|
109
|
+
try {
|
|
110
|
+
const req = createRequire(resolve(basePath, "package.json"));
|
|
111
|
+
const pkgNames = eslintPluginPkgNames(pluginName);
|
|
112
|
+
for (const pkg of pkgNames) {
|
|
113
|
+
const result = tryResolvePlugin(req, pkg);
|
|
114
|
+
if (result) return result;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Built-in resolvers (Node API)
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
const LINTER_RESOLVERS: Record<
|
|
127
|
+
string,
|
|
128
|
+
(basePath: string) => EslintRuleSet | Set<string>
|
|
129
|
+
> = {
|
|
130
|
+
eslint(basePath: string): EslintRuleSet {
|
|
131
|
+
const req = createRequire(resolve(basePath, "package.json"));
|
|
132
|
+
const { builtinRules } = req("eslint/use-at-your-own-risk") as {
|
|
133
|
+
builtinRules: Map<string, unknown>;
|
|
134
|
+
};
|
|
135
|
+
const rules: EslintRuleSet = new Set(builtinRules.keys());
|
|
136
|
+
rules._basePath = basePath;
|
|
137
|
+
rules._isEslint = true;
|
|
138
|
+
return rules;
|
|
139
|
+
},
|
|
140
|
+
stylelint(basePath: string): Set<string> {
|
|
141
|
+
const req = createRequire(resolve(basePath, "package.json"));
|
|
142
|
+
const mod = req("stylelint") as { rules: Record<string, unknown> };
|
|
143
|
+
return new Set(Object.keys(mod.rules));
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// CLI-based per-rule checks
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
const CLI_RULE_CHECKS: Record<string, (ruleName: string) => void> = {
|
|
152
|
+
ruff(ruleName: string): void {
|
|
153
|
+
execSync(`ruff rule ${ruleName}`, { stdio: "ignore" });
|
|
154
|
+
},
|
|
155
|
+
clippy(ruleName: string): void {
|
|
156
|
+
execSync(`cargo clippy --explain ${ruleName}`, { stdio: "ignore" });
|
|
157
|
+
},
|
|
158
|
+
pylint(ruleName: string): void {
|
|
159
|
+
const output = execSync(`pylint --help-msg=${ruleName}`, {
|
|
160
|
+
encoding: "utf-8",
|
|
161
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
162
|
+
});
|
|
163
|
+
if (output.includes("No such message id")) {
|
|
164
|
+
throw new Error(`Unknown pylint message: ${ruleName}`);
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
rubocop(ruleName: string): void {
|
|
168
|
+
const output = execSync(`rubocop --show-cops ${ruleName}`, {
|
|
169
|
+
encoding: "utf-8",
|
|
170
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
171
|
+
});
|
|
172
|
+
if (!output || output.trim().length === 0) {
|
|
173
|
+
throw new Error(`Unknown cop: ${ruleName}`);
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Config-enabled checkers
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
type ConfigLoader = (ruleName: string) => ConfigEnabledStatus;
|
|
183
|
+
|
|
184
|
+
function createCachedChecker(
|
|
185
|
+
loadConfigFn: (basePath: string) => ConfigLoader | null,
|
|
186
|
+
): (ruleName: string, basePath: string) => ConfigEnabledStatus {
|
|
187
|
+
const cache = new Map<string, ConfigLoader | null>();
|
|
188
|
+
return (ruleName: string, basePath: string): ConfigEnabledStatus => {
|
|
189
|
+
if (!cache.has(basePath)) {
|
|
190
|
+
try {
|
|
191
|
+
cache.set(basePath, loadConfigFn(basePath));
|
|
192
|
+
} catch {
|
|
193
|
+
cache.set(basePath, null);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const config = cache.get(basePath);
|
|
197
|
+
if (!config) return "unknown";
|
|
198
|
+
return config(ruleName);
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const LINTER_CONFIG_CHECKERS: Record<
|
|
203
|
+
string,
|
|
204
|
+
(ruleName: string, basePath: string) => ConfigEnabledStatus
|
|
205
|
+
> = {
|
|
206
|
+
eslint: createCachedChecker((basePath: string): ConfigLoader | null => {
|
|
207
|
+
try {
|
|
208
|
+
const script = `
|
|
209
|
+
const { loadESLint } = require("eslint");
|
|
210
|
+
(async () => {
|
|
211
|
+
try {
|
|
212
|
+
const ESLint = await loadESLint();
|
|
213
|
+
const eslint = new ESLint({ cwd: ${JSON.stringify(basePath)} });
|
|
214
|
+
const config = await eslint.calculateConfigForFile("dummy.js");
|
|
215
|
+
console.log(JSON.stringify(config.rules || {}));
|
|
216
|
+
} catch(e) {
|
|
217
|
+
console.log("{}");
|
|
218
|
+
}
|
|
219
|
+
})();
|
|
220
|
+
`;
|
|
221
|
+
const output = execSync(`node -e '${script.replace(/'/g, "'\\''")}'`, {
|
|
222
|
+
encoding: "utf-8",
|
|
223
|
+
cwd: basePath,
|
|
224
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
225
|
+
timeout: 15000,
|
|
226
|
+
});
|
|
227
|
+
const rules = JSON.parse(output.trim() || "{}") as Record<
|
|
228
|
+
string,
|
|
229
|
+
unknown
|
|
230
|
+
>;
|
|
231
|
+
return (ruleName: string): ConfigEnabledStatus => {
|
|
232
|
+
if (!(ruleName in rules)) return "unknown";
|
|
233
|
+
const setting: unknown = rules[ruleName];
|
|
234
|
+
const severity: unknown = Array.isArray(setting) ? setting[0] : setting;
|
|
235
|
+
if (severity === 0 || severity === "off") return "disabled";
|
|
236
|
+
return "enabled";
|
|
237
|
+
};
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}),
|
|
242
|
+
|
|
243
|
+
stylelint: createCachedChecker((basePath: string): ConfigLoader | null => {
|
|
244
|
+
try {
|
|
245
|
+
const script = `
|
|
246
|
+
const stylelint = require("stylelint");
|
|
247
|
+
(async () => {
|
|
248
|
+
try {
|
|
249
|
+
const linter = stylelint.createLinter({});
|
|
250
|
+
const result = await linter.getConfigForFile(${JSON.stringify(resolve(basePath, "dummy.css"))});
|
|
251
|
+
console.log(JSON.stringify(result.config.rules || {}));
|
|
252
|
+
} catch(e) {
|
|
253
|
+
console.log("{}");
|
|
254
|
+
}
|
|
255
|
+
})();
|
|
256
|
+
`;
|
|
257
|
+
const output = execSync(`node -e '${script.replace(/'/g, "'\\''")}'`, {
|
|
258
|
+
encoding: "utf-8",
|
|
259
|
+
cwd: basePath,
|
|
260
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
261
|
+
timeout: 15000,
|
|
262
|
+
});
|
|
263
|
+
const rules = JSON.parse(output.trim() || "{}") as Record<
|
|
264
|
+
string,
|
|
265
|
+
unknown
|
|
266
|
+
>;
|
|
267
|
+
return (ruleName: string): ConfigEnabledStatus => {
|
|
268
|
+
if (!(ruleName in rules)) return "unknown";
|
|
269
|
+
const setting = rules[ruleName];
|
|
270
|
+
if (setting === null || (Array.isArray(setting) && setting[0] === null))
|
|
271
|
+
return "disabled";
|
|
272
|
+
return "enabled";
|
|
273
|
+
};
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}),
|
|
278
|
+
|
|
279
|
+
ruff: createCachedChecker((basePath: string): ConfigLoader | null => {
|
|
280
|
+
try {
|
|
281
|
+
const dummyPath = resolve(basePath, "dummy.py");
|
|
282
|
+
const output = execSync(`ruff check --show-settings ${dummyPath}`, {
|
|
283
|
+
encoding: "utf-8",
|
|
284
|
+
cwd: basePath,
|
|
285
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
286
|
+
timeout: 10000,
|
|
287
|
+
});
|
|
288
|
+
const enabledMatch = output.match(
|
|
289
|
+
/linter\.rules\.enabled\s*=\s*\[([\s\S]*?)\]/,
|
|
290
|
+
);
|
|
291
|
+
const enabledCodes = new Set<string>();
|
|
292
|
+
if (enabledMatch?.[1]) {
|
|
293
|
+
const codeRe = /\(([A-Z]+\d*)\)/g;
|
|
294
|
+
let m: RegExpExecArray | null;
|
|
295
|
+
while ((m = codeRe.exec(enabledMatch[1])) !== null) {
|
|
296
|
+
enabledCodes.add(m[1]);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return (ruleName: string): ConfigEnabledStatus => {
|
|
300
|
+
if (enabledCodes.has(ruleName)) return "enabled";
|
|
301
|
+
for (const code of enabledCodes) {
|
|
302
|
+
if (code.startsWith(ruleName)) return "enabled";
|
|
303
|
+
}
|
|
304
|
+
return "disabled";
|
|
305
|
+
};
|
|
306
|
+
} catch {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
}),
|
|
310
|
+
|
|
311
|
+
pylint: createCachedChecker((basePath: string): ConfigLoader | null => {
|
|
312
|
+
try {
|
|
313
|
+
const output = execSync("pylint --list-msgs-enabled", {
|
|
314
|
+
encoding: "utf-8",
|
|
315
|
+
cwd: basePath,
|
|
316
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
317
|
+
timeout: 15000,
|
|
318
|
+
});
|
|
319
|
+
const disabledIdx = output.indexOf("Disabled messages:");
|
|
320
|
+
const enabledSection =
|
|
321
|
+
disabledIdx >= 0 ? output.substring(0, disabledIdx) : output;
|
|
322
|
+
const disabledSection =
|
|
323
|
+
disabledIdx >= 0 ? output.substring(disabledIdx) : "";
|
|
324
|
+
return (ruleName: string): ConfigEnabledStatus => {
|
|
325
|
+
if (disabledSection.includes(ruleName)) return "disabled";
|
|
326
|
+
if (enabledSection.includes(ruleName)) return "enabled";
|
|
327
|
+
return "unknown";
|
|
328
|
+
};
|
|
329
|
+
} catch {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
}),
|
|
333
|
+
|
|
334
|
+
rubocop(ruleName: string, basePath: string): ConfigEnabledStatus {
|
|
335
|
+
try {
|
|
336
|
+
const output = execSync(`rubocop --show-cops ${ruleName}`, {
|
|
337
|
+
encoding: "utf-8",
|
|
338
|
+
cwd: basePath,
|
|
339
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
340
|
+
});
|
|
341
|
+
if (!output || output.trim().length === 0) return "unknown";
|
|
342
|
+
const enabledMatch = output.match(/Enabled:\s*(true|false|pending)/);
|
|
343
|
+
if (!enabledMatch) return "unknown";
|
|
344
|
+
return enabledMatch[1] === "true" ? "enabled" : "disabled";
|
|
345
|
+
} catch {
|
|
346
|
+
return "unknown";
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
clippy: createCachedChecker((basePath: string): ConfigLoader | null => {
|
|
351
|
+
try {
|
|
352
|
+
const cargoPath = resolve(basePath, "Cargo.toml");
|
|
353
|
+
if (!existsSync(cargoPath)) return null;
|
|
354
|
+
const content = readFileSync(cargoPath, "utf-8");
|
|
355
|
+
const sectionMatch = content.match(
|
|
356
|
+
/\[lints\.clippy\]([\s\S]*?)(?=\n\[|$)/,
|
|
357
|
+
);
|
|
358
|
+
if (!sectionMatch?.[1]) return null;
|
|
359
|
+
const section = sectionMatch[1];
|
|
360
|
+
return (ruleName: string): ConfigEnabledStatus => {
|
|
361
|
+
const shortName = ruleName.replace(/^clippy::/, "");
|
|
362
|
+
const ruleMatch = section.match(
|
|
363
|
+
new RegExp(
|
|
364
|
+
`${shortName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*=\\s*"(\\w+)"`,
|
|
365
|
+
),
|
|
366
|
+
);
|
|
367
|
+
if (!ruleMatch?.[1]) return "unknown";
|
|
368
|
+
return ruleMatch[1] === "allow" ? "disabled" : "enabled";
|
|
369
|
+
};
|
|
370
|
+
} catch {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
}),
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
function cliAvailable(command: string): boolean {
|
|
377
|
+
try {
|
|
378
|
+
execSync(`which ${command}`, { stdio: "ignore" });
|
|
379
|
+
return true;
|
|
380
|
+
} catch {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const CLI_TOOL_FOR_LINTER: Record<string, string> = {
|
|
386
|
+
ruff: "ruff",
|
|
387
|
+
clippy: "cargo",
|
|
388
|
+
pylint: "pylint",
|
|
389
|
+
rubocop: "rubocop",
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// Custom linter support (rulesDir)
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
function ruleFileExists(
|
|
397
|
+
ruleName: string,
|
|
398
|
+
rulesDir: string,
|
|
399
|
+
basePath: string,
|
|
400
|
+
): boolean | null {
|
|
401
|
+
const dir = resolve(basePath, rulesDir);
|
|
402
|
+
if (!existsSync(dir)) return null;
|
|
403
|
+
const matches = globSync(`${ruleName}.*`, { cwd: dir });
|
|
404
|
+
return matches.length > 0;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// checkLinterRule helpers
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
interface RuleContext {
|
|
412
|
+
linterName: string;
|
|
413
|
+
ruleName: string;
|
|
414
|
+
basePath: string;
|
|
415
|
+
catalogOnly?: boolean;
|
|
416
|
+
linters?: Record<string, { rulesDir?: string | string[] }>;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function makeResult(
|
|
420
|
+
ctx: RuleContext,
|
|
421
|
+
exists: boolean,
|
|
422
|
+
enabled: ConfigEnabledStatus = "unknown",
|
|
423
|
+
error?: string,
|
|
424
|
+
): LinterCheckResult {
|
|
425
|
+
return { exists, enabled, linter: ctx.linterName, rule: ctx.ruleName, error };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Levenshtein distance for short-string typo detection. Rule names are
|
|
430
|
+
* short so edit distance is more appropriate than NCD (which is tuned
|
|
431
|
+
* for longer texts).
|
|
432
|
+
*/
|
|
433
|
+
function editDistance(a: string, b: string): number {
|
|
434
|
+
if (a === b) return 0;
|
|
435
|
+
const m = a.length;
|
|
436
|
+
const n = b.length;
|
|
437
|
+
if (m === 0) return n;
|
|
438
|
+
if (n === 0) return m;
|
|
439
|
+
const dp: number[] = Array.from({ length: n + 1 }, (_, i) => i);
|
|
440
|
+
for (let i = 1; i <= m; i++) {
|
|
441
|
+
let prev = dp[0];
|
|
442
|
+
dp[0] = i;
|
|
443
|
+
for (let j = 1; j <= n; j++) {
|
|
444
|
+
const tmp = dp[j];
|
|
445
|
+
dp[j] =
|
|
446
|
+
a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, dp[j], dp[j - 1]);
|
|
447
|
+
prev = tmp;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return dp[n];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Top-N closest rule names by edit distance, filtered by a max distance. */
|
|
454
|
+
function closestRuleNames(
|
|
455
|
+
target: string,
|
|
456
|
+
candidates: Iterable<string>,
|
|
457
|
+
limit = 3,
|
|
458
|
+
maxDistance = 4,
|
|
459
|
+
): string[] {
|
|
460
|
+
const scored: { name: string; dist: number }[] = [];
|
|
461
|
+
for (const c of candidates) {
|
|
462
|
+
const d = editDistance(target, c);
|
|
463
|
+
if (d <= maxDistance) scored.push({ name: c, dist: d });
|
|
464
|
+
}
|
|
465
|
+
scored.sort((a, b) => a.dist - b.dist);
|
|
466
|
+
return scored.slice(0, limit).map((s) => s.name);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** @internal */ function tryNodeResolver(
|
|
470
|
+
ctx: RuleContext,
|
|
471
|
+
): LinterCheckResult | null {
|
|
472
|
+
const resolver = LINTER_RESOLVERS[ctx.linterName];
|
|
473
|
+
if (!resolver) return null;
|
|
474
|
+
try {
|
|
475
|
+
const resolved = resolver(ctx.basePath);
|
|
476
|
+
const eslintSet = resolved as EslintRuleSet;
|
|
477
|
+
|
|
478
|
+
if (!resolved.has(ctx.ruleName)) {
|
|
479
|
+
const foundInPlugin =
|
|
480
|
+
eslintSet._isEslint &&
|
|
481
|
+
ctx.ruleName.includes("/") &&
|
|
482
|
+
isEslintPluginRule(ctx.ruleName, eslintSet._basePath ?? ctx.basePath);
|
|
483
|
+
if (!foundInPlugin) {
|
|
484
|
+
const suggestions = closestRuleNames(ctx.ruleName, resolved);
|
|
485
|
+
const hint =
|
|
486
|
+
suggestions.length > 0
|
|
487
|
+
? ` Did you mean: ${suggestions.map((s) => `"${ctx.linterName}/${s}"`).join(", ")}?`
|
|
488
|
+
: "";
|
|
489
|
+
return makeResult(
|
|
490
|
+
ctx,
|
|
491
|
+
false,
|
|
492
|
+
"unknown",
|
|
493
|
+
`Rule "${ctx.ruleName}" not found in ${ctx.linterName}.${hint}`,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const enabled = checkConfigEnabled(
|
|
499
|
+
ctx.linterName,
|
|
500
|
+
ctx.ruleName,
|
|
501
|
+
ctx.basePath,
|
|
502
|
+
ctx.catalogOnly,
|
|
503
|
+
);
|
|
504
|
+
return makeResult(ctx, true, enabled);
|
|
505
|
+
} catch {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function isEslintPluginRule(ruleName: string, basePath: string): boolean {
|
|
511
|
+
const pluginPrefix = ruleName.substring(0, ruleName.indexOf("/"));
|
|
512
|
+
const pluginRuleName = ruleName.substring(ruleName.indexOf("/") + 1);
|
|
513
|
+
const pluginRules = resolveEslintPluginRules(pluginPrefix, basePath);
|
|
514
|
+
return pluginRules?.has(pluginRuleName) === true;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** @internal */ function tryScopedPlugin(
|
|
518
|
+
ctx: RuleContext,
|
|
519
|
+
): LinterCheckResult | null {
|
|
520
|
+
const pluginRules = resolveEslintPluginRules(ctx.linterName, ctx.basePath);
|
|
521
|
+
if (!pluginRules) return null;
|
|
522
|
+
if (!pluginRules.has(ctx.ruleName)) {
|
|
523
|
+
return makeResult(
|
|
524
|
+
ctx,
|
|
525
|
+
false,
|
|
526
|
+
"unknown",
|
|
527
|
+
`Rule "${ctx.ruleName}" not found in ${ctx.linterName}`,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
const enabled = checkConfigEnabled(
|
|
531
|
+
"eslint",
|
|
532
|
+
`${ctx.linterName}/${ctx.ruleName}`,
|
|
533
|
+
ctx.basePath,
|
|
534
|
+
ctx.catalogOnly,
|
|
535
|
+
);
|
|
536
|
+
return makeResult(ctx, true, enabled);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Enumerate all rules for a CLI-based linter so `tryCliCheck` can emit
|
|
541
|
+
* closest-match suggestions on typos. Result is cached per (linter,
|
|
542
|
+
* basePath) so each linter's discovery CLI runs at most once per audit.
|
|
543
|
+
*/
|
|
544
|
+
const CLI_RULE_SET_CACHE = new Map<string, Set<string>>();
|
|
545
|
+
function getCliRuleSet(linterName: string, basePath: string): Set<string> {
|
|
546
|
+
const key = `${linterName}:${basePath}`;
|
|
547
|
+
const cached = CLI_RULE_SET_CACHE.get(key);
|
|
548
|
+
if (cached) return cached;
|
|
549
|
+
const rules = new Set<string>();
|
|
550
|
+
try {
|
|
551
|
+
if (linterName === "ruff") {
|
|
552
|
+
const output = execSync(
|
|
553
|
+
`ruff check --show-settings ${resolve(basePath, "dummy.py")}`,
|
|
554
|
+
{ encoding: "utf-8", cwd: basePath, stdio: ["pipe", "pipe", "pipe"] },
|
|
555
|
+
);
|
|
556
|
+
const enabledMatch = output.match(
|
|
557
|
+
/linter\.rules\.enabled\s*=\s*\[([\s\S]*?)\]/,
|
|
558
|
+
);
|
|
559
|
+
if (enabledMatch?.[1]) {
|
|
560
|
+
const codeRe = /\(([A-Z]+\d*)\)/g;
|
|
561
|
+
let m: RegExpExecArray | null;
|
|
562
|
+
while ((m = codeRe.exec(enabledMatch[1])) !== null) {
|
|
563
|
+
rules.add(m[1]);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
} else if (linterName === "pylint") {
|
|
567
|
+
const output = execSync("pylint --list-msgs-enabled", {
|
|
568
|
+
encoding: "utf-8",
|
|
569
|
+
cwd: basePath,
|
|
570
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
571
|
+
});
|
|
572
|
+
const codeRe = /\b([a-z][a-z0-9-]+)\s*\([A-Z]\d+\)/g;
|
|
573
|
+
let m: RegExpExecArray | null;
|
|
574
|
+
while ((m = codeRe.exec(output)) !== null) {
|
|
575
|
+
rules.add(m[1]);
|
|
576
|
+
}
|
|
577
|
+
} else if (linterName === "rubocop") {
|
|
578
|
+
const output = execSync("rubocop --show-cops", {
|
|
579
|
+
encoding: "utf-8",
|
|
580
|
+
cwd: basePath,
|
|
581
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
582
|
+
});
|
|
583
|
+
const copRe = /^([A-Z][A-Za-z]+\/[A-Z][A-Za-z0-9]+):/gm;
|
|
584
|
+
let m: RegExpExecArray | null;
|
|
585
|
+
while ((m = copRe.exec(output)) !== null) {
|
|
586
|
+
rules.add(m[1]);
|
|
587
|
+
}
|
|
588
|
+
} else if (linterName === "clippy") {
|
|
589
|
+
// Read Cargo.toml [lints.clippy] section — same source of truth
|
|
590
|
+
// generate-types uses. Full clippy catalogue is enormous and
|
|
591
|
+
// only partially enabled per project.
|
|
592
|
+
const cargoPath = resolve(basePath, "Cargo.toml");
|
|
593
|
+
if (existsSync(cargoPath)) {
|
|
594
|
+
const content = readFileSync(cargoPath, "utf-8");
|
|
595
|
+
const sectionMatch = content.match(
|
|
596
|
+
/\[lints\.clippy\]([\s\S]*?)(?=\n\[|$)/,
|
|
597
|
+
);
|
|
598
|
+
if (sectionMatch?.[1]) {
|
|
599
|
+
const ruleRe = /^([a-z][a-z_]*)\s*=/gm;
|
|
600
|
+
let m: RegExpExecArray | null;
|
|
601
|
+
while ((m = ruleRe.exec(sectionMatch[1])) !== null) {
|
|
602
|
+
rules.add(m[1]);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
} catch {
|
|
608
|
+
// CLI failed — return empty set, caller will just skip suggestions
|
|
609
|
+
}
|
|
610
|
+
CLI_RULE_SET_CACHE.set(key, rules);
|
|
611
|
+
return rules;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** @internal */ function tryCliCheck(
|
|
615
|
+
ctx: RuleContext,
|
|
616
|
+
): LinterCheckResult | null {
|
|
617
|
+
const cliCheck = CLI_RULE_CHECKS[ctx.linterName];
|
|
618
|
+
if (!cliCheck) return null;
|
|
619
|
+
const tool = CLI_TOOL_FOR_LINTER[ctx.linterName];
|
|
620
|
+
if (tool && !cliAvailable(tool)) {
|
|
621
|
+
return makeResult(
|
|
622
|
+
ctx,
|
|
623
|
+
false,
|
|
624
|
+
"unknown",
|
|
625
|
+
`${ctx.linterName} CLI tool "${tool}" not found on PATH`,
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
try {
|
|
629
|
+
cliCheck(ctx.ruleName);
|
|
630
|
+
const enabled = checkConfigEnabled(
|
|
631
|
+
ctx.linterName,
|
|
632
|
+
ctx.ruleName,
|
|
633
|
+
ctx.basePath,
|
|
634
|
+
ctx.catalogOnly,
|
|
635
|
+
);
|
|
636
|
+
return makeResult(ctx, true, enabled);
|
|
637
|
+
} catch {
|
|
638
|
+
// Rule not found — try to suggest closest matches from the full
|
|
639
|
+
// rule set. Uses the same edit-distance helper as the Node
|
|
640
|
+
// resolver path; caching means this runs the CLI at most once.
|
|
641
|
+
const ruleSet = getCliRuleSet(ctx.linterName, ctx.basePath);
|
|
642
|
+
const suggestions =
|
|
643
|
+
ruleSet.size > 0 ? closestRuleNames(ctx.ruleName, ruleSet) : [];
|
|
644
|
+
const hint =
|
|
645
|
+
suggestions.length > 0
|
|
646
|
+
? ` Did you mean: ${suggestions.map((s) => `"${ctx.linterName}/${s}"`).join(", ")}?`
|
|
647
|
+
: "";
|
|
648
|
+
return makeResult(
|
|
649
|
+
ctx,
|
|
650
|
+
false,
|
|
651
|
+
"unknown",
|
|
652
|
+
`Rule "${ctx.ruleName}" not found in ${ctx.linterName}.${hint}`,
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** @internal */ function tryCustomRulesDir(
|
|
658
|
+
ctx: RuleContext,
|
|
659
|
+
): LinterCheckResult | null {
|
|
660
|
+
const linterCfg = ctx.linters?.[ctx.linterName];
|
|
661
|
+
if (!linterCfg?.rulesDir) return null;
|
|
662
|
+
const dirs = Array.isArray(linterCfg.rulesDir)
|
|
663
|
+
? linterCfg.rulesDir
|
|
664
|
+
: [linterCfg.rulesDir];
|
|
665
|
+
for (const dir of dirs) {
|
|
666
|
+
const found = ruleFileExists(ctx.ruleName, dir, ctx.basePath);
|
|
667
|
+
if (found) return makeResult(ctx, true);
|
|
668
|
+
}
|
|
669
|
+
return makeResult(
|
|
670
|
+
ctx,
|
|
671
|
+
false,
|
|
672
|
+
"unknown",
|
|
673
|
+
`Rule file for "${ctx.ruleName}" not found in ${ctx.linterName} rulesDir`,
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// ---------------------------------------------------------------------------
|
|
678
|
+
// Public API
|
|
679
|
+
// ---------------------------------------------------------------------------
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Check a single linter rule reference (e.g., "eslint/no-console").
|
|
683
|
+
*
|
|
684
|
+
* Verifies: (1) rule exists in linter, (2) rule is enabled in project config.
|
|
685
|
+
* Returns a result with exists/enabled status.
|
|
686
|
+
*/
|
|
687
|
+
export function checkLinterRule(
|
|
688
|
+
enforcedBy: string,
|
|
689
|
+
basePath: string,
|
|
690
|
+
options?: {
|
|
691
|
+
catalogOnly?: boolean;
|
|
692
|
+
linters?: Record<string, { rulesDir?: string | string[] }>;
|
|
693
|
+
},
|
|
694
|
+
): LinterCheckResult {
|
|
695
|
+
const linterName = extractLinterName(enforcedBy);
|
|
696
|
+
const ruleName = extractRuleName(enforcedBy);
|
|
697
|
+
|
|
698
|
+
if (!ruleName || !SAFE_RULE_NAME_RE.test(ruleName)) {
|
|
699
|
+
return {
|
|
700
|
+
exists: false,
|
|
701
|
+
enabled: "unknown",
|
|
702
|
+
linter: linterName,
|
|
703
|
+
rule: ruleName ?? enforcedBy,
|
|
704
|
+
error: `Invalid rule reference: "${enforcedBy}"`,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const ctx: RuleContext = {
|
|
709
|
+
linterName,
|
|
710
|
+
ruleName,
|
|
711
|
+
basePath,
|
|
712
|
+
catalogOnly: options?.catalogOnly,
|
|
713
|
+
linters: options?.linters,
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
return (
|
|
717
|
+
tryNodeResolver(ctx) ??
|
|
718
|
+
tryScopedPlugin(ctx) ??
|
|
719
|
+
tryCliCheck(ctx) ??
|
|
720
|
+
tryCustomRulesDir(ctx) ??
|
|
721
|
+
makeResult(ctx, false, "unknown", `Unknown linter: "${linterName}"`)
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function checkConfigEnabled(
|
|
726
|
+
linterName: string,
|
|
727
|
+
ruleName: string,
|
|
728
|
+
basePath: string,
|
|
729
|
+
catalogOnly?: boolean,
|
|
730
|
+
): ConfigEnabledStatus {
|
|
731
|
+
if (catalogOnly) return "unknown";
|
|
732
|
+
const checker = LINTER_CONFIG_CHECKERS[linterName];
|
|
733
|
+
if (!checker) return "unknown";
|
|
734
|
+
try {
|
|
735
|
+
return checker(ruleName, basePath);
|
|
736
|
+
} catch {
|
|
737
|
+
return "unknown";
|
|
738
|
+
}
|
|
739
|
+
}
|