@chappibunny/repolens 1.11.0 → 1.12.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.
@@ -0,0 +1,354 @@
1
+ // JSDoc/TSDoc extraction for API Surface enrichment
2
+ // Parses @param, @returns, @deprecated, @example, @description tags
3
+
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { info } from "../utils/logger.js";
7
+
8
+ const JS_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"];
9
+
10
+ /**
11
+ * Pattern to match JSDoc/TSDoc block comments
12
+ * Captures the entire comment block including * prefixes
13
+ */
14
+ const JSDOC_BLOCK = /\/\*\*[\s\S]*?\*\//g;
15
+
16
+ /**
17
+ * Pattern to match exported functions (named exports and default exports)
18
+ * Captures function name and parameter list
19
+ */
20
+ const EXPORT_FUNCTION = /export\s+(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/g;
21
+ const EXPORT_CONST_ARROW = /export\s+const\s+(\w+)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>/g;
22
+ const EXPORT_DEFAULT_FUNCTION = /export\s+default\s+(?:async\s+)?function(?:\s+(\w+))?\s*\(([^)]*)\)/g;
23
+
24
+ /**
25
+ * Parse a JSDoc block into structured tags
26
+ */
27
+ function parseJSDocBlock(block) {
28
+ const result = {
29
+ description: "",
30
+ params: [],
31
+ returns: null,
32
+ deprecated: null,
33
+ examples: [],
34
+ throws: [],
35
+ see: [],
36
+ since: null,
37
+ };
38
+
39
+ // Remove comment markers and normalize
40
+ const lines = block
41
+ .replace(/^\/\*\*/, "")
42
+ .replace(/\*\/$/, "")
43
+ .split("\n")
44
+ .map(line => line.replace(/^\s*\*\s?/, "").trim())
45
+ .filter(line => line.length > 0);
46
+
47
+ let currentTag = null;
48
+ let currentValue = [];
49
+
50
+ function flushTag() {
51
+ if (!currentTag) {
52
+ // If no tag yet, this is the description
53
+ if (currentValue.length > 0) {
54
+ result.description = currentValue.join(" ").trim();
55
+ }
56
+ } else {
57
+ const value = currentValue.join(" ").trim();
58
+
59
+ switch (currentTag) {
60
+ case "param":
61
+ case "arg":
62
+ case "argument": {
63
+ // Parse @param {Type} name - description
64
+ const paramMatch = value.match(/^(?:\{([^}]+)\}\s*)?(\[?[\w.]+\]?)\s*(?:-\s*)?(.*)$/);
65
+ if (paramMatch) {
66
+ result.params.push({
67
+ type: paramMatch[1] || "any",
68
+ name: paramMatch[2].replace(/^\[|\]$/g, ""), // Remove optional brackets
69
+ optional: paramMatch[2].startsWith("["),
70
+ description: paramMatch[3] || "",
71
+ });
72
+ }
73
+ break;
74
+ }
75
+ case "returns":
76
+ case "return": {
77
+ // Parse @returns {Type} description
78
+ const returnMatch = value.match(/^(?:\{([^}]+)\}\s*)?(.*)$/);
79
+ if (returnMatch) {
80
+ result.returns = {
81
+ type: returnMatch[1] || "void",
82
+ description: returnMatch[2] || "",
83
+ };
84
+ }
85
+ break;
86
+ }
87
+ case "deprecated":
88
+ result.deprecated = value || "This function is deprecated.";
89
+ break;
90
+ case "example":
91
+ result.examples.push(value);
92
+ break;
93
+ case "throws":
94
+ case "exception":
95
+ result.throws.push(value);
96
+ break;
97
+ case "see":
98
+ result.see.push(value);
99
+ break;
100
+ case "since":
101
+ result.since = value;
102
+ break;
103
+ }
104
+ }
105
+ currentTag = null;
106
+ currentValue = [];
107
+ }
108
+
109
+ for (const line of lines) {
110
+ const tagMatch = line.match(/^@(\w+)\s*(.*)?$/);
111
+ if (tagMatch) {
112
+ flushTag();
113
+ currentTag = tagMatch[1];
114
+ if (tagMatch[2]) {
115
+ currentValue.push(tagMatch[2]);
116
+ }
117
+ } else if (line.length > 0) {
118
+ currentValue.push(line);
119
+ }
120
+ }
121
+ flushTag();
122
+
123
+ return result;
124
+ }
125
+
126
+ /**
127
+ * Find the JSDoc block immediately preceding a position in source code
128
+ */
129
+ function findPrecedingJSDoc(source, position) {
130
+ // Look backwards from position for a JSDoc block
131
+ const beforePos = source.slice(0, position);
132
+ const blocks = [...beforePos.matchAll(JSDOC_BLOCK)];
133
+
134
+ if (blocks.length === 0) return null;
135
+
136
+ const lastBlock = blocks[blocks.length - 1];
137
+ const blockEnd = lastBlock.index + lastBlock[0].length;
138
+
139
+ // Check if the JSDoc block is immediately before the function (allow whitespace/newlines)
140
+ const between = source.slice(blockEnd, position);
141
+ if (/^[\s]*$/.test(between)) {
142
+ return parseJSDocBlock(lastBlock[0]);
143
+ }
144
+
145
+ return null;
146
+ }
147
+
148
+ /**
149
+ * Extract all documented exports from a file
150
+ */
151
+ function extractDocumentedExports(source, filePath) {
152
+ const exports = [];
153
+ const relativePath = filePath;
154
+
155
+ // Named function exports
156
+ const funcRegex = new RegExp(EXPORT_FUNCTION.source, "g");
157
+ let match;
158
+ while ((match = funcRegex.exec(source)) !== null) {
159
+ const jsdoc = findPrecedingJSDoc(source, match.index);
160
+ exports.push({
161
+ name: match[1],
162
+ type: "function",
163
+ params: match[2] ? match[2].split(",").map(p => p.trim()).filter(Boolean) : [],
164
+ source: relativePath,
165
+ line: source.slice(0, match.index).split("\n").length,
166
+ jsdoc: jsdoc,
167
+ });
168
+ }
169
+
170
+ // Arrow function exports (export const x = () => ...)
171
+ const arrowRegex = new RegExp(EXPORT_CONST_ARROW.source, "g");
172
+ while ((match = arrowRegex.exec(source)) !== null) {
173
+ const jsdoc = findPrecedingJSDoc(source, match.index);
174
+ exports.push({
175
+ name: match[1],
176
+ type: "arrow",
177
+ source: relativePath,
178
+ line: source.slice(0, match.index).split("\n").length,
179
+ jsdoc: jsdoc,
180
+ });
181
+ }
182
+
183
+ // Default function exports
184
+ const defaultRegex = new RegExp(EXPORT_DEFAULT_FUNCTION.source, "g");
185
+ while ((match = defaultRegex.exec(source)) !== null) {
186
+ const jsdoc = findPrecedingJSDoc(source, match.index);
187
+ exports.push({
188
+ name: match[1] || "default",
189
+ type: "function",
190
+ params: match[2] ? match[2].split(",").map(p => p.trim()).filter(Boolean) : [],
191
+ source: relativePath,
192
+ line: source.slice(0, match.index).split("\n").length,
193
+ isDefault: true,
194
+ jsdoc: jsdoc,
195
+ });
196
+ }
197
+
198
+ return exports;
199
+ }
200
+
201
+ async function readFileSafe(filePath) {
202
+ try {
203
+ return await fs.readFile(filePath, "utf8");
204
+ } catch {
205
+ return "";
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Analyze JSDoc/TSDoc across all JavaScript/TypeScript files
211
+ * @param {string[]} files - List of file paths relative to repo root
212
+ * @param {string} repoRoot - Absolute path to repository root
213
+ * @returns {Promise<Object>} Analysis result with documented exports
214
+ */
215
+ export async function analyzeJSDoc(files, repoRoot) {
216
+ const result = {
217
+ detected: false,
218
+ exports: [],
219
+ documented: 0,
220
+ undocumented: 0,
221
+ deprecated: [],
222
+ byFile: {},
223
+ summary: null,
224
+ };
225
+
226
+ const jsFiles = files.filter(f => JS_EXTENSIONS.some(ext => f.endsWith(ext)));
227
+ if (jsFiles.length === 0) return result;
228
+
229
+ for (const file of jsFiles) {
230
+ const content = await readFileSafe(path.join(repoRoot, file));
231
+ if (!content) continue;
232
+
233
+ const fileExports = extractDocumentedExports(content, file);
234
+ if (fileExports.length === 0) continue;
235
+
236
+ result.detected = true;
237
+ result.byFile[file] = fileExports;
238
+
239
+ for (const exp of fileExports) {
240
+ result.exports.push(exp);
241
+
242
+ if (exp.jsdoc) {
243
+ result.documented++;
244
+ if (exp.jsdoc.deprecated) {
245
+ result.deprecated.push({
246
+ name: exp.name,
247
+ source: exp.source,
248
+ reason: exp.jsdoc.deprecated,
249
+ });
250
+ }
251
+ } else {
252
+ result.undocumented++;
253
+ }
254
+ }
255
+ }
256
+
257
+ // Build summary
258
+ const total = result.documented + result.undocumented;
259
+ const coverage = total > 0 ? Math.round((result.documented / total) * 100) : 0;
260
+
261
+ result.summary = {
262
+ totalExports: total,
263
+ documented: result.documented,
264
+ undocumented: result.undocumented,
265
+ coverage: `${coverage}%`,
266
+ deprecatedCount: result.deprecated.length,
267
+ filesWithExports: Object.keys(result.byFile).length,
268
+ };
269
+
270
+ if (total > 0) {
271
+ info(`JSDoc analysis: ${result.documented}/${total} exports documented (${coverage}%), ${result.deprecated.length} deprecated`);
272
+ }
273
+
274
+ return result;
275
+ }
276
+
277
+ /**
278
+ * Get documentation for a specific export
279
+ * @param {Object} jsdocResult - Result from analyzeJSDoc
280
+ * @param {string} exportName - Name of the export to find
281
+ * @returns {Object|null} Export info with JSDoc or null if not found
282
+ */
283
+ export function getExportDoc(jsdocResult, exportName) {
284
+ return jsdocResult.exports.find(e => e.name === exportName) || null;
285
+ }
286
+
287
+ /**
288
+ * Get all documented exports from a specific file
289
+ * @param {Object} jsdocResult - Result from analyzeJSDoc
290
+ * @param {string} filePath - Relative path to the file
291
+ * @returns {Object[]} Array of exports from that file
292
+ */
293
+ export function getFileExports(jsdocResult, filePath) {
294
+ return jsdocResult.byFile[filePath] || [];
295
+ }
296
+
297
+ /**
298
+ * Format JSDoc for display in documentation
299
+ * @param {Object} jsdoc - Parsed JSDoc object
300
+ * @returns {string} Markdown-formatted documentation
301
+ */
302
+ export function formatJSDocAsMarkdown(jsdoc) {
303
+ if (!jsdoc) return "";
304
+
305
+ const lines = [];
306
+
307
+ if (jsdoc.description) {
308
+ lines.push(jsdoc.description);
309
+ lines.push("");
310
+ }
311
+
312
+ if (jsdoc.deprecated) {
313
+ lines.push(`> ⚠️ **Deprecated**: ${jsdoc.deprecated}`);
314
+ lines.push("");
315
+ }
316
+
317
+ if (jsdoc.params.length > 0) {
318
+ lines.push("**Parameters:**");
319
+ for (const param of jsdoc.params) {
320
+ const opt = param.optional ? " *(optional)*" : "";
321
+ lines.push(`- \`${param.name}\` (\`${param.type}\`)${opt}: ${param.description}`);
322
+ }
323
+ lines.push("");
324
+ }
325
+
326
+ if (jsdoc.returns) {
327
+ lines.push(`**Returns:** \`${jsdoc.returns.type}\` — ${jsdoc.returns.description}`);
328
+ lines.push("");
329
+ }
330
+
331
+ if (jsdoc.throws.length > 0) {
332
+ lines.push("**Throws:**");
333
+ for (const t of jsdoc.throws) {
334
+ lines.push(`- ${t}`);
335
+ }
336
+ lines.push("");
337
+ }
338
+
339
+ if (jsdoc.examples.length > 0) {
340
+ lines.push("**Example:**");
341
+ for (const ex of jsdoc.examples) {
342
+ lines.push("```javascript");
343
+ lines.push(ex);
344
+ lines.push("```");
345
+ }
346
+ lines.push("");
347
+ }
348
+
349
+ if (jsdoc.since) {
350
+ lines.push(`*Since: ${jsdoc.since}*`);
351
+ }
352
+
353
+ return lines.join("\n").trim();
354
+ }
@@ -0,0 +1,329 @@
1
+ // Security pattern detection — scans source files for risky code patterns
2
+ // Detects: eval(), innerHTML, SQL concatenation, command injection,
3
+ // hardcoded secrets, prototype pollution, regex DoS, path traversal
4
+
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { info, warn } from "../utils/logger.js";
8
+
9
+ const JS_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"];
10
+
11
+ /**
12
+ * Security pattern categories with regex detection and severity
13
+ */
14
+ const SECURITY_PATTERNS = [
15
+ // Code Injection
16
+ {
17
+ id: "eval-usage",
18
+ category: "Code Injection",
19
+ name: "eval() usage",
20
+ severity: "high",
21
+ pattern: /\beval\s*\(/g,
22
+ description: "eval() executes arbitrary code and is a major injection risk. Replace with JSON.parse(), Function constructor, or domain-specific parsers.",
23
+ cwe: "CWE-95",
24
+ },
25
+ {
26
+ id: "new-function",
27
+ category: "Code Injection",
28
+ name: "new Function() constructor",
29
+ severity: "high",
30
+ pattern: /new\s+Function\s*\(/g,
31
+ description: "Function constructor is equivalent to eval(). Use safe alternatives.",
32
+ cwe: "CWE-95",
33
+ },
34
+ {
35
+ id: "set-timeout-string",
36
+ category: "Code Injection",
37
+ name: "setTimeout/setInterval with string argument",
38
+ severity: "medium",
39
+ // Matches setTimeout("...", ...) or setTimeout('...', ...) or setTimeout(`...`, ...)
40
+ pattern: /(?:setTimeout|setInterval)\s*\(\s*['"`]/g,
41
+ description: "Passing a string to setTimeout/setInterval triggers eval-like behavior. Pass a function reference instead.",
42
+ cwe: "CWE-95",
43
+ },
44
+
45
+ // XSS
46
+ {
47
+ id: "innerhtml-assignment",
48
+ category: "Cross-Site Scripting (XSS)",
49
+ name: "innerHTML assignment",
50
+ severity: "high",
51
+ pattern: /\.innerHTML\s*[=+](?!=)/g,
52
+ description: "Direct innerHTML assignment can execute injected scripts. Use textContent, or sanitize with DOMPurify.",
53
+ cwe: "CWE-79",
54
+ },
55
+ {
56
+ id: "outerhtml-assignment",
57
+ category: "Cross-Site Scripting (XSS)",
58
+ name: "outerHTML assignment",
59
+ severity: "high",
60
+ pattern: /\.outerHTML\s*[=+](?!=)/g,
61
+ description: "outerHTML assignment is equivalent to innerHTML and carries the same XSS risk.",
62
+ cwe: "CWE-79",
63
+ },
64
+ {
65
+ id: "document-write",
66
+ category: "Cross-Site Scripting (XSS)",
67
+ name: "document.write()",
68
+ severity: "high",
69
+ pattern: /document\.write(?:ln)?\s*\(/g,
70
+ description: "document.write() can inject unescaped HTML. Use DOM APIs to manipulate the page.",
71
+ cwe: "CWE-79",
72
+ },
73
+ {
74
+ id: "dangerously-set-inner-html",
75
+ category: "Cross-Site Scripting (XSS)",
76
+ name: "React dangerouslySetInnerHTML",
77
+ severity: "medium",
78
+ pattern: /dangerouslySetInnerHTML/g,
79
+ description: "dangerouslySetInnerHTML bypasses React's XSS protection. Ensure content is sanitized before use.",
80
+ cwe: "CWE-79",
81
+ },
82
+
83
+ // SQL Injection
84
+ {
85
+ id: "sql-concatenation",
86
+ category: "SQL Injection",
87
+ name: "SQL string concatenation",
88
+ severity: "high",
89
+ // Matches SQL keywords followed by string concat with variables
90
+ pattern: /(?:SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER)\s+.*?\+\s*(?:\w+|['"`])/gi,
91
+ description: "Concatenating user input into SQL queries enables injection attacks. Use parameterized queries or prepared statements.",
92
+ cwe: "CWE-89",
93
+ },
94
+ {
95
+ id: "sql-template-literal",
96
+ category: "SQL Injection",
97
+ name: "SQL in template literals with interpolation",
98
+ severity: "medium",
99
+ pattern: /`(?:SELECT|INSERT|UPDATE|DELETE|DROP)\s+[^`]*\$\{/gi,
100
+ description: "Template literals with SQL and variable interpolation may be vulnerable. Use parameterized queries.",
101
+ cwe: "CWE-89",
102
+ },
103
+
104
+ // Command Injection
105
+ {
106
+ id: "exec-usage",
107
+ category: "Command Injection",
108
+ name: "child_process exec()",
109
+ severity: "high",
110
+ // exec with string that includes variable interpolation
111
+ pattern: /(?:exec|execSync)\s*\(\s*(?:`[^`]*\$\{|['"][^)]*\+)/g,
112
+ description: "exec() with string interpolation enables command injection. Use execFile() or spawn() with argument arrays.",
113
+ cwe: "CWE-78",
114
+ },
115
+ {
116
+ id: "shell-true",
117
+ category: "Command Injection",
118
+ name: "spawn/execFile with shell: true",
119
+ severity: "medium",
120
+ pattern: /shell\s*:\s*true/g,
121
+ description: "shell: true enables shell interpretation of arguments, risking injection. Remove when possible.",
122
+ cwe: "CWE-78",
123
+ },
124
+
125
+ // Path Traversal
126
+ {
127
+ id: "path-traversal",
128
+ category: "Path Traversal",
129
+ name: "Unsanitized path join with user input",
130
+ severity: "medium",
131
+ // req.params, req.query, req.body used in path operations
132
+ pattern: /path\.(?:join|resolve)\s*\([^)]*req\.(?:params|query|body)/g,
133
+ description: "Combining user input with path operations without sanitization enables directory traversal. Validate and normalize paths.",
134
+ cwe: "CWE-22",
135
+ },
136
+
137
+ // Prototype Pollution
138
+ {
139
+ id: "prototype-assignment",
140
+ category: "Prototype Pollution",
141
+ name: "__proto__ assignment",
142
+ severity: "high",
143
+ pattern: /__proto__\s*[=\[]/g,
144
+ description: "__proto__ manipulation can pollute Object prototype. Use Object.create(null) for dictionary objects.",
145
+ cwe: "CWE-1321",
146
+ },
147
+ {
148
+ id: "object-merge-unsafe",
149
+ category: "Prototype Pollution",
150
+ name: "Recursive object merge without prototype check",
151
+ severity: "low",
152
+ // Deep/recursive merge or extend patterns
153
+ pattern: /(?:deepMerge|deepExtend|merge|assign)\s*\(/g,
154
+ description: "Deep merge utilities may be vulnerable to prototype pollution if they don't check hasOwnProperty. Verify the implementation.",
155
+ cwe: "CWE-1321",
156
+ },
157
+
158
+ // Hardcoded Credentials
159
+ {
160
+ id: "hardcoded-password",
161
+ category: "Hardcoded Credentials",
162
+ name: "Hardcoded password or secret",
163
+ severity: "high",
164
+ // password = "...", secret = "...", apiKey = "..." (not env vars)
165
+ pattern: /(?:password|passwd|secret|api_?key|token)\s*[:=]\s*['"][^'"]{8,}['"]/gi,
166
+ description: "Hardcoded credentials should be moved to environment variables or a secrets manager.",
167
+ cwe: "CWE-798",
168
+ },
169
+
170
+ // Insecure Randomness
171
+ {
172
+ id: "math-random-security",
173
+ category: "Insecure Randomness",
174
+ name: "Math.random() for security-sensitive operation",
175
+ severity: "low",
176
+ pattern: /Math\.random\s*\(\s*\)/g,
177
+ description: "Math.random() is not cryptographically secure. Use crypto.randomUUID() or crypto.getRandomValues() for security tokens.",
178
+ cwe: "CWE-330",
179
+ },
180
+
181
+ // Regex DoS
182
+ {
183
+ id: "regex-dos",
184
+ category: "ReDoS",
185
+ name: "Potentially catastrophic regex",
186
+ severity: "low",
187
+ // Nested quantifiers like (a+)+ or (a*)*
188
+ pattern: /new\s+RegExp\s*\(\s*(?:\w+|['"`])/g,
189
+ description: "Dynamic regex construction from user input can cause ReDoS. Use static regex or input validation.",
190
+ cwe: "CWE-1333",
191
+ },
192
+ ];
193
+
194
+ // Files/patterns to skip (test files, config, generated code)
195
+ const SKIP_PATTERNS = [
196
+ /node_modules\//,
197
+ /\.test\.[jt]sx?$/,
198
+ /\.spec\.[jt]sx?$/,
199
+ /\btest[s]?\//,
200
+ /\bdist\//,
201
+ /\bbuild\//,
202
+ /\.min\.[jt]s$/,
203
+ /\.d\.ts$/,
204
+ /\.config\.[jt]s$/,
205
+ /\.repolens\//,
206
+ ];
207
+
208
+ function shouldSkipFile(filePath) {
209
+ return SKIP_PATTERNS.some(pattern => pattern.test(filePath));
210
+ }
211
+
212
+ async function readFileSafe(filePath) {
213
+ try {
214
+ return await fs.readFile(filePath, "utf8");
215
+ } catch {
216
+ return "";
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Scan source files for security anti-patterns
222
+ * @param {string[]} files - List of file paths relative to repo root
223
+ * @param {string} repoRoot - Absolute path to repository root
224
+ * @returns {Promise<Object>} Security analysis result
225
+ */
226
+ export async function analyzeSecurityPatterns(files, repoRoot) {
227
+ const result = {
228
+ detected: false,
229
+ findings: [],
230
+ summary: null,
231
+ byCategory: {},
232
+ bySeverity: { high: 0, medium: 0, low: 0 },
233
+ filesScanned: 0,
234
+ filesWithFindings: new Set(),
235
+ };
236
+
237
+ const jsFiles = files
238
+ .filter(f => JS_EXTENSIONS.some(ext => f.endsWith(ext)))
239
+ .filter(f => !shouldSkipFile(f));
240
+
241
+ if (jsFiles.length === 0) return result;
242
+
243
+ result.filesScanned = jsFiles.length;
244
+
245
+ for (const file of jsFiles) {
246
+ const content = await readFileSafe(path.join(repoRoot, file));
247
+ if (!content) continue;
248
+
249
+ // Strip comments to reduce false positives
250
+ const stripped = stripComments(content);
251
+
252
+ for (const pattern of SECURITY_PATTERNS) {
253
+ const regex = new RegExp(pattern.pattern.source, pattern.pattern.flags);
254
+ let match;
255
+
256
+ while ((match = regex.exec(stripped)) !== null) {
257
+ // Get line number
258
+ const lineNumber = stripped.slice(0, match.index).split("\n").length;
259
+
260
+ // Get the surrounding code snippet
261
+ const lines = stripped.split("\n");
262
+ const snippetStart = Math.max(0, lineNumber - 2);
263
+ const snippetEnd = Math.min(lines.length, lineNumber + 1);
264
+ const snippet = lines.slice(snippetStart, snippetEnd).join("\n").trim();
265
+
266
+ result.detected = true;
267
+ result.findings.push({
268
+ id: pattern.id,
269
+ category: pattern.category,
270
+ name: pattern.name,
271
+ severity: pattern.severity,
272
+ file,
273
+ line: lineNumber,
274
+ snippet: snippet.length > 200 ? snippet.slice(0, 200) + "..." : snippet,
275
+ description: pattern.description,
276
+ cwe: pattern.cwe,
277
+ });
278
+
279
+ result.bySeverity[pattern.severity]++;
280
+ result.filesWithFindings.add(file);
281
+
282
+ if (!result.byCategory[pattern.category]) {
283
+ result.byCategory[pattern.category] = [];
284
+ }
285
+ result.byCategory[pattern.category].push(result.findings[result.findings.length - 1]);
286
+ }
287
+ }
288
+ }
289
+
290
+ // Convert Set to count for serialization
291
+ result.filesWithFindingsCount = result.filesWithFindings.size;
292
+ result.filesWithFindings = [...result.filesWithFindings];
293
+
294
+ result.summary = buildSummary(result);
295
+
296
+ if (result.findings.length > 0) {
297
+ info(`Security patterns: ${result.findings.length} finding(s) in ${result.filesWithFindingsCount} file(s) (${result.bySeverity.high} high, ${result.bySeverity.medium} medium, ${result.bySeverity.low} low)`);
298
+ }
299
+
300
+ return result;
301
+ }
302
+
303
+ /**
304
+ * Strip single-line and multi-line comments from source code
305
+ * to reduce false positives in pattern matching
306
+ */
307
+ function stripComments(source) {
308
+ // Remove multi-line comments
309
+ let stripped = source.replace(/\/\*[\s\S]*?\*\//g, (match) => {
310
+ // Preserve line count for accurate line numbers
311
+ return match.replace(/[^\n]/g, " ");
312
+ });
313
+ // Remove single-line comments (but not URLs like https://)
314
+ stripped = stripped.replace(/(?<!:)\/\/.*$/gm, "");
315
+ return stripped;
316
+ }
317
+
318
+ function buildSummary(result) {
319
+ if (result.findings.length === 0) {
320
+ return "No security hotspots detected.";
321
+ }
322
+
323
+ const parts = [`${result.findings.length} security finding(s)`];
324
+ if (result.bySeverity.high > 0) parts.push(`${result.bySeverity.high} high severity`);
325
+ if (result.bySeverity.medium > 0) parts.push(`${result.bySeverity.medium} medium severity`);
326
+ if (result.bySeverity.low > 0) parts.push(`${result.bySeverity.low} low severity`);
327
+ parts.push(`across ${result.filesWithFindingsCount} file(s)`);
328
+ return parts.join(" · ");
329
+ }