pi-lens 2.1.0 → 2.1.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  All notable changes to pi-lens will be documented in this file.
4
4
 
5
+ ## [2.1.1] - 2026-03-29
6
+
7
+ ### Added
8
+ - **Content-level secret scanning**: Catches secrets in ANY file type on write/edit (`.env`, `.yaml`, `.json`, not just TypeScript). Blocks before save with patterns for `sk-*`, `ghp_*`, `AKIA*`, private keys, hardcoded passwords.
9
+ - **Project rules integration**: Scans for `.claude/rules/`, `.agents/rules/`, `CLAUDE.md`, `AGENTS.md` at session start and surfaces in system prompt.
10
+ - **Grep-ability rules**: New ast-grep rules for `no-default-export` and `no-relative-cross-package-import` to improve agent searchability.
11
+
12
+ ### Changed
13
+ - **Inline feedback stripped to blocking only**: Warnings no longer shown inline (noise). Only blocking violations and test failures interrupt the agent.
14
+ - **booboo-fix output compacted**: Summary in terminal, full plan in `.pi-lens/reports/fix-plan.tsv`.
15
+ - **booboo-refactor output compacted**: Top 5 worst offenders in terminal, full ranked list in `.pi-lens/reports/refactor-ranked.tsv`.
16
+ - **`ast_grep_search` new params**: Added `selector` (extract specific AST node) and `context` (show surrounding lines).
17
+ - **`ast_grep_replace` mode indicator**: Shows `[DRY-RUN]` or `[APPLIED]` prefix.
18
+ - **no-hardcoded-secrets**: Fixed to only flag actual hardcoded strings (not `process.env` assignments).
19
+ - **no-process-env**: Now only flags secret-related env vars (not PORT, NODE_ENV, etc.).
20
+ - **Removed Factory AI article reference** from architect.yaml.
21
+
5
22
  ## [2.0.40] - 2026-03-27
6
23
 
7
24
  ### Changed
package/README.md CHANGED
@@ -16,6 +16,37 @@ pi install git:github.com/apmantza/pi-lens
16
16
 
17
17
  ---
18
18
 
19
+ ## What's New (v2.1)
20
+
21
+ ### Content-Level Secret Scanning
22
+
23
+ Secrets are now blocked **before** they're saved, on **all file types**:
24
+
25
+ ```
26
+ 🔴 STOP — 1 potential secret(s) in src/config.ts:
27
+ L12: Possible Stripe or OpenAI API key (sk-*)
28
+ → Remove before continuing. Use env vars instead.
29
+ ```
30
+
31
+ Works on `.env`, `.yaml`, `.json`, `.md` — not just TypeScript. Catches `sk-*`, `ghp_*`, `AKIA*`, private keys, hardcoded passwords.
32
+
33
+ ### Compact Output
34
+
35
+ Inline feedback stripped to **blocking only** — no more warning noise:
36
+
37
+ ```
38
+ 🔴 STOP — 2 issue(s) must fixed:
39
+ L23: var total = sum(items); — use 'let' or 'const'
40
+ ```
41
+
42
+ Warnings are tracked and surfaced via `/lens-booboo`. booboo-fix and booboo-refactor output compacted to summaries with TSV files for full details.
43
+
44
+ ### Project Rules Integration
45
+
46
+ Scans for `.claude/rules/`, `.agents/rules/`, `CLAUDE.md`, `AGENTS.md` at session start. Project-specific rules are surfaced in the system prompt — the agent knows to read them when relevant. Works alongside pi-lens architect rules.
47
+
48
+ ---
49
+
19
50
  ## What's New (v2.0)
20
51
 
21
52
  ### Declarative Dispatch System
@@ -184,7 +215,7 @@ These files provide **general project guidance** (coding conventions, workflow r
184
215
 
185
216
  | Tool | Description |
186
217
  |---|---|
187
- | **`ast_grep_search`** | Search code patterns using AST-aware matching. Supports meta-variables: `$VAR` (single node), `$$$` (multiple). Example: `console.log($MSG)` |
218
+ | **`ast_grep_search`** | Search code patterns using AST-aware matching. Supports meta-variables: `$VAR` (single node), `$$$` (multiple). Optional: `selector` (extract specific AST node), `context` (show surrounding lines). Example: `console.log($MSG)` |
188
219
  | **`ast_grep_replace`** | Replace code patterns with AST-aware rewriting. Dry-run by default, use `apply=true` to apply changes. Example: `pattern='console.log($MSG)' rewrite='logger.info($MSG)'` |
189
220
 
190
221
  Supported languages: c, cpp, csharp, css, dart, elixir, go, haskell, html, java, javascript, json, kotlin, lua, php, python, ruby, rust, scala, sql, swift, tsx, typescript, yaml
@@ -45,16 +45,16 @@ export class AstGrepClient {
45
45
  /**
46
46
  * Search for AST patterns in files
47
47
  */
48
- async search(pattern, lang, paths) {
49
- return this.runner.exec([
50
- "run",
51
- "-p",
52
- pattern,
53
- "--lang",
54
- lang,
55
- "--json=compact",
56
- ...paths,
57
- ]);
48
+ async search(pattern, lang, paths, options) {
49
+ const args = ["run", "-p", pattern, "--lang", lang, "--json=compact"];
50
+ if (options?.selector) {
51
+ args.push("--selector", options.selector);
52
+ }
53
+ if (options?.context !== undefined) {
54
+ args.push("--context", String(options.context));
55
+ }
56
+ args.push(...paths);
57
+ return this.runner.exec(args);
58
58
  }
59
59
  /**
60
60
  * Search and replace AST patterns
@@ -165,8 +165,8 @@ message: found
165
165
  }
166
166
  return exports;
167
167
  }
168
- formatMatches(matches, isDryRun = false) {
169
- return this.runner.formatMatches(matches, isDryRun);
168
+ formatMatches(matches, isDryRun = false, showModeIndicator = false) {
169
+ return this.runner.formatMatches(matches, isDryRun, 50, showModeIndicator);
170
170
  }
171
171
  /**
172
172
  * Scan a file against all rules
@@ -92,16 +92,17 @@ export class AstGrepClient {
92
92
  pattern: string,
93
93
  lang: string,
94
94
  paths: string[],
95
+ options?: { selector?: string; context?: number },
95
96
  ): Promise<{ matches: AstGrepMatch[]; error?: string }> {
96
- return this.runner.exec([
97
- "run",
98
- "-p",
99
- pattern,
100
- "--lang",
101
- lang,
102
- "--json=compact",
103
- ...paths,
104
- ]);
97
+ const args = ["run", "-p", pattern, "--lang", lang, "--json=compact"];
98
+ if (options?.selector) {
99
+ args.push("--selector", options.selector);
100
+ }
101
+ if (options?.context !== undefined) {
102
+ args.push("--context", String(options.context));
103
+ }
104
+ args.push(...paths);
105
+ return this.runner.exec(args);
105
106
  }
106
107
 
107
108
  /**
@@ -257,8 +258,17 @@ message: found
257
258
  return exports;
258
259
  }
259
260
 
260
- formatMatches(matches: AstGrepMatch[], isDryRun = false): string {
261
- return this.runner.formatMatches(matches as SgMatch[], isDryRun);
261
+ formatMatches(
262
+ matches: AstGrepMatch[],
263
+ isDryRun = false,
264
+ showModeIndicator = false,
265
+ ): string {
266
+ return this.runner.formatMatches(
267
+ matches as SgMatch[],
268
+ isDryRun,
269
+ 50,
270
+ showModeIndicator,
271
+ );
262
272
  }
263
273
 
264
274
  /**
@@ -191,9 +191,9 @@ export async function dispatchForFile(ctx, groups) {
191
191
  const blockers = allDiagnostics.filter((d) => d.semantic === "blocking");
192
192
  const warnings = allDiagnostics.filter((d) => d.semantic === "warning" || d.semantic === "none");
193
193
  const fixedItems = allDiagnostics.filter((d) => d.semantic === "fixed");
194
- // Format output
194
+ // Format output — only blocking issues shown inline
195
+ // Warnings tracked but not shown (noise) — surfaced via /lens-booboo
195
196
  let output = formatDiagnostics(blockers, "blocking");
196
- output += formatDiagnostics(warnings, "warning");
197
197
  output += formatDiagnostics(fixedItems, "fixed");
198
198
  return {
199
199
  diagnostics: allDiagnostics,
@@ -266,9 +266,9 @@ export async function dispatchForFile(
266
266
  );
267
267
  const fixedItems = allDiagnostics.filter((d) => d.semantic === "fixed");
268
268
 
269
- // Format output
269
+ // Format output — only blocking issues shown inline
270
+ // Warnings tracked but not shown (noise) — surfaced via /lens-booboo
270
271
  let output = formatDiagnostics(blockers, "blocking");
271
- output += formatDiagnostics(warnings, "warning");
272
272
  output += formatDiagnostics(fixedItems, "fixed");
273
273
 
274
274
  return {
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Secrets scanner runner
3
+ *
4
+ * Scans file content for potential secret patterns before write.
5
+ * Works on all file types via regex matching.
6
+ *
7
+ * Patterns detected:
8
+ * - Stripe/OpenAI keys (sk-*)
9
+ * - GitHub tokens (ghp_*, gho_*, github_pat_*)
10
+ * - AWS keys (AKIA*)
11
+ * - Slack tokens (xoxp-*, xoxb-*)
12
+ * - Private keys (BEGIN PRIVATE KEY)
13
+ * - Generic API key patterns
14
+ */
15
+ const SECRET_PATTERNS = [
16
+ {
17
+ pattern: /sk-[a-zA-Z0-9]{20,}/g,
18
+ name: "stripe-openai-key",
19
+ message: "Possible Stripe or OpenAI API key (sk-*)",
20
+ },
21
+ {
22
+ pattern: /ghp_[a-zA-Z0-9]{36}/g,
23
+ name: "github-personal-token",
24
+ message: "GitHub personal access token (ghp_*)",
25
+ },
26
+ {
27
+ pattern: /gho_[a-zA-Z0-9]{36}/g,
28
+ name: "github-oauth-token",
29
+ message: "GitHub OAuth token (gho_*)",
30
+ },
31
+ {
32
+ pattern: /github_pat_[a-zA-Z_]{82}/g,
33
+ name: "github-fine-grained-pat",
34
+ message: "GitHub fine-grained PAT (github_pat_*)",
35
+ },
36
+ {
37
+ pattern: /AKIA[0-9A-Z]{16}/g,
38
+ name: "aws-access-key",
39
+ message: "AWS access key ID (AKIA*)",
40
+ },
41
+ {
42
+ pattern: /xox[bp]-[a-zA-Z0-9]{10,}/g,
43
+ name: "slack-token",
44
+ message: "Slack token (xoxb-*/xoxp-*)",
45
+ },
46
+ {
47
+ pattern: /-----BEGIN\s+(RSA\s+)?PRIVATE KEY-----/g,
48
+ name: "private-key",
49
+ message: "Private key material detected",
50
+ },
51
+ {
52
+ pattern: /password\s*[:=]\s*["'][^"']{8,}["']/gi,
53
+ name: "hardcoded-password",
54
+ message: "Possible hardcoded password",
55
+ },
56
+ {
57
+ pattern: /(?:secret|api_?key|token)\s*[:=]\s*["'][a-zA-Z0-9]{20,}["']/gi,
58
+ name: "generic-api-secret",
59
+ message: "Possible hardcoded secret, API key, or token",
60
+ },
61
+ ];
62
+ function scanContent(content) {
63
+ const findings = [];
64
+ const lines = content.split("\n");
65
+ for (let i = 0; i < lines.length; i++) {
66
+ const line = lines[i];
67
+ for (const secret of SECRET_PATTERNS) {
68
+ secret.pattern.lastIndex = 0; // Reset regex state
69
+ const match = secret.pattern.exec(line);
70
+ if (match) {
71
+ findings.push({
72
+ line: i + 1,
73
+ pattern: secret,
74
+ match: match[0].slice(0, 20) + "...",
75
+ });
76
+ }
77
+ }
78
+ }
79
+ return findings;
80
+ }
81
+ async function run(ctx) {
82
+ const diagnostics = [];
83
+ // Get the content being written - check both full content and the diff
84
+ const content = ctx.content;
85
+ if (!content)
86
+ return { diagnostics };
87
+ const findings = scanContent(content);
88
+ for (const finding of findings) {
89
+ diagnostics.push({
90
+ id: `secrets-${finding.pattern.name}-${finding.line}`,
91
+ message: `${finding.pattern.message} at line ${finding.line}. Remove before committing.`,
92
+ filePath: ctx.filePath,
93
+ line: finding.line,
94
+ severity: "error",
95
+ semantic: "blocking", // Always blocking - secrets should never be committed
96
+ tool: "secrets",
97
+ rule: finding.pattern.name,
98
+ fixable: false,
99
+ });
100
+ }
101
+ return { diagnostics };
102
+ }
103
+ const runner = {
104
+ id: "secrets",
105
+ appliesTo: ["*"], // All file types
106
+ priority: 0, // Run first - block before other checks
107
+ run,
108
+ };
109
+ export default runner;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Content-level secrets scanner
3
+ *
4
+ * Scans file content for potential secret patterns before write.
5
+ * Works on all file types via regex matching.
6
+ *
7
+ * Detected patterns:
8
+ * - Stripe/OpenAI keys (sk-*)
9
+ * - GitHub tokens (ghp_*, gho_*, github_pat_*)
10
+ * - AWS keys (AKIA*)
11
+ * - Slack tokens (xoxp-*, xoxb-*)
12
+ * - Private keys (BEGIN PRIVATE KEY)
13
+ * - Generic API key/password patterns
14
+ */
15
+ // Patterns ordered by specificity - first match wins per line
16
+ const SECRET_PATTERNS = [
17
+ // High-confidence: specific key prefixes
18
+ {
19
+ pattern: /sk-[a-zA-Z0-9-]{20,}/g,
20
+ name: "stripe-openai-key",
21
+ message: "Possible Stripe or OpenAI API key (sk-*)",
22
+ },
23
+ {
24
+ pattern: /ghp_[a-zA-Z0-9]{36}/g,
25
+ name: "github-personal-token",
26
+ message: "GitHub personal access token (ghp_*)",
27
+ },
28
+ {
29
+ pattern: /gho_[a-zA-Z0-9]{36}/g,
30
+ name: "github-oauth-token",
31
+ message: "GitHub OAuth token (gho_*)",
32
+ },
33
+ {
34
+ pattern: /github_pat_[a-zA-Z_]{82}/g,
35
+ name: "github-fine-grained-pat",
36
+ message: "GitHub fine-grained PAT (github_pat_*)",
37
+ },
38
+ {
39
+ pattern: /AKIA[0-9A-Z]{16}/g,
40
+ name: "aws-access-key",
41
+ message: "AWS access key ID (AKIA*)",
42
+ },
43
+ {
44
+ pattern: /xox[bp]-[a-zA-Z0-9]{10,}/g,
45
+ name: "slack-token",
46
+ message: "Slack token (xoxb-*/xoxp-*)",
47
+ },
48
+ {
49
+ pattern: /-----BEGIN\s+(RSA\s+)?PRIVATE KEY-----/g,
50
+ name: "private-key",
51
+ message: "Private key material detected",
52
+ },
53
+ // Medium-confidence: quoted credentials
54
+ {
55
+ pattern: /password\s*[:=]\s*["'][^"']{4,}["']/gi,
56
+ name: "hardcoded-password",
57
+ message: "Possible hardcoded password",
58
+ },
59
+ {
60
+ pattern: /(?:secret|api_?key|token|access_?key)\s*[:=]\s*["'][a-zA-Z0-9_\-/.]{8,}["']/gi,
61
+ name: "hardcoded-secret",
62
+ message: "Possible hardcoded secret or API key",
63
+ },
64
+ // .env format: KEY=VALUE (no quotes)
65
+ {
66
+ pattern: /^(?:API_?KEY|SECRET|TOKEN|PASSWORD|AWS_?ACCESS_?KEY)\s*=\s*\S{8,}/gim,
67
+ name: "env-file-secret",
68
+ message: "Possible secret in .env format",
69
+ },
70
+ ];
71
+ /**
72
+ * Scan content for potential secrets
73
+ * Returns findings with line numbers
74
+ */
75
+ export function scanForSecrets(content) {
76
+ const findings = [];
77
+ const lines = content.split("\n");
78
+ for (let i = 0; i < lines.length; i++) {
79
+ const line = lines[i];
80
+ let matched = false;
81
+ for (const pattern of SECRET_PATTERNS) {
82
+ // Reset lastIndex before each test (important for global regex)
83
+ const regex = new RegExp(pattern.pattern.source, pattern.pattern.flags);
84
+ if (regex.test(line)) {
85
+ findings.push({
86
+ line: i + 1,
87
+ message: pattern.message,
88
+ });
89
+ matched = true;
90
+ break; // One finding per line
91
+ }
92
+ }
93
+ }
94
+ return findings;
95
+ }
96
+ /**
97
+ * Format secrets findings for terminal output
98
+ */
99
+ export function formatSecrets(findings, filePath) {
100
+ if (findings.length === 0)
101
+ return "";
102
+ const lines = [
103
+ `🔴 STOP — ${findings.length} potential secret(s) in ${filePath}:`,
104
+ ];
105
+ for (const f of findings.slice(0, 5)) {
106
+ lines.push(` L${f.line}: ${f.message}`);
107
+ }
108
+ if (findings.length > 5) {
109
+ lines.push(` ... and ${findings.length - 5} more`);
110
+ }
111
+ lines.push(" → Remove before continuing. Use env vars instead.");
112
+ return lines.join("\n");
113
+ }
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatSecrets, scanForSecrets } from "./secrets-scanner.js";
3
+ describe("scanForSecrets", () => {
4
+ it("should detect Stripe/OpenAI keys (sk-*)", () => {
5
+ const content = `const apiKey = "sk-live-1234567890abcdefghij";`;
6
+ const findings = scanForSecrets(content);
7
+ expect(findings.length).toBe(1);
8
+ expect(findings[0].message).toContain("Stripe or OpenAI");
9
+ });
10
+ it("should detect GitHub personal tokens (ghp_*)", () => {
11
+ const content = `token = "ghp_1234567890abcdefghijklmnopqrstuvwxyz";`;
12
+ const findings = scanForSecrets(content);
13
+ expect(findings.length).toBe(1);
14
+ expect(findings[0].message).toContain("GitHub personal");
15
+ });
16
+ it("should detect AWS access keys (AKIA*)", () => {
17
+ const content = `const AWS_KEY = "AKIAIOSFODNN7EXAMPLE";`;
18
+ const findings = scanForSecrets(content);
19
+ expect(findings.length).toBe(1);
20
+ expect(findings[0].message).toContain("AWS access key");
21
+ });
22
+ it("should detect private key material", () => {
23
+ const content = `-----BEGIN RSA PRIVATE KEY-----
24
+ MIIEpAIBAAKCAQEA...`;
25
+ const findings = scanForSecrets(content);
26
+ expect(findings.length).toBe(1);
27
+ expect(findings[0].message).toContain("Private key");
28
+ });
29
+ it("should detect hardcoded passwords", () => {
30
+ const content = `const config = { password: "hunter2" };`;
31
+ const findings = scanForSecrets(content);
32
+ expect(findings.length).toBe(1);
33
+ expect(findings[0].message).toContain("password");
34
+ });
35
+ it("should detect secrets in .env format", () => {
36
+ const content = `API_KEY=sk-live-1234567890abcdefghij
37
+ DATABASE_URL=postgres://localhost`;
38
+ const findings = scanForSecrets(content);
39
+ expect(findings.length).toBe(1);
40
+ // sk-* pattern catches this first (more specific)
41
+ expect(findings[0].message).toContain("Stripe or OpenAI");
42
+ });
43
+ it("should NOT flag safe content", () => {
44
+ const content = `
45
+ const name = "test";
46
+ const url = "https://example.com";
47
+ const port = 3000;
48
+ const message = "Hello world";
49
+ `;
50
+ const findings = scanForSecrets(content);
51
+ expect(findings.length).toBe(0);
52
+ });
53
+ it("should NOT flag env var references", () => {
54
+ const content = `const key = process.env.API_KEY;`;
55
+ const findings = scanForSecrets(content);
56
+ expect(findings.length).toBe(0);
57
+ });
58
+ it("should detect multiple secrets", () => {
59
+ const content = `
60
+ const sk = "sk-live-1234567890abcdefghij";
61
+ const gh = "ghp_1234567890abcdefghijklmnopqrstuvwxyz";
62
+ `;
63
+ const findings = scanForSecrets(content);
64
+ expect(findings.length).toBe(2);
65
+ });
66
+ it("should report correct line numbers", () => {
67
+ const content = `line 1
68
+ line 2
69
+ const secret = "sk-live-1234567890abcdefghij";
70
+ line 4`;
71
+ const findings = scanForSecrets(content);
72
+ expect(findings.length).toBe(1);
73
+ expect(findings[0].line).toBe(3);
74
+ });
75
+ });
76
+ describe("formatSecrets", () => {
77
+ it("should format findings for terminal output", () => {
78
+ const findings = [
79
+ { line: 5, message: "Possible Stripe or OpenAI API key (sk-*)" },
80
+ ];
81
+ const output = formatSecrets(findings, "src/config.ts");
82
+ expect(output).toContain("STOP");
83
+ expect(output).toContain("1 potential secret(s)");
84
+ expect(output).toContain("L5");
85
+ expect(output).toContain("src/config.ts");
86
+ });
87
+ it("should return empty string for no findings", () => {
88
+ const output = formatSecrets([], "src/config.ts");
89
+ expect(output).toBe("");
90
+ });
91
+ it("should truncate at 5 findings", () => {
92
+ const findings = Array.from({ length: 10 }, (_, i) => ({
93
+ line: i + 1,
94
+ message: "Test secret",
95
+ }));
96
+ const output = formatSecrets(findings, "src/config.ts");
97
+ expect(output).toContain("10 potential secret(s)");
98
+ expect(output).toContain("... and 5 more");
99
+ });
100
+ });
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatSecrets, scanForSecrets } from "./secrets-scanner.js";
3
+
4
+ describe("scanForSecrets", () => {
5
+ it("should detect Stripe/OpenAI keys (sk-*)", () => {
6
+ const content = `const apiKey = "sk-live-1234567890abcdefghij";`;
7
+ const findings = scanForSecrets(content);
8
+ expect(findings.length).toBe(1);
9
+ expect(findings[0].message).toContain("Stripe or OpenAI");
10
+ });
11
+
12
+ it("should detect GitHub personal tokens (ghp_*)", () => {
13
+ const content = `token = "ghp_1234567890abcdefghijklmnopqrstuvwxyz";`;
14
+ const findings = scanForSecrets(content);
15
+ expect(findings.length).toBe(1);
16
+ expect(findings[0].message).toContain("GitHub personal");
17
+ });
18
+
19
+ it("should detect AWS access keys (AKIA*)", () => {
20
+ const content = `const AWS_KEY = "AKIAIOSFODNN7EXAMPLE";`;
21
+ const findings = scanForSecrets(content);
22
+ expect(findings.length).toBe(1);
23
+ expect(findings[0].message).toContain("AWS access key");
24
+ });
25
+
26
+ it("should detect private key material", () => {
27
+ const content = `-----BEGIN RSA PRIVATE KEY-----
28
+ MIIEpAIBAAKCAQEA...`;
29
+ const findings = scanForSecrets(content);
30
+ expect(findings.length).toBe(1);
31
+ expect(findings[0].message).toContain("Private key");
32
+ });
33
+
34
+ it("should detect hardcoded passwords", () => {
35
+ const content = `const config = { password: "hunter2" };`;
36
+ const findings = scanForSecrets(content);
37
+ expect(findings.length).toBe(1);
38
+ expect(findings[0].message).toContain("password");
39
+ });
40
+
41
+ it("should detect secrets in .env format", () => {
42
+ const content = `API_KEY=sk-live-1234567890abcdefghij
43
+ DATABASE_URL=postgres://localhost`;
44
+ const findings = scanForSecrets(content);
45
+ expect(findings.length).toBe(1);
46
+ // sk-* pattern catches this first (more specific)
47
+ expect(findings[0].message).toContain("Stripe or OpenAI");
48
+ });
49
+
50
+ it("should NOT flag safe content", () => {
51
+ const content = `
52
+ const name = "test";
53
+ const url = "https://example.com";
54
+ const port = 3000;
55
+ const message = "Hello world";
56
+ `;
57
+ const findings = scanForSecrets(content);
58
+ expect(findings.length).toBe(0);
59
+ });
60
+
61
+ it("should NOT flag env var references", () => {
62
+ const content = `const key = process.env.API_KEY;`;
63
+ const findings = scanForSecrets(content);
64
+ expect(findings.length).toBe(0);
65
+ });
66
+
67
+ it("should detect multiple secrets", () => {
68
+ const content = `
69
+ const sk = "sk-live-1234567890abcdefghij";
70
+ const gh = "ghp_1234567890abcdefghijklmnopqrstuvwxyz";
71
+ `;
72
+ const findings = scanForSecrets(content);
73
+ expect(findings.length).toBe(2);
74
+ });
75
+
76
+ it("should report correct line numbers", () => {
77
+ const content = `line 1
78
+ line 2
79
+ const secret = "sk-live-1234567890abcdefghij";
80
+ line 4`;
81
+ const findings = scanForSecrets(content);
82
+ expect(findings.length).toBe(1);
83
+ expect(findings[0].line).toBe(3);
84
+ });
85
+ });
86
+
87
+ describe("formatSecrets", () => {
88
+ it("should format findings for terminal output", () => {
89
+ const findings = [
90
+ { line: 5, message: "Possible Stripe or OpenAI API key (sk-*)" },
91
+ ];
92
+ const output = formatSecrets(findings, "src/config.ts");
93
+ expect(output).toContain("STOP");
94
+ expect(output).toContain("1 potential secret(s)");
95
+ expect(output).toContain("L5");
96
+ expect(output).toContain("src/config.ts");
97
+ });
98
+
99
+ it("should return empty string for no findings", () => {
100
+ const output = formatSecrets([], "src/config.ts");
101
+ expect(output).toBe("");
102
+ });
103
+
104
+ it("should truncate at 5 findings", () => {
105
+ const findings = Array.from({ length: 10 }, (_, i) => ({
106
+ line: i + 1,
107
+ message: "Test secret",
108
+ }));
109
+ const output = formatSecrets(findings, "src/config.ts");
110
+ expect(output).toContain("10 potential secret(s)");
111
+ expect(output).toContain("... and 5 more");
112
+ });
113
+ });