@praneeth_54/agentdoctor 0.1.0-beta

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 (112) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/LICENSE +21 -0
  3. package/README.md +348 -0
  4. package/dist/agents/claude/adapter.d.ts +1 -0
  5. package/dist/agents/claude/adapter.js +1 -0
  6. package/dist/agents/claude/detector.d.ts +16 -0
  7. package/dist/agents/claude/detector.js +225 -0
  8. package/dist/agents/codex/adapter.d.ts +1 -0
  9. package/dist/agents/codex/adapter.js +1 -0
  10. package/dist/agents/codex/detector.d.ts +17 -0
  11. package/dist/agents/codex/detector.js +151 -0
  12. package/dist/agents/cursor/adapter.d.ts +1 -0
  13. package/dist/agents/cursor/adapter.js +1 -0
  14. package/dist/agents/cursor/detector.d.ts +17 -0
  15. package/dist/agents/cursor/detector.js +198 -0
  16. package/dist/agents/detect-agents.d.ts +17 -0
  17. package/dist/agents/detect-agents.js +23 -0
  18. package/dist/agents/inspect.d.ts +36 -0
  19. package/dist/agents/inspect.js +165 -0
  20. package/dist/agents/registry.d.ts +7 -0
  21. package/dist/agents/registry.js +11 -0
  22. package/dist/agents/types.d.ts +52 -0
  23. package/dist/agents/types.js +15 -0
  24. package/dist/cli/commands/doctor.d.ts +5 -0
  25. package/dist/cli/commands/doctor.js +22 -0
  26. package/dist/cli/commands/explain.d.ts +5 -0
  27. package/dist/cli/commands/explain.js +51 -0
  28. package/dist/cli/commands/fix.d.ts +8 -0
  29. package/dist/cli/commands/fix.js +19 -0
  30. package/dist/cli/commands/scan.d.ts +10 -0
  31. package/dist/cli/commands/scan.js +59 -0
  32. package/dist/cli/index.d.ts +2 -0
  33. package/dist/cli/index.js +3 -0
  34. package/dist/cli/program.d.ts +3 -0
  35. package/dist/cli/program.js +124 -0
  36. package/dist/constants.d.ts +5 -0
  37. package/dist/constants.js +38 -0
  38. package/dist/core/mcp/parse.d.ts +7 -0
  39. package/dist/core/mcp/parse.js +303 -0
  40. package/dist/core/mcp/types.d.ts +33 -0
  41. package/dist/core/mcp/types.js +1 -0
  42. package/dist/core/rules/build-context.d.ts +10 -0
  43. package/dist/core/rules/build-context.js +31 -0
  44. package/dist/core/rules/context/generated-directory.d.ts +2 -0
  45. package/dist/core/rules/context/generated-directory.js +89 -0
  46. package/dist/core/rules/context/large-instruction-file.d.ts +2 -0
  47. package/dist/core/rules/context/large-instruction-file.js +56 -0
  48. package/dist/core/rules/context/large-log-file.d.ts +2 -0
  49. package/dist/core/rules/context/large-log-file.js +45 -0
  50. package/dist/core/rules/dedupe.d.ts +13 -0
  51. package/dist/core/rules/dedupe.js +53 -0
  52. package/dist/core/rules/ignore.d.ts +23 -0
  53. package/dist/core/rules/ignore.js +97 -0
  54. package/dist/core/rules/instructions/duplicate-content.d.ts +2 -0
  55. package/dist/core/rules/instructions/duplicate-content.js +99 -0
  56. package/dist/core/rules/instructions/empty-instructions.d.ts +2 -0
  57. package/dist/core/rules/instructions/empty-instructions.js +53 -0
  58. package/dist/core/rules/instructions/missing-path-reference.d.ts +2 -0
  59. package/dist/core/rules/instructions/missing-path-reference.js +131 -0
  60. package/dist/core/rules/mcp/mcp-rules.d.ts +3 -0
  61. package/dist/core/rules/mcp/mcp-rules.js +83 -0
  62. package/dist/core/rules/registry.d.ts +6 -0
  63. package/dist/core/rules/registry.js +31 -0
  64. package/dist/core/rules/run-rules.d.ts +16 -0
  65. package/dist/core/rules/run-rules.js +36 -0
  66. package/dist/core/rules/security/claude-bypass-permissions.d.ts +7 -0
  67. package/dist/core/rules/security/claude-bypass-permissions.js +52 -0
  68. package/dist/core/rules/security/env-file-exposure.d.ts +2 -0
  69. package/dist/core/rules/security/env-file-exposure.js +87 -0
  70. package/dist/core/rules/security/mcp-broad-filesystem.d.ts +2 -0
  71. package/dist/core/rules/security/mcp-broad-filesystem.js +42 -0
  72. package/dist/core/rules/security/private-key-file.d.ts +2 -0
  73. package/dist/core/rules/security/private-key-file.js +60 -0
  74. package/dist/core/rules/text-cache.d.ts +19 -0
  75. package/dist/core/rules/text-cache.js +112 -0
  76. package/dist/core/rules/thresholds.d.ts +13 -0
  77. package/dist/core/rules/thresholds.js +13 -0
  78. package/dist/core/rules/types.d.ts +57 -0
  79. package/dist/core/rules/types.js +23 -0
  80. package/dist/core/scanner/scan.d.ts +7 -0
  81. package/dist/core/scanner/scan.js +70 -0
  82. package/dist/core/scoring/placeholder.d.ts +7 -0
  83. package/dist/core/scoring/placeholder.js +29 -0
  84. package/dist/detectors/framework.d.ts +17 -0
  85. package/dist/detectors/framework.js +148 -0
  86. package/dist/detectors/language.d.ts +14 -0
  87. package/dist/detectors/language.js +122 -0
  88. package/dist/detectors/monorepo.d.ts +13 -0
  89. package/dist/detectors/monorepo.js +44 -0
  90. package/dist/detectors/package-manager.d.ts +14 -0
  91. package/dist/detectors/package-manager.js +78 -0
  92. package/dist/detectors/project.d.ts +10 -0
  93. package/dist/detectors/project.js +60 -0
  94. package/dist/discovery/files.d.ts +15 -0
  95. package/dist/discovery/files.js +118 -0
  96. package/dist/index.d.ts +8 -0
  97. package/dist/index.js +7 -0
  98. package/dist/reporters/json/report.d.ts +5 -0
  99. package/dist/reporters/json/report.js +84 -0
  100. package/dist/reporters/terminal/report.d.ts +8 -0
  101. package/dist/reporters/terminal/report.js +147 -0
  102. package/dist/security/redaction.d.ts +9 -0
  103. package/dist/security/redaction.js +26 -0
  104. package/dist/types/index.d.ts +137 -0
  105. package/dist/types/index.js +6 -0
  106. package/dist/utils/colors.d.ts +14 -0
  107. package/dist/utils/colors.js +26 -0
  108. package/dist/utils/fs.d.ts +17 -0
  109. package/dist/utils/fs.js +63 -0
  110. package/dist/utils/path.d.ts +17 -0
  111. package/dist/utils/path.js +37 -0
  112. package/package.json +80 -0
@@ -0,0 +1,303 @@
1
+ import path from "node:path";
2
+ import { inspectRepoFile, pathExistsInsideRoot, tryParseJson } from "../../agents/inspect.js";
3
+ import { isPathInsideRoot } from "../../utils/path.js";
4
+ const FILESYSTEM_SERVER_HINTS = [
5
+ "server-filesystem",
6
+ "@modelcontextprotocol/server-filesystem",
7
+ "mcp-server-filesystem",
8
+ ];
9
+ function isFilesystemServer(command, args) {
10
+ const haystack = [command ?? "", ...(args ?? [])].join(" ").toLowerCase();
11
+ return FILESYSTEM_SERVER_HINTS.some((hint) => haystack.includes(hint));
12
+ }
13
+ function extractFilesystemScopes(args) {
14
+ if (!args) {
15
+ return [];
16
+ }
17
+ const scopes = [];
18
+ for (const arg of args) {
19
+ if (arg.startsWith("-") ||
20
+ arg.startsWith("@") ||
21
+ arg.includes("://") ||
22
+ arg === "npx" ||
23
+ arg === "-y") {
24
+ continue;
25
+ }
26
+ // Path-like arguments
27
+ if (arg.startsWith("/") ||
28
+ arg.startsWith("~") ||
29
+ arg.startsWith("$") ||
30
+ arg.includes("/") ||
31
+ arg.includes("\\") ||
32
+ arg === "." ||
33
+ arg === "..") {
34
+ scopes.push(arg);
35
+ }
36
+ }
37
+ return scopes;
38
+ }
39
+ function envKeysOnly(env) {
40
+ if (!env || typeof env !== "object" || Array.isArray(env)) {
41
+ return undefined;
42
+ }
43
+ return Object.keys(env).sort();
44
+ }
45
+ function detectTransport(entry) {
46
+ const type = typeof entry.type === "string" ? entry.type.toLowerCase() : "";
47
+ if (type === "http" || type === "streamable-http") {
48
+ return "http";
49
+ }
50
+ if (type === "sse") {
51
+ return "sse";
52
+ }
53
+ if (type === "stdio" || entry.command) {
54
+ return "stdio";
55
+ }
56
+ if (entry.url) {
57
+ return "http";
58
+ }
59
+ return "unknown";
60
+ }
61
+ function parseMcpServersObject(data, sourceAgent, sourcePath) {
62
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
63
+ return { servers: [], error: "Root value is not an object" };
64
+ }
65
+ const root = data;
66
+ const serversRaw = root.mcpServers;
67
+ if (serversRaw === undefined) {
68
+ return { servers: [] };
69
+ }
70
+ if (!serversRaw || typeof serversRaw !== "object" || Array.isArray(serversRaw)) {
71
+ return { servers: [], error: "mcpServers must be an object" };
72
+ }
73
+ const servers = [];
74
+ for (const [name, value] of Object.entries(serversRaw)) {
75
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
76
+ servers.push({
77
+ name,
78
+ sourceAgent,
79
+ sourcePath,
80
+ transport: "unknown",
81
+ parseError: "Server entry is not an object",
82
+ });
83
+ continue;
84
+ }
85
+ const entry = value;
86
+ const command = typeof entry.command === "string" ? entry.command : undefined;
87
+ const args = Array.isArray(entry.args)
88
+ ? entry.args.filter((a) => typeof a === "string")
89
+ : undefined;
90
+ const url = typeof entry.url === "string" ? entry.url : undefined;
91
+ const envKeys = envKeysOnly(entry.env);
92
+ const filesystemScopes = isFilesystemServer(command, args)
93
+ ? extractFilesystemScopes(args)
94
+ : extractFilesystemScopes(args).filter((s) => s.startsWith("/") || s.startsWith("~"));
95
+ const server = {
96
+ name,
97
+ sourceAgent,
98
+ sourcePath,
99
+ transport: detectTransport(entry),
100
+ };
101
+ if (command !== undefined)
102
+ server.command = command;
103
+ if (args !== undefined)
104
+ server.args = args;
105
+ if (url !== undefined)
106
+ server.url = url;
107
+ if (envKeys !== undefined)
108
+ server.envKeys = envKeys;
109
+ if (filesystemScopes.length > 0)
110
+ server.filesystemScopes = filesystemScopes;
111
+ servers.push(server);
112
+ }
113
+ return { servers };
114
+ }
115
+ /**
116
+ * Minimal TOML extractor for Codex `[mcp_servers.<name>]` tables.
117
+ * Does not evaluate values beyond strings/arrays needed for command/args.
118
+ * Never captures env values — only env key names from `env = { KEY = ... }`.
119
+ */
120
+ function parseCodexMcpToml(text, sourcePath) {
121
+ const servers = [];
122
+ const lines = text.split(/\r?\n/);
123
+ let current = null;
124
+ const flush = () => {
125
+ if (!current?.name) {
126
+ current = null;
127
+ return;
128
+ }
129
+ const server = {
130
+ name: current.name,
131
+ sourceAgent: "codex",
132
+ sourcePath,
133
+ transport: current.command ? "stdio" : current.url ? "http" : "unknown",
134
+ };
135
+ if (current.command)
136
+ server.command = current.command;
137
+ if (current.args)
138
+ server.args = current.args;
139
+ if (current.url)
140
+ server.url = current.url;
141
+ if (current.envKeys)
142
+ server.envKeys = current.envKeys;
143
+ if (current.filesystemScopes)
144
+ server.filesystemScopes = current.filesystemScopes;
145
+ servers.push(server);
146
+ current = null;
147
+ };
148
+ for (const rawLine of lines) {
149
+ const line = rawLine.trim();
150
+ if (!line || line.startsWith("#")) {
151
+ continue;
152
+ }
153
+ const header = line.match(/^\[mcp_servers\.([^\]]+)\]$/i);
154
+ if (header) {
155
+ flush();
156
+ const name = header[1];
157
+ current = name ? { name } : null;
158
+ continue;
159
+ }
160
+ if (!current) {
161
+ continue;
162
+ }
163
+ const commandMatch = line.match(/^command\s*=\s*"([^"]*)"/i);
164
+ if (commandMatch?.[1] !== undefined) {
165
+ current.command = commandMatch[1];
166
+ continue;
167
+ }
168
+ const urlMatch = line.match(/^url\s*=\s*"([^"]*)"/i);
169
+ if (urlMatch?.[1] !== undefined) {
170
+ current.url = urlMatch[1];
171
+ continue;
172
+ }
173
+ const argsMatch = line.match(/^args\s*=\s*\[(.*)\]/i);
174
+ if (argsMatch?.[1] !== undefined) {
175
+ const args = [...argsMatch[1].matchAll(/"([^"]*)"/g)].map((m) => m[1] ?? "");
176
+ current.args = args;
177
+ if (isFilesystemServer(current.command, args)) {
178
+ current.filesystemScopes = extractFilesystemScopes(args);
179
+ }
180
+ continue;
181
+ }
182
+ const envMatch = line.match(/^env\s*=\s*\{([^}]*)\}/i);
183
+ if (envMatch?.[1] !== undefined) {
184
+ const keys = [...envMatch[1].matchAll(/([A-Za-z_][A-Za-z0-9_]*)\s*=/g)].map((m) => m[1] ?? "");
185
+ current.envKeys = [...new Set(keys)].sort();
186
+ }
187
+ }
188
+ flush();
189
+ return { servers };
190
+ }
191
+ export async function parseProjectMcpConfigs(root, maxFileSizeBytes) {
192
+ const servers = [];
193
+ const diagnostics = [];
194
+ const jsonTargets = [
195
+ { relative: ".cursor/mcp.json", agent: "cursor" },
196
+ { relative: ".mcp.json", agent: "claude-code" },
197
+ ];
198
+ for (const target of jsonTargets) {
199
+ if (!(await pathExistsInsideRoot(root, target.relative))) {
200
+ continue;
201
+ }
202
+ const inspected = await inspectRepoFile(root, target.relative, maxFileSizeBytes);
203
+ if (!inspected.readable || inspected.text === null) {
204
+ diagnostics.push({
205
+ severity: "warning",
206
+ message: `MCP config could not be read: ${inspected.error ?? "unknown error"}`,
207
+ file: target.relative,
208
+ });
209
+ servers.push({
210
+ name: "(unreadable)",
211
+ sourceAgent: target.agent,
212
+ sourcePath: target.relative,
213
+ transport: "unknown",
214
+ parseError: inspected.error ?? "unreadable",
215
+ });
216
+ continue;
217
+ }
218
+ if (inspected.empty) {
219
+ diagnostics.push({
220
+ severity: "info",
221
+ message: `MCP config is empty`,
222
+ file: target.relative,
223
+ });
224
+ continue;
225
+ }
226
+ const parsedJson = tryParseJson(inspected.text);
227
+ if (!parsedJson.ok) {
228
+ diagnostics.push({
229
+ severity: "warning",
230
+ message: `MCP config could not be parsed: ${parsedJson.error}`,
231
+ file: target.relative,
232
+ });
233
+ servers.push({
234
+ name: "(malformed)",
235
+ sourceAgent: target.agent,
236
+ sourcePath: target.relative,
237
+ transport: "unknown",
238
+ parseError: parsedJson.error,
239
+ });
240
+ continue;
241
+ }
242
+ const parsed = parseMcpServersObject(parsedJson.data, target.agent, target.relative);
243
+ if (parsed.error) {
244
+ diagnostics.push({
245
+ severity: "warning",
246
+ message: parsed.error,
247
+ file: target.relative,
248
+ });
249
+ }
250
+ servers.push(...parsed.servers);
251
+ }
252
+ const codexConfig = ".codex/config.toml";
253
+ if (await pathExistsInsideRoot(root, codexConfig)) {
254
+ const inspected = await inspectRepoFile(root, codexConfig, maxFileSizeBytes);
255
+ if (inspected.readable && inspected.text) {
256
+ const parsed = parseCodexMcpToml(inspected.text, codexConfig);
257
+ servers.push(...parsed.servers);
258
+ }
259
+ else if (inspected.exists && inspected.error) {
260
+ diagnostics.push({
261
+ severity: "warning",
262
+ message: `Codex MCP config could not be read: ${inspected.error}`,
263
+ file: codexConfig,
264
+ });
265
+ }
266
+ }
267
+ return { servers, diagnostics };
268
+ }
269
+ /**
270
+ * Classify whether a filesystem scope string is overly broad relative to the repo.
271
+ * Does not follow or read the path.
272
+ */
273
+ export function classifyFilesystemScope(scope, root) {
274
+ const trimmed = scope.trim();
275
+ if (!trimmed) {
276
+ return "unknown";
277
+ }
278
+ if (trimmed === "/" || trimmed === "~" || trimmed === "$HOME" || trimmed === "${HOME}") {
279
+ return "broad";
280
+ }
281
+ if (trimmed.startsWith("~/") || trimmed.startsWith("$HOME/") || trimmed.startsWith("${HOME}/")) {
282
+ return "broad";
283
+ }
284
+ if (trimmed === ".." || trimmed.startsWith("../")) {
285
+ return "outside";
286
+ }
287
+ if (path.isAbsolute(trimmed)) {
288
+ try {
289
+ if (!isPathInsideRoot(root, trimmed)) {
290
+ return "outside";
291
+ }
292
+ return "ok";
293
+ }
294
+ catch {
295
+ return "unknown";
296
+ }
297
+ }
298
+ // Relative paths are treated as repo-relative → ok
299
+ if (trimmed === "." || !trimmed.startsWith("..")) {
300
+ return "ok";
301
+ }
302
+ return "unknown";
303
+ }
@@ -0,0 +1,33 @@
1
+ import type { AgentId } from "../../types/index.js";
2
+ /**
3
+ * Parsed MCP server configuration from repository-scoped files only.
4
+ * Never stores environment variable values — keys only.
5
+ *
6
+ * Official project-level sources verified:
7
+ * - Cursor: `.cursor/mcp.json` (`mcpServers`) — cursor.com MCP docs / community + Cursor MCP UI
8
+ * - Claude Code: `.mcp.json` (`mcpServers`) — https://code.claude.com/docs/en/mcp
9
+ * - Codex: `.codex/config.toml` `[mcp_servers.*]` — https://developers.openai.com/codex/mcp
10
+ */
11
+ export type McpTransport = "stdio" | "http" | "sse" | "unknown";
12
+ export interface McpServerConfig {
13
+ name: string;
14
+ sourceAgent: AgentId;
15
+ sourcePath: string;
16
+ transport: McpTransport;
17
+ command?: string;
18
+ args?: string[];
19
+ url?: string;
20
+ /** Environment variable NAMES only — never values. */
21
+ envKeys?: string[];
22
+ /** Path-like arguments that may indicate filesystem scope. */
23
+ filesystemScopes?: string[];
24
+ parseError?: string;
25
+ }
26
+ export interface McpParseResult {
27
+ servers: McpServerConfig[];
28
+ diagnostics: Array<{
29
+ severity: "warning" | "error" | "info";
30
+ message: string;
31
+ file?: string;
32
+ }>;
33
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ import type { AgentDetectionResult } from "../../agents/types.js";
2
+ import type { DiscoveryResult, RepositoryInfo } from "../../types/index.js";
3
+ import type { RuleContext } from "./types.js";
4
+ export declare function buildRuleContext(options: {
5
+ root: string;
6
+ repository: RepositoryInfo;
7
+ discovery: DiscoveryResult;
8
+ agents: AgentDetectionResult[];
9
+ maxFileSizeBytes: number;
10
+ }): Promise<RuleContext>;
@@ -0,0 +1,31 @@
1
+ import { parseProjectMcpConfigs } from "../mcp/parse.js";
2
+ import { createIgnoreIndex, parseIgnoreFile } from "./ignore.js";
3
+ import { TextCache } from "./text-cache.js";
4
+ export async function buildRuleContext(options) {
5
+ const textCache = new TextCache(options.root, options.maxFileSizeBytes);
6
+ const gitignore = await textCache.read(".gitignore");
7
+ const cursorignore = await textCache.read(".cursorignore");
8
+ const ignore = createIgnoreIndex({
9
+ gitignorePatterns: gitignore.text ? parseIgnoreFile(gitignore.text) : [],
10
+ cursorignorePatterns: cursorignore.text ? parseIgnoreFile(cursorignore.text) : [],
11
+ });
12
+ const mcp = await parseProjectMcpConfigs(options.root, options.maxFileSizeBytes);
13
+ const instructionPaths = new Set();
14
+ for (const agent of options.agents) {
15
+ for (const file of agent.configFiles) {
16
+ instructionPaths.add(file.relativePath);
17
+ }
18
+ }
19
+ const instructionFiles = options.discovery.files.filter((f) => instructionPaths.has(f.relativePath));
20
+ return {
21
+ root: options.root,
22
+ repository: options.repository,
23
+ discovery: options.discovery,
24
+ agents: options.agents,
25
+ ignore,
26
+ mcpServers: mcp.servers,
27
+ textCache,
28
+ maxFileSizeBytes: options.maxFileSizeBytes,
29
+ instructionFiles,
30
+ };
31
+ }
@@ -0,0 +1,2 @@
1
+ import type { RuleDefinition } from "../types.js";
2
+ export declare const generatedDirectoryRule: RuleDefinition;
@@ -0,0 +1,89 @@
1
+ /** Directories commonly generated/build-related. vendor/ is ecosystem-aware. */
2
+ const GENERATED = [
3
+ { name: "dist", label: "build output" },
4
+ { name: "build", label: "build output" },
5
+ { name: "coverage", label: "test coverage" },
6
+ { name: ".next", label: "Next.js build cache" },
7
+ { name: ".nuxt", label: "Nuxt build cache" },
8
+ { name: ".svelte-kit", label: "SvelteKit build output" },
9
+ { name: ".dart_tool", label: "Dart tooling cache" },
10
+ { name: "target", label: "Rust/Java build output" },
11
+ ];
12
+ export const generatedDirectoryRule = {
13
+ id: "context/generated-directory",
14
+ title: "Generated directory may enter agent context",
15
+ description: "Notes common generated directories present in the tree when agent exclusion is unclear.",
16
+ category: "context",
17
+ severity: "info",
18
+ fixability: "safe",
19
+ rationale: "Generated artifacts are usually low-value context and can be large.",
20
+ recommendation: "Ensure generated directories are listed in .gitignore and agent ignore configuration where applicable.",
21
+ async check(context) {
22
+ const findings = [];
23
+ const skipped = new Set(context.discovery.directoriesSkipped.map((d) => d.split("/")[0] ?? d));
24
+ // directoriesSkipped stores relative paths like "dist" or "packages/app/dist"
25
+ const present = new Set();
26
+ for (const dir of context.discovery.directoriesSkipped) {
27
+ const top = dir.split("/")[0] ?? dir;
28
+ present.add(top);
29
+ present.add(dir);
30
+ }
31
+ void skipped;
32
+ const affected = context.agents.filter((a) => a.configured || a.detected).map((a) => a.id);
33
+ if (affected.length === 0) {
34
+ return [];
35
+ }
36
+ for (const entry of GENERATED) {
37
+ const found = present.has(entry.name) ||
38
+ [...present].some((p) => p === entry.name || p.endsWith(`/${entry.name}`));
39
+ if (!found) {
40
+ continue;
41
+ }
42
+ // If .gitignore or .cursorignore already exclude it, skip (already appropriately excluded)
43
+ const samplePath = `${entry.name}/`;
44
+ const excluded = context.ignore.matchesGitignore(samplePath) ||
45
+ context.ignore.matchesGitignore(entry.name) ||
46
+ context.ignore.matchesCursorignore(samplePath) ||
47
+ context.ignore.matchesCursorignore(entry.name);
48
+ if (excluded) {
49
+ continue;
50
+ }
51
+ // Discovery already skips these dirs by default — absence from ignore files is still useful info
52
+ // when agents are configured. Keep severity info.
53
+ findings.push({
54
+ ruleId: "context/generated-directory",
55
+ category: "context",
56
+ severity: "info",
57
+ title: "Generated directory may enter agent context",
58
+ message: `${entry.name}/ (${entry.label}) is present and no project ignore pattern was detected`,
59
+ whyItMatters: "Generated directories are usually low-value for coding agents and can bloat indexing/context when not excluded.",
60
+ recommendation: `Add ${entry.name}/ to .gitignore and, for Cursor, .cursorignore if you need agent-specific exclusion beyond gitignore.`,
61
+ affectedAgents: affected,
62
+ evidence: { path: entry.name },
63
+ fixability: "safe",
64
+ });
65
+ }
66
+ // vendor/ — only flag for non-PHP/Composer ecosystems
67
+ if ((present.has("vendor") ||
68
+ [...present].some((p) => p === "vendor" || p.endsWith("/vendor"))) &&
69
+ context.repository.primaryPackageManager !== "composer" &&
70
+ context.repository.primaryLanguage !== "php") {
71
+ const excluded = context.ignore.matchesGitignore("vendor/") || context.ignore.matchesCursorignore("vendor/");
72
+ if (!excluded) {
73
+ findings.push({
74
+ ruleId: "context/generated-directory",
75
+ category: "context",
76
+ severity: "info",
77
+ title: "Generated directory may enter agent context",
78
+ message: "vendor/ is present outside a PHP/Composer project and no ignore pattern was detected",
79
+ whyItMatters: "In non-PHP projects, vendor/ is often third-party or generated content that adds noise to agent context.",
80
+ recommendation: "Confirm whether vendor/ should be ignored for git and AI tooling.",
81
+ affectedAgents: affected,
82
+ evidence: { path: "vendor" },
83
+ fixability: "safe",
84
+ });
85
+ }
86
+ }
87
+ return findings;
88
+ },
89
+ };
@@ -0,0 +1,2 @@
1
+ import type { RuleDefinition } from "../types.js";
2
+ export declare const largeInstructionFileRule: RuleDefinition;
@@ -0,0 +1,56 @@
1
+ import { THRESHOLDS } from "../thresholds.js";
2
+ const INSTRUCTION_KINDS = new Set([
3
+ "cursor-rule-mdc",
4
+ "cursor-legacy-cursorrules",
5
+ "agents-md",
6
+ "agents-override-md",
7
+ "claude-md",
8
+ "claude-local-md",
9
+ "claude-rule-md",
10
+ ]);
11
+ export const largeInstructionFileRule = {
12
+ id: "context/large-instruction-file",
13
+ title: "Large instruction file",
14
+ description: "Flags unusually large AI instruction files that may inflate repeated context.",
15
+ category: "context",
16
+ severity: "warning",
17
+ fixability: "review",
18
+ rationale: "Large instruction files consume context on every session and can bury the most important guidance.",
19
+ recommendation: "Split into focused rule files, move rare procedures to docs/skills, and keep always-on instructions concise.",
20
+ async check(context) {
21
+ const findings = [];
22
+ const seen = new Set();
23
+ for (const agent of context.agents) {
24
+ for (const file of agent.configFiles) {
25
+ if (!INSTRUCTION_KINDS.has(file.kind)) {
26
+ continue;
27
+ }
28
+ if (seen.has(file.relativePath)) {
29
+ continue;
30
+ }
31
+ seen.add(file.relativePath);
32
+ if (file.sizeBytes < THRESHOLDS.instructionInfoBytes) {
33
+ continue;
34
+ }
35
+ const severity = file.sizeBytes >= THRESHOLDS.instructionWarningBytes ? "warning" : "info";
36
+ const kb = (file.sizeBytes / 1024).toFixed(0);
37
+ const affected = context.agents
38
+ .filter((a) => a.configPaths.includes(file.relativePath))
39
+ .map((a) => a.id);
40
+ findings.push({
41
+ ruleId: "context/large-instruction-file",
42
+ category: "context",
43
+ severity,
44
+ title: "Large instruction file",
45
+ message: `${file.relativePath} is ${kb} KB`,
46
+ whyItMatters: "Large instruction files may increase repeated context usage and make important instructions harder to prioritize. Exact token impact is not measured.",
47
+ recommendation: "Keep always-loaded instructions lean; split specialized guidance into scoped rules or linked docs.",
48
+ affectedAgents: affected.length > 0 ? affected : agent.id === "cursor" ? ["cursor"] : [agent.id],
49
+ evidence: { path: file.relativePath, detail: `${file.sizeBytes} bytes` },
50
+ fixability: "review",
51
+ });
52
+ }
53
+ }
54
+ return findings;
55
+ },
56
+ };
@@ -0,0 +1,2 @@
1
+ import type { RuleDefinition } from "../types.js";
2
+ export declare const largeLogFileRule: RuleDefinition;
@@ -0,0 +1,45 @@
1
+ import { THRESHOLDS } from "../thresholds.js";
2
+ const LOG_LIKE = /\.(log|out|dump)$/i;
3
+ const HEAVY_NAME = /(^|\/)(debug|trace|coverage-final|chrome-devtools|heapdump)/i;
4
+ export const largeLogFileRule = {
5
+ id: "context/large-log-file",
6
+ title: "Large log or dump file in repository",
7
+ description: "Detects large log/dump files that may unnecessarily enter agent context.",
8
+ category: "context",
9
+ severity: "info",
10
+ fixability: "safe",
11
+ rationale: "Large logs add noise and can push useful source code out of the context window.",
12
+ recommendation: "Delete or ignore large logs; add them to .gitignore and agent ignore files.",
13
+ async check(context) {
14
+ const findings = [];
15
+ const affected = context.agents.filter((a) => a.configured || a.detected).map((a) => a.id);
16
+ for (const file of context.discovery.files) {
17
+ const base = file.relativePath.split("/").pop() ?? file.relativePath;
18
+ const looksLog = LOG_LIKE.test(base) || HEAVY_NAME.test(file.relativePath);
19
+ if (!looksLog) {
20
+ continue;
21
+ }
22
+ if (file.sizeBytes < THRESHOLDS.largeLogBytes) {
23
+ continue;
24
+ }
25
+ // Skip if clearly ignored for Cursor and no other agents configured
26
+ if (context.ignore.matchesGitignore(file.relativePath) ||
27
+ context.ignore.matchesCursorignore(file.relativePath)) {
28
+ continue;
29
+ }
30
+ findings.push({
31
+ ruleId: "context/large-log-file",
32
+ category: "context",
33
+ severity: "info",
34
+ title: "Large log or dump file in repository",
35
+ message: `${file.relativePath} is ${(file.sizeBytes / 1024).toFixed(0)} KB`,
36
+ whyItMatters: "Large logs and dumps rarely help coding agents and can crowd out useful source context.",
37
+ recommendation: "Remove or ignore this file for both git and AI agent tooling.",
38
+ affectedAgents: affected.length > 0 ? affected : ["cursor", "claude-code", "codex"],
39
+ evidence: { path: file.relativePath, detail: `${file.sizeBytes} bytes` },
40
+ fixability: "safe",
41
+ });
42
+ }
43
+ return findings;
44
+ },
45
+ };
@@ -0,0 +1,13 @@
1
+ import type { Finding } from "../../types/index.js";
2
+ import type { FindingDraft } from "./types.js";
3
+ /**
4
+ * Merge findings that share the same rule + evidence path into one finding
5
+ * with unioned affected agents. Distinct problems are not merged.
6
+ */
7
+ export declare function dedupeFindings(drafts: FindingDraft[]): Finding[];
8
+ export declare function summarizeFindings(findings: Finding[]): {
9
+ critical: number;
10
+ warning: number;
11
+ info: number;
12
+ total: number;
13
+ };
@@ -0,0 +1,53 @@
1
+ import { finalizeFinding } from "./types.js";
2
+ /**
3
+ * Merge findings that share the same rule + evidence path into one finding
4
+ * with unioned affected agents. Distinct problems are not merged.
5
+ */
6
+ export function dedupeFindings(drafts) {
7
+ const map = new Map();
8
+ for (const draft of drafts) {
9
+ const pathKey = (draft.evidence?.path ?? "repo").replace(/\\/g, "/");
10
+ const detailKey = (draft.evidence?.detail ?? "").replace(/\\/g, "/");
11
+ const key = `${draft.ruleId}::${pathKey}::${detailKey}::${draft.title}`;
12
+ const existing = map.get(key);
13
+ if (!existing) {
14
+ const next = {
15
+ ...draft,
16
+ affectedAgents: [...draft.affectedAgents],
17
+ };
18
+ if (draft.evidence) {
19
+ next.evidence = { ...draft.evidence };
20
+ }
21
+ map.set(key, next);
22
+ continue;
23
+ }
24
+ existing.affectedAgents = [
25
+ ...new Set([...existing.affectedAgents, ...draft.affectedAgents]),
26
+ ].sort();
27
+ // Prefer higher severity if somehow duplicated with different severities
28
+ const rank = { critical: 3, warning: 2, info: 1 };
29
+ if (rank[draft.severity] > rank[existing.severity]) {
30
+ existing.severity = draft.severity;
31
+ existing.message = draft.message;
32
+ existing.whyItMatters = draft.whyItMatters;
33
+ if (draft.recommendation !== undefined) {
34
+ existing.recommendation = draft.recommendation;
35
+ }
36
+ }
37
+ }
38
+ return [...map.values()].map(finalizeFinding).sort((a, b) => {
39
+ const severityRank = { critical: 0, warning: 1, info: 2 };
40
+ const sev = severityRank[a.severity] - severityRank[b.severity];
41
+ if (sev !== 0)
42
+ return sev;
43
+ return a.id.localeCompare(b.id);
44
+ });
45
+ }
46
+ export function summarizeFindings(findings) {
47
+ return {
48
+ critical: findings.filter((f) => f.severity === "critical").length,
49
+ warning: findings.filter((f) => f.severity === "warning").length,
50
+ info: findings.filter((f) => f.severity === "info").length,
51
+ total: findings.length,
52
+ };
53
+ }