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.
Files changed (156) hide show
  1. package/.claude/settings.json +46 -0
  2. package/.claude/settings.local.json +8 -0
  3. package/.claude-plugin/hooks/post-edit.sh +34 -0
  4. package/.claude-plugin/hooks/pre-edit.sh +40 -0
  5. package/.claude-plugin/hooks/session-start.sh +38 -0
  6. package/.claude-plugin/marketplace.json +14 -0
  7. package/.claude-plugin/plugin.json +47 -0
  8. package/.github/workflows/ci.yml +81 -0
  9. package/.prettierignore +1 -0
  10. package/.vigiles/generated.d.ts +205 -0
  11. package/CLAUDE.md +95 -0
  12. package/CLAUDE.md.spec.ts +142 -0
  13. package/CONTRIBUTING.md +121 -0
  14. package/LICENSE +21 -0
  15. package/README.md +377 -0
  16. package/action.yml +25 -0
  17. package/dist/action.d.ts +7 -0
  18. package/dist/action.d.ts.map +1 -0
  19. package/dist/action.js +180 -0
  20. package/dist/action.js.map +1 -0
  21. package/dist/cli.d.ts +12 -0
  22. package/dist/cli.d.ts.map +1 -0
  23. package/dist/cli.js +1267 -0
  24. package/dist/cli.js.map +1 -0
  25. package/dist/cli.test.d.ts +2 -0
  26. package/dist/cli.test.d.ts.map +1 -0
  27. package/dist/cli.test.js +650 -0
  28. package/dist/cli.test.js.map +1 -0
  29. package/dist/compile.d.ts +101 -0
  30. package/dist/compile.d.ts.map +1 -0
  31. package/dist/compile.js +503 -0
  32. package/dist/compile.js.map +1 -0
  33. package/dist/evolve.d.ts +132 -0
  34. package/dist/evolve.d.ts.map +1 -0
  35. package/dist/evolve.js +599 -0
  36. package/dist/evolve.js.map +1 -0
  37. package/dist/freshness.d.ts +67 -0
  38. package/dist/freshness.d.ts.map +1 -0
  39. package/dist/freshness.js +244 -0
  40. package/dist/freshness.js.map +1 -0
  41. package/dist/freshness.test.d.ts +2 -0
  42. package/dist/freshness.test.d.ts.map +1 -0
  43. package/dist/freshness.test.js +356 -0
  44. package/dist/freshness.test.js.map +1 -0
  45. package/dist/generate-types.d.ts +34 -0
  46. package/dist/generate-types.d.ts.map +1 -0
  47. package/dist/generate-types.js +381 -0
  48. package/dist/generate-types.js.map +1 -0
  49. package/dist/inline.d.ts +58 -0
  50. package/dist/inline.d.ts.map +1 -0
  51. package/dist/inline.js +142 -0
  52. package/dist/inline.js.map +1 -0
  53. package/dist/inline.test.d.ts +5 -0
  54. package/dist/inline.test.d.ts.map +1 -0
  55. package/dist/inline.test.js +152 -0
  56. package/dist/inline.test.js.map +1 -0
  57. package/dist/linters.d.ts +38 -0
  58. package/dist/linters.d.ts.map +1 -0
  59. package/dist/linters.js +588 -0
  60. package/dist/linters.js.map +1 -0
  61. package/dist/proofs.d.ts +272 -0
  62. package/dist/proofs.d.ts.map +1 -0
  63. package/dist/proofs.js +622 -0
  64. package/dist/proofs.js.map +1 -0
  65. package/dist/proofs.test.d.ts +9 -0
  66. package/dist/proofs.test.d.ts.map +1 -0
  67. package/dist/proofs.test.js +952 -0
  68. package/dist/proofs.test.js.map +1 -0
  69. package/dist/spec.d.ts +258 -0
  70. package/dist/spec.d.ts.map +1 -0
  71. package/dist/spec.js +113 -0
  72. package/dist/spec.js.map +1 -0
  73. package/dist/spec.test.d.ts +2 -0
  74. package/dist/spec.test.d.ts.map +1 -0
  75. package/dist/spec.test.js +1222 -0
  76. package/dist/spec.test.js.map +1 -0
  77. package/dist/types.d.ts +101 -0
  78. package/dist/types.d.ts.map +1 -0
  79. package/dist/types.js +3 -0
  80. package/dist/types.js.map +1 -0
  81. package/dist/validate.d.ts +10 -0
  82. package/dist/validate.d.ts.map +1 -0
  83. package/dist/validate.js +286 -0
  84. package/dist/validate.js.map +1 -0
  85. package/dist/validate.test.d.ts +2 -0
  86. package/dist/validate.test.d.ts.map +1 -0
  87. package/dist/validate.test.js +531 -0
  88. package/dist/validate.test.js.map +1 -0
  89. package/docs/agent-setup.md +85 -0
  90. package/docs/agent-workflows.md +103 -0
  91. package/docs/comparison.md +71 -0
  92. package/docs/freshness.md +124 -0
  93. package/docs/inline-mode.md +119 -0
  94. package/docs/linter-support.md +166 -0
  95. package/docs/spec-format.md +194 -0
  96. package/eslint.config.mjs +79 -0
  97. package/examples/CLAUDE.md +54 -0
  98. package/examples/CLAUDE.md.spec.ts +65 -0
  99. package/examples/SKILL.md +50 -0
  100. package/examples/SKILL.md.spec.ts +57 -0
  101. package/fixtures/example-project/CLAUDE.md +11 -0
  102. package/fixtures/example-project/package.json +9 -0
  103. package/fixtures/example-project/src/index.ts +3 -0
  104. package/fixtures/example-project/src/utils.test.ts +2 -0
  105. package/fixtures/example-project/src/utils.ts +3 -0
  106. package/logo.png +0 -0
  107. package/package.json +42 -0
  108. package/research/adoption-strategy.md +111 -0
  109. package/research/agent-integration.md +145 -0
  110. package/research/ai-code-quality.md +197 -0
  111. package/research/code-search-for-agents.md +313 -0
  112. package/research/competitive-landscape.md +163 -0
  113. package/research/doc-freshness.md +516 -0
  114. package/research/executable-specs.md +368 -0
  115. package/research/feature-ideas.md +464 -0
  116. package/research/formal-proofs-for-agents.md +338 -0
  117. package/research/fp-for-agent-harness.md +150 -0
  118. package/research/fp-for-deterministic-ai.md +131 -0
  119. package/research/self-evolving-specs.md +298 -0
  120. package/schemas/claude-md-strict.yml +18 -0
  121. package/schemas/claude-md.yml +6 -0
  122. package/schemas/skill-strict.yml +12 -0
  123. package/schemas/skill.yml +5 -0
  124. package/skills/audit-feedback-loop/SKILL.md +76 -0
  125. package/skills/edit-spec/SKILL.md +131 -0
  126. package/skills/enforce-rules-format/SKILL.md +71 -0
  127. package/skills/generate-logo/SKILL.md +102 -0
  128. package/skills/generate-rule/SKILL.md +90 -0
  129. package/skills/linter-docs/clippy.md +241 -0
  130. package/skills/linter-docs/eslint.md +384 -0
  131. package/skills/linter-docs/pylint.md +288 -0
  132. package/skills/linter-docs/rubocop.md +277 -0
  133. package/skills/linter-docs/ruff.md +187 -0
  134. package/skills/linter-docs/stylelint.md +247 -0
  135. package/skills/migrate-to-spec/SKILL.md +124 -0
  136. package/skills/pr-to-lint-rule/SKILL.md +97 -0
  137. package/skills/strengthen/SKILL.md +168 -0
  138. package/src/action.ts +214 -0
  139. package/src/cli.test.ts +914 -0
  140. package/src/cli.ts +1631 -0
  141. package/src/compile.ts +691 -0
  142. package/src/evolve.ts +781 -0
  143. package/src/freshness.test.ts +449 -0
  144. package/src/freshness.ts +299 -0
  145. package/src/generate-types.ts +448 -0
  146. package/src/inline.test.ts +206 -0
  147. package/src/inline.ts +164 -0
  148. package/src/linters.ts +739 -0
  149. package/src/proofs.test.ts +1314 -0
  150. package/src/proofs.ts +849 -0
  151. package/src/spec.test.ts +1471 -0
  152. package/src/spec.ts +427 -0
  153. package/src/types.ts +117 -0
  154. package/src/validate.test.ts +701 -0
  155. package/src/validate.ts +381 -0
  156. 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
+ }