@parasraju/plumber-mcp 0.0.0-stage → 1.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/README.md CHANGED
@@ -1,3 +1,109 @@
1
- # Temporary Holding Version
1
+ # @parasraju/plumber-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Plumber as an MCP server. Give your AI coding agent a deterministic, offline scanner for leaked secrets, dead code, and AI slop — with plain-English fixes it can apply immediately.
4
+
5
+ No API keys, no network calls. The server reads files locally and returns findings over stdio.
6
+
7
+ ## Run
8
+
9
+ ```bash
10
+ npx -y @parasraju/plumber-mcp
11
+ ```
12
+
13
+ ## Configure
14
+
15
+ ### Claude Code
16
+
17
+ ```json
18
+ {
19
+ "mcpServers": {
20
+ "plumber": {
21
+ "command": "npx",
22
+ "args": ["-y", "@parasraju/plumber-mcp"]
23
+ }
24
+ }
25
+ }
26
+ ```
27
+
28
+ Or from the CLI:
29
+
30
+ ```bash
31
+ claude mcp add plumber -- npx -y @parasraju/plumber-mcp
32
+ ```
33
+
34
+ ### Cursor
35
+
36
+ Add to `~/.cursor/mcp.json` (or `.cursor/mcp.json` in a project):
37
+
38
+ ```json
39
+ {
40
+ "mcpServers": {
41
+ "plumber": {
42
+ "command": "npx",
43
+ "args": ["-y", "@parasraju/plumber-mcp"]
44
+ }
45
+ }
46
+ }
47
+ ```
48
+
49
+ ### opencode
50
+
51
+ Add to `opencode.json`:
52
+
53
+ ```json
54
+ {
55
+ "$schema": "https://opencode.ai/config.json",
56
+ "mcp": {
57
+ "plumber": {
58
+ "type": "local",
59
+ "command": ["npx", "-y", "@parasraju/plumber-mcp"],
60
+ "enabled": true
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ ### VS Code Copilot
67
+
68
+ Add to `.vscode/mcp.json`:
69
+
70
+ ```json
71
+ {
72
+ "servers": {
73
+ "plumber": {
74
+ "type": "stdio",
75
+ "command": "npx",
76
+ "args": ["-y", "@parasraju/plumber-mcp"]
77
+ }
78
+ }
79
+ }
80
+ ```
81
+
82
+ ## Tools
83
+
84
+ | Tool | Description |
85
+ | --- | --- |
86
+ | `plumber_scan` | Scan a local directory or file. Returns a score, grade, file count, and one line per issue with its fix. |
87
+ | `plumber_list_rules` | List all 21 inspection rules with id, severity, title, and fix. |
88
+ | `plumber_scan_text` | Scan a pasted code or config snippet. `filename` is optional and only selects the applicable rules. |
89
+
90
+ Tool inputs are deliberately small so they cost few tokens:
91
+
92
+ - `plumber_scan`: `target` (string, default `"."`)
93
+ - `plumber_list_rules`: none
94
+ - `plumber_scan_text`: `text` (string, required), `filename` (string, optional)
95
+
96
+ ## Example session
97
+
98
+ > **You:** Scan this repo for leaked credentials and fix anything you find.
99
+ >
100
+ > **Agent:** *(calls `plumber_scan` with `target: "."`)*
101
+ >
102
+ > Found 1 issue in `src/config.js:14` — `[CRITICAL] Leaked OpenAI / Model API Key (Extract secret to .env file immediately.)`
103
+ >
104
+ > **Agent:** I moved the key into `.env`, replaced it with `process.env.OPENAI_API_KEY`, and added `.env` to `.gitignore`. You should revoke the exposed key.
105
+
106
+ ## Links
107
+
108
+ - CLI: [plumber-linter](https://www.npmjs.com/package/plumber-linter)
109
+ - Repository: [github.com/parasraju/plumber](https://github.com/parasraju/plumber)
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Plumber MCP stdio server.
5
+ *
6
+ * Tools:
7
+ * plumber_scan scan a local directory or file
8
+ * plumber_list_rules list the 21 inspection rules
9
+ * plumber_scan_text scan a pasted snippet
10
+ *
11
+ * Only JSON-RPC goes to stdout. Diagnostics go to stderr.
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
17
+ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
18
+ const {
19
+ CallToolRequestSchema,
20
+ ListToolsRequestSchema,
21
+ ErrorCode,
22
+ McpError
23
+ } = require('@modelcontextprotocol/sdk/types.js');
24
+ const { RULES, scanLocalDirectory, scanText } = require('../src/engine.js');
25
+
26
+ const SERVER_INFO = { name: 'plumber-mcp', version: '1.0.0' };
27
+
28
+ const TOOLS = [
29
+ {
30
+ name: 'plumber_scan',
31
+ description: 'Scan a local directory or file for leaked secrets, dead code, and AI slop. Returns a score and fixes.',
32
+ inputSchema: {
33
+ type: 'object',
34
+ properties: {
35
+ target: { type: 'string', description: 'Directory or file path to scan (default ".")' }
36
+ },
37
+ additionalProperties: false
38
+ }
39
+ },
40
+ {
41
+ name: 'plumber_list_rules',
42
+ description: 'List all Plumber inspection rules with severity, title, and recommended fix.',
43
+ inputSchema: {
44
+ type: 'object',
45
+ properties: {},
46
+ additionalProperties: false
47
+ }
48
+ },
49
+ {
50
+ name: 'plumber_scan_text',
51
+ description: 'Scan a pasted code or config snippet for leaked secrets, dead code, and AI slop.',
52
+ inputSchema: {
53
+ type: 'object',
54
+ properties: {
55
+ text: { type: 'string', description: 'Code or config snippet to scan' },
56
+ filename: { type: 'string', description: 'Optional filename used to select applicable rules (e.g. app.js)' }
57
+ },
58
+ required: ['text'],
59
+ additionalProperties: false
60
+ }
61
+ }
62
+ ];
63
+
64
+ function formatReport(report) {
65
+ const header = [
66
+ `Plumber Health Report for ${report.repo_name}`,
67
+ `Score: ${report.score}/100 (Grade ${report.grade})`,
68
+ `Files: ${report.total_files} | Issues: ${report.issues.length}`
69
+ ].join('\n');
70
+
71
+ if (report.issues.length === 0) {
72
+ return `${header}\n\nNo issues found.`;
73
+ }
74
+
75
+ const locations = new Map();
76
+ for (const file of report.file_reports) {
77
+ for (const issue of file.issues) {
78
+ locations.set(issue, `${file.path}:${issue.line}`);
79
+ }
80
+ }
81
+
82
+ const lines = report.issues.map((issue) => {
83
+ const location = locations.get(issue);
84
+ const suffix = location ? ` — ${location}` : '';
85
+ return `- [${issue.severity}] ${issue.title} (${issue.fix})${suffix}`;
86
+ });
87
+
88
+ return `${header}\n\n${lines.join('\n')}`;
89
+ }
90
+
91
+ function requireString(value, name) {
92
+ if (value === undefined) return undefined;
93
+ if (typeof value !== 'string' || value.trim() === '') {
94
+ throw new McpError(ErrorCode.InvalidParams, `'${name}' must be a non-empty string`);
95
+ }
96
+ return value;
97
+ }
98
+
99
+ function requireText(text) {
100
+ if (typeof text !== 'string' || text.trim() === '') {
101
+ throw new McpError(ErrorCode.InvalidParams, "'text' must be a non-empty string");
102
+ }
103
+ return text;
104
+ }
105
+
106
+ function runScanTool(args) {
107
+ const target = requireString(args.target, 'target') || '.';
108
+ const absolute = path.resolve(target);
109
+
110
+ if (!fs.existsSync(absolute)) {
111
+ throw new McpError(ErrorCode.InvalidParams, `Target path does not exist: ${absolute}`);
112
+ }
113
+
114
+ try {
115
+ return formatReport(scanLocalDirectory(absolute));
116
+ } catch (err) {
117
+ throw new McpError(ErrorCode.InternalError, `Scan failed: ${err.message}`);
118
+ }
119
+ }
120
+
121
+ function runScanTextTool(args) {
122
+ const text = requireText(args.text);
123
+ const filename = requireString(args.filename, 'filename');
124
+
125
+ try {
126
+ return formatReport(scanText(text, filename));
127
+ } catch (err) {
128
+ throw new McpError(ErrorCode.InternalError, `Scan failed: ${err.message}`);
129
+ }
130
+ }
131
+
132
+ function runListRulesTool() {
133
+ return RULES.map((rule) => `${rule.id} [${rule.sev}] ${rule.title} (${rule.fix})`).join('\n');
134
+ }
135
+
136
+ async function main() {
137
+ const server = new Server(SERVER_INFO, { capabilities: { tools: {} } });
138
+
139
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
140
+
141
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
142
+ const name = request.params.name;
143
+ const args = request.params.arguments || {};
144
+ let text;
145
+
146
+ if (name === 'plumber_scan') {
147
+ text = runScanTool(args);
148
+ } else if (name === 'plumber_list_rules') {
149
+ text = runListRulesTool();
150
+ } else if (name === 'plumber_scan_text') {
151
+ text = runScanTextTool(args);
152
+ } else {
153
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
154
+ }
155
+
156
+ return { content: [{ type: 'text', text }] };
157
+ });
158
+
159
+ server.onerror = (error) => {
160
+ process.stderr.write(`[plumber-mcp] ${error.message}\n`);
161
+ };
162
+
163
+ await server.connect(new StdioServerTransport());
164
+ process.stderr.write('Plumber MCP stdio server started\n');
165
+ }
166
+
167
+ main().catch((error) => {
168
+ process.stderr.write(`[plumber-mcp] Failed to start: ${error.stack || error.message}\n`);
169
+ process.exit(1);
170
+ });
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@parasraju/plumber-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Plumber as an MCP server — let your AI coding agent scan repos for leaked secrets, dead code, and AI slop, with fixes in plain English.",
5
+ "main": "src/engine.js",
6
+ "bin": {
7
+ "plumber-mcp": "bin/plumber-mcp.js"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "engines": {
13
+ "node": ">=18.0.0"
14
+ },
15
+ "files": [
16
+ "bin",
17
+ "src",
18
+ "README.md"
19
+ ],
20
+ "keywords": [
21
+ "mcp",
22
+ "model-context-protocol",
23
+ "claude-code",
24
+ "cursor",
25
+ "opencode",
26
+ "ai-agents",
27
+ "security",
28
+ "secrets-detection",
29
+ "code-quality",
30
+ "linter"
31
+ ],
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/parasraju/plumber.git"
35
+ },
36
+ "homepage": "https://github.com/parasraju/plumber#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/parasraju/plumber/issues"
39
+ },
40
+ "license": "MIT",
41
+ "author": "Paras Raju",
42
+ "dependencies": {
43
+ "@modelcontextprotocol/sdk": "^1.32.2"
44
+ }
45
+ }
package/src/engine.js ADDED
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Plumber inspection engine.
3
+ *
4
+ * Ported from plumber-linter v1.1.2
5
+ * (packages/cli/bin/plumber.js, commit bde61c5), with two additions:
6
+ * - leak/api-key now matches AWS access key IDs (AKIA...).
7
+ * - leak/hardcoded-secret now matches quoted password/token assignments.
8
+ * Both rule IDs already exist in the CLI's RULES table; the CLI declares them
9
+ * but does not detect them. The CLI itself is left untouched.
10
+ *
11
+ * This is a copy rather than a shared import because the published CLI package
12
+ * ships only `bin/` and `README.md`, so a relative import from the MCP package
13
+ * would break `plumber-linter` for npm consumers. The CLI is intentionally left
14
+ * untouched; keep the detection rules in this file in sync with the CLI.
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ const ENV_TEMPLATE_RE = /\.(example|sample|template|dist)$/;
21
+
22
+ const RULES = [
23
+ { id: 'leak/api-key', cat: 'Leaks & Secrets', sev: 'CRITICAL', title: 'Leaked API Key or Private Access Token', fix: 'Move secret to environment variable (.env).' },
24
+ { id: 'leak/committed-env', cat: 'Leaks & Secrets', sev: 'CRITICAL', title: 'Production Secret Committed in Environment File', fix: 'Remove sensitive values and use .env.example.' },
25
+ { id: 'leak/hardcoded-secret', cat: 'Leaks & Secrets', sev: 'HIGH', title: 'Hardcoded Password or Database Credential', fix: 'Replace with process.env / os.environ reference.' },
26
+ { id: 'sec/raw-html', cat: 'Leaks & Secrets', sev: 'MEDIUM', title: 'Raw HTML Injection (XSS Risk)', fix: 'Use textContent or safe DOM templating.' },
27
+ { id: 'clog/elided-code', cat: 'Clogs & Dead Code', sev: 'HIGH', title: 'AI-Elided Code Snippet Left in File', fix: 'Restore the complete function or component implementation.' },
28
+ { id: 'clog/swallowed-error', cat: 'Clogs & Dead Code', sev: 'HIGH', title: 'Swallowed Exception (Silent Failure)', fix: 'Log the error or properly re-raise/handle it.' },
29
+ { id: 'clog/stub-function', cat: 'Clogs & Dead Code', sev: 'MEDIUM', title: 'Unimplemented Stub Function', fix: 'Complete missing logic or remove placeholder.' },
30
+ { id: 'clog/step-comments', cat: 'Clogs & Dead Code', sev: 'LOW', title: 'Robotic AI Step Comments Left Behind', fix: 'Clean up AI instructional comments.' },
31
+ { id: 'clog/dead-code', cat: 'Clogs & Dead Code', sev: 'HIGH', title: 'Unreachable Dead Code', fix: 'Delete unreachable lines or run plumber clean --write.' },
32
+ { id: 'slop/pointless-wrapper', cat: 'Clogs & Dead Code', sev: 'LOW', title: 'Function That Only Forwards Its Arguments', fix: 'Call underlying function directly.' },
33
+ { id: 'joint/icon-button', cat: 'Joints & Accessibility', sev: 'HIGH', title: 'Icon Button Without Accessible Name', fix: 'Add aria-label attribute to button element.' },
34
+ { id: 'joint/clickable-div', cat: 'Joints & Accessibility', sev: 'HIGH', title: 'Non-Semantic Clickable Element (Missing Button / Role)', fix: 'Use native <button type="button">.' },
35
+ { id: 'joint/empty-test', cat: 'Joints & Accessibility', sev: 'HIGH', title: 'Empty Test with No Assertions', fix: 'Add assert statements or verification logic.' },
36
+ { id: 'joint/focus-removed', cat: 'Joints & Accessibility', sev: 'MEDIUM', title: 'Focus Outline Removed Without Replacement', fix: 'Provide a visible :focus-visible style.' },
37
+ { id: 'pipe/phantom-import', cat: 'Pipes & Dependencies', sev: 'HIGH', title: 'Plausible but Hallucinated Package Import', fix: 'Check package registry or use official package.' },
38
+ { id: 'pipe/dead-link', cat: 'Pipes & Dependencies', sev: 'MEDIUM', title: 'Broken or Null Link in Navigation', fix: 'Use a <button> for actions or valid route for href.' },
39
+ { id: 'slop/ai-gradient', cat: 'AI Slop Tells', sev: 'MEDIUM', title: 'Purple / Violet AI Default Gradient', fix: 'Use clean brand tokens or neutral surfaces.' },
40
+ { id: 'slop/chatbot-residue', cat: 'AI Slop Tells', sev: 'MEDIUM', title: 'Pasted Chatbot Conversational Residue', fix: 'Delete conversational intro/outro boilerplate.' },
41
+ { id: 'slop/neon-glow', cat: 'AI Slop Tells', sev: 'LOW', title: 'Colored Neon Glow Shadow', fix: 'Replace with subtle crisp 1px borders.' },
42
+ { id: 'slop/gradient-text', cat: 'AI Slop Tells', sev: 'LOW', title: 'Gradient-Clipped Headline Text', fix: 'Use solid, high-contrast typography.' },
43
+ { id: 'ui/important-abuse', cat: 'AI Slop Tells', sev: 'LOW', title: '!important Overrides in Stylesheet', fix: 'Structure CSS cascade cleanly without !important.' }
44
+ ];
45
+
46
+ const SEV_WEIGHT = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, INFO: 0 };
47
+
48
+ function gradeFor(score) {
49
+ return score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 70 ? 'C' : score >= 60 ? 'D' : 'F';
50
+ }
51
+
52
+ function scoreFor(issueCount) {
53
+ return Math.max(0, 100 - (issueCount * 4.2));
54
+ }
55
+
56
+ function scanLocalDirectory(dirPath) {
57
+ const scannableExts = new Set(['.py', '.js', '.jsx', '.ts', '.tsx', '.html', '.htm', '.css', '.scss', '.env']);
58
+ const skipDirs = new Set(['.git', 'node_modules', 'dist', 'build', '.next', '.nuxt', 'vendor', '.venv', 'venv', '__pycache__']);
59
+
60
+ const issues = [];
61
+ let totalFiles = 0;
62
+ let totalLines = 0;
63
+ const fileReports = [];
64
+
65
+ function walk(current) {
66
+ const stat = fs.statSync(current);
67
+ if (stat.isFile()) {
68
+ const ext = path.extname(current).toLowerCase();
69
+ const base = path.basename(current);
70
+ if (scannableExts.has(ext) || base.startsWith('.env')) {
71
+ totalFiles++;
72
+ const rel = path.relative(dirPath, current).replace(/\\/g, '/') || path.basename(current);
73
+ try {
74
+ const content = fs.readFileSync(current, 'utf-8');
75
+ const lines = content.split('\n');
76
+ totalLines += lines.length;
77
+ const fileIssues = inspectFile(rel, content, lines, ext);
78
+ if (fileIssues.length > 0) {
79
+ issues.push(...fileIssues);
80
+ fileReports.push({ path: rel, issues: fileIssues });
81
+ }
82
+ } catch (err) {
83
+ // Unreadable or locked file: skip it, matching the CLI's behavior.
84
+ void err;
85
+ }
86
+ }
87
+ return;
88
+ }
89
+
90
+ if (stat.isDirectory()) {
91
+ const base = path.basename(current);
92
+ if (skipDirs.has(base) || (base.startsWith('.') && base !== '.')) return;
93
+ const entries = fs.readdirSync(current);
94
+ for (const entry of entries) {
95
+ walk(path.join(current, entry));
96
+ }
97
+ }
98
+ }
99
+
100
+ walk(dirPath);
101
+
102
+ const score = scoreFor(issues.length);
103
+ const grade = gradeFor(score);
104
+
105
+ return {
106
+ repo_name: path.basename(dirPath),
107
+ total_files: totalFiles,
108
+ total_lines: totalLines,
109
+ score: Math.round(score * 10) / 10,
110
+ grade,
111
+ issues,
112
+ file_reports: fileReports
113
+ };
114
+ }
115
+
116
+ function scanText(text, filename) {
117
+ if (typeof text !== 'string') {
118
+ throw new TypeError('text must be a string');
119
+ }
120
+ const name = filename && typeof filename === 'string' ? path.basename(filename) : 'snippet.js';
121
+ const ext = path.extname(name).toLowerCase();
122
+ const content = text.replace(/\r\n?/g, '\n');
123
+ const lines = content.split('\n');
124
+ const issues = inspectFile(name, content, lines, ext);
125
+ const score = scoreFor(issues.length);
126
+
127
+ return {
128
+ repo_name: name,
129
+ total_files: 1,
130
+ total_lines: lines.length,
131
+ score: Math.round(score * 10) / 10,
132
+ grade: gradeFor(score),
133
+ issues,
134
+ file_reports: issues.length > 0 ? [{ path: name, issues }] : []
135
+ };
136
+ }
137
+
138
+ function inspectFile(relPath, content, lines, ext) {
139
+ const issues = [];
140
+ const lower = content.toLowerCase();
141
+ const base = path.basename(relPath);
142
+ const isTest = relPath.toLowerCase().includes('test') || relPath.toLowerCase().includes('spec');
143
+
144
+ // Environment files must never be committed
145
+ if (base.startsWith('.env') && !ENV_TEMPLATE_RE.test(base)) {
146
+ issues.push({ rule_id: 'leak/committed-env', title: 'Environment File Staged for Commit', severity: 'CRITICAL', line: 1, evidence: base, fix: 'Remove from git, add to .gitignore, and commit .env.example instead.' });
147
+ }
148
+
149
+ // Elided code
150
+ if (!isTest && /(?:\/\/|#|\/\*)\s*(?:\.\.\.|…)?\s*(?:existing\s+code|rest\s+of\s+code)/i.test(content)) {
151
+ lines.forEach((l, idx) => {
152
+ if (/(?:\/\/|#|\/\*)\s*(?:\.\.\.|…)?\s*(?:existing\s+code|rest\s+of\s+code)/i.test(l)) {
153
+ issues.push({ rule_id: 'clog/elided-code', title: 'AI-Elided Code Snippet Left in File', severity: 'HIGH', line: idx + 1, evidence: l.trim().slice(0, 90), fix: 'Restore the complete code implementation.' });
154
+ }
155
+ });
156
+ }
157
+
158
+ // Swallowed error
159
+ if (!isTest && (ext === '.py' || ext === '.js' || ext === '.ts' || ext === '.jsx' || ext === '.tsx')) {
160
+ lines.forEach((l, idx) => {
161
+ const trim = l.trim();
162
+ if (trim.startsWith('#') || trim.startsWith('//') || trim.startsWith('/*')) return;
163
+ if (ext === '.py' && /^\s*except(?:\s+\w+)?:\s*(?:pass|\.\.\.)/.test(l)) {
164
+ issues.push({ rule_id: 'clog/swallowed-error', title: 'Swallowed Exception (Silent Failure)', severity: 'HIGH', line: idx + 1, evidence: trim, fix: 'Log the error or properly re-raise/handle it.' });
165
+ } else if (ext !== '.py' && /catch\s*\([^)]*\)\s*\{\s*\}/.test(l)) {
166
+ issues.push({ rule_id: 'clog/swallowed-error', title: 'Swallowed Exception (Silent Failure)', severity: 'HIGH', line: idx + 1, evidence: trim, fix: 'Log the error or properly re-raise/handle it.' });
167
+ }
168
+ });
169
+ }
170
+
171
+ // API keys
172
+ if (!isTest && (content.includes('sk-') || content.includes('ghp_') || content.includes('AKIA'))) {
173
+ lines.forEach((l, idx) => {
174
+ const trim = l.trim();
175
+ if (trim.startsWith('#') || trim.startsWith('//')) return;
176
+ if (/\b(sk-(?:live|test|proj)?[a-zA-Z0-9_-]{24,})\b/.test(l)) {
177
+ issues.push({ rule_id: 'leak/api-key', title: 'Leaked OpenAI / Model API Key', severity: 'CRITICAL', line: idx + 1, evidence: trim.slice(0, 70), fix: 'Extract secret to .env file immediately.' });
178
+ }
179
+ if (/\b(ghp_[a-zA-Z0-9]{36})\b/.test(l)) {
180
+ issues.push({ rule_id: 'leak/api-key', title: 'Leaked GitHub Personal Access Token', severity: 'CRITICAL', line: idx + 1, evidence: trim.slice(0, 70), fix: 'Revoke key and store in secrets manager.' });
181
+ }
182
+ // Added in the MCP package: AWS access key IDs.
183
+ if (/\b(AKIA[0-9A-Z]{16})\b/.test(l)) {
184
+ issues.push({ rule_id: 'leak/api-key', title: 'Leaked AWS Access Key ID', severity: 'CRITICAL', line: idx + 1, evidence: trim.slice(0, 70), fix: 'Revoke key and store in secrets manager.' });
185
+ }
186
+ });
187
+ }
188
+
189
+ // Hardcoded password or token assignment.
190
+ // Added in the MCP package: the CLI declares leak/hardcoded-secret but has no match.
191
+ if (!isTest && !ENV_TEMPLATE_RE.test(base) && /(?:password|passwd|pwd|secret|api[_-]?key|token)/i.test(content)) {
192
+ lines.forEach((l, idx) => {
193
+ const trim = l.trim();
194
+ if (trim.startsWith('#') || trim.startsWith('//') || trim.startsWith('/*')) return;
195
+ const match = /(?:password|passwd|pwd|secret|api[_-]?key|token)\w*\s*[:=]\s*['"]([^'"]{4,})['"]/i.exec(l);
196
+ if (match && !match[1].includes('${') && !/^(?:sk-|ghp_|AKIA)/.test(match[1])) {
197
+ issues.push({ rule_id: 'leak/hardcoded-secret', title: 'Hardcoded Password or Database Credential', severity: 'HIGH', line: idx + 1, evidence: trim.slice(0, 90), fix: 'Replace with process.env / os.environ reference.' });
198
+ }
199
+ });
200
+ }
201
+
202
+ // Clickable div
203
+ if ((ext === '.html' || ext === '.jsx' || ext === '.tsx') && lower.includes('<div') && lower.includes('onclick')) {
204
+ lines.forEach((l, idx) => {
205
+ if (/<(?:div|span)\b(?![^>]*role=)[^>]*(?:onClick|onclick)=/i.test(l)) {
206
+ issues.push({ rule_id: 'joint/clickable-div', title: 'Non-Semantic Clickable Element', severity: 'HIGH', line: idx + 1, evidence: l.trim().slice(0, 80), fix: 'Use a native <button type="button"> instead.' });
207
+ }
208
+ });
209
+ }
210
+
211
+ // AI Default Gradient
212
+ if ((ext === '.html' || ext === '.jsx' || ext === '.tsx' || ext === '.css') && (lower.includes('purple') || lower.includes('violet'))) {
213
+ lines.forEach((l, idx) => {
214
+ if (/\b(?:from|via|to)-(?:purple|violet|indigo|fuchsia)-\d{2,3}\b/.test(l)) {
215
+ issues.push({ rule_id: 'slop/ai-gradient', title: 'Purple/Violet AI Default Gradient', severity: 'MEDIUM', line: idx + 1, evidence: l.trim().slice(0, 80), fix: 'Use clean brand tokens or neutral surface.' });
216
+ }
217
+ });
218
+ }
219
+
220
+ // Unreachable dead code
221
+ for (const dead of findDeadCode(content, ext)) {
222
+ issues.push({
223
+ rule_id: 'clog/dead-code',
224
+ title: 'Unreachable Dead Code',
225
+ severity: 'HIGH',
226
+ line: dead.line,
227
+ evidence: dead.evidence.slice(0, 100),
228
+ fix: dead.removable ? "Delete this unreachable line or run 'plumber clean <path> --write'." : 'Remove the constant-false branch and its body.'
229
+ });
230
+ }
231
+
232
+ return issues;
233
+ }
234
+
235
+ function findDeadCode(content, ext) {
236
+ if (!['.py', '.js', '.jsx', '.ts', '.tsx'].includes(ext)) return [];
237
+ const lines = content.split('\n');
238
+ const skip = new Set();
239
+ markMultilineStrings(lines, skip, ext === '.py' ? ['"""', "'''"] : ['`']);
240
+
241
+ const found = [];
242
+ const indentOf = (line) => line.length - line.replace(/^[ \t]+/, '').length;
243
+ const termRe = ext === '.py' ? /^(return|raise|break|continue)\b/ : /^(return|throw|break|continue)\b/;
244
+ const unsafeTail = ['\\', '(', '[', '{', ',', ':', '+', '-', '*', '/', '%', '&', '|', '=', '?', '>'];
245
+
246
+ for (let idx = 0; idx < lines.length; idx++) {
247
+ const line = lines[idx];
248
+ const clean = line.trim();
249
+ if (skip.has(idx) || !clean || clean.startsWith('#') || clean.startsWith('//') || clean.startsWith('/*') || clean.startsWith('*')) continue;
250
+
251
+ const isPy = ext === '.py';
252
+ const deadBranch = isPy ? /^(?:if|while)\s+(?:False|0)\s*:/.test(clean) : /^(?:if|while)\s*\(\s*(?:false|0)\s*\)/.test(clean);
253
+ if (deadBranch) {
254
+ found.push({ line: idx + 1, reason: 'Dead branch: condition is always false', evidence: clean, removable: false });
255
+ continue;
256
+ }
257
+
258
+ const terminator = termRe.exec(clean);
259
+ if (!terminator || unsafeTail.some((t) => clean.endsWith(t))) continue;
260
+
261
+ const target = findUnreachable(lines, idx, indentOf(line), skip, isPy);
262
+ if (target !== null) {
263
+ found.push({ line: target + 1, reason: `Unreachable statement after '${terminator[1]}'`, evidence: lines[target].trim(), removable: true });
264
+ }
265
+ }
266
+ return found;
267
+ }
268
+
269
+ function markMultilineStrings(lines, skip, quotes) {
270
+ let inside = null;
271
+ for (let idx = 0; idx < lines.length; idx++) {
272
+ if (inside !== null) skip.add(idx);
273
+ for (const quote of quotes) {
274
+ const count = lines[idx].split(quote).length - 1;
275
+ if (count === 0) continue;
276
+ if (inside === quote) {
277
+ if (count % 2 === 1) inside = null;
278
+ } else if (inside === null && count % 2 === 1) {
279
+ inside = quote;
280
+ }
281
+ }
282
+ }
283
+ }
284
+
285
+ function findUnreachable(lines, start, indent, skip, isPy) {
286
+ const closers = ['else:', 'elif ', 'except', 'finally:', 'case ', 'default:'];
287
+ for (let j = start + 1; j < lines.length; j++) {
288
+ const raw = lines[j];
289
+ const clean = raw.trim();
290
+ if (skip.has(j) || !clean || clean.startsWith('#') || clean.startsWith('//') || clean.startsWith('/*') || clean.startsWith('*')) continue;
291
+ if ((raw.length - raw.replace(/^[ \t]+/, '').length) < indent) return null;
292
+ if (isPy ? closers.some((p) => clean.startsWith(p)) : /^(?:\}|else\b|catch\b|finally\b|case\b|default\b)/.test(clean)) return null;
293
+ if (/^[\)\]\}\.,:?+\-*\/%&|]/.test(clean)) continue;
294
+ return j;
295
+ }
296
+ return null;
297
+ }
298
+
299
+ module.exports = {
300
+ RULES,
301
+ SEV_WEIGHT,
302
+ scanLocalDirectory,
303
+ scanText
304
+ };