pinata-security-cli 0.1.6 → 0.2.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/dist/cli/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, readFileSync, writeFileSync, chmodSync, mkdirSync } from 'fs';
3
- import fs, { mkdir, writeFile, readFile, readdir, stat } from 'fs/promises';
2
+ import { z } from 'zod';
3
+ import fs, { mkdir, writeFile, readFile, stat, readdir } from 'fs/promises';
4
4
  import path, { dirname, resolve, basename, relative, join, extname } from 'path';
5
5
  import { useState } from 'react';
6
6
  import { render, useApp, useInput, Box, Text } from 'ink';
7
7
  import Spinner from 'ink-spinner';
8
8
  import { jsx, jsxs } from 'react/jsx-runtime';
9
+ import { existsSync, readFileSync, writeFileSync, chmodSync, mkdirSync } from 'fs';
9
10
  import { homedir } from 'os';
10
- import { z } from 'zod';
11
11
  import { fileURLToPath } from 'url';
12
12
  import chalk5 from 'chalk';
13
13
  import { Command } from 'commander';
@@ -124,6 +124,35 @@ var init_result = __esm({
124
124
  "src/lib/result.ts"() {
125
125
  }
126
126
  });
127
+ var SEVERITY_WEIGHTS, CONFIDENCE_WEIGHTS, PRIORITY_WEIGHTS, DEFAULT_TEST_PATTERNS;
128
+ var init_types = __esm({
129
+ "src/core/scanner/types.ts"() {
130
+ SEVERITY_WEIGHTS = {
131
+ critical: 4,
132
+ high: 3,
133
+ medium: 2,
134
+ low: 1
135
+ };
136
+ CONFIDENCE_WEIGHTS = {
137
+ high: 1,
138
+ medium: 0.7,
139
+ low: 0.4
140
+ };
141
+ PRIORITY_WEIGHTS = {
142
+ P0: 3,
143
+ P1: 2,
144
+ P2: 1
145
+ };
146
+ DEFAULT_TEST_PATTERNS = {
147
+ python: ["test_*.py", "*_test.py", "tests/**/*.py", "test/**/*.py"],
148
+ typescript: ["*.test.ts", "*.spec.ts", "__tests__/**/*.ts", "tests/**/*.ts"],
149
+ javascript: ["*.test.js", "*.spec.js", "__tests__/**/*.js", "tests/**/*.js"],
150
+ go: ["*_test.go"],
151
+ java: ["*Test.java", "*Tests.java", "src/test/**/*.java"],
152
+ rust: ["tests/**/*.rs"]
153
+ };
154
+ }
155
+ });
127
156
  function getCachePath(projectRoot) {
128
157
  return resolve(projectRoot, CACHE_DIR, CACHE_FILE);
129
158
  }
@@ -131,9 +160,7 @@ async function saveScanResults(projectRoot, result) {
131
160
  try {
132
161
  const cacheDir = resolve(projectRoot, CACHE_DIR);
133
162
  const cachePath = getCachePath(projectRoot);
134
- if (!existsSync(cacheDir)) {
135
- await mkdir(cacheDir, { recursive: true });
136
- }
163
+ await mkdir(cacheDir, { recursive: true });
137
164
  const cached = {
138
165
  timestamp: result.completedAt.toISOString(),
139
166
  targetDirectory: result.targetDirectory,
@@ -158,7 +185,10 @@ async function saveScanResults(projectRoot, result) {
158
185
  async function loadScanResults(projectRoot) {
159
186
  try {
160
187
  const cachePath = getCachePath(projectRoot);
161
- if (!existsSync(cachePath)) {
188
+ let content;
189
+ try {
190
+ content = await readFile(cachePath, "utf-8");
191
+ } catch {
162
192
  return err(
163
193
  new PinataError(
164
194
  "No cached scan results found. Run `pinata analyze` first.",
@@ -166,7 +196,6 @@ async function loadScanResults(projectRoot) {
166
196
  )
167
197
  );
168
198
  }
169
- const content = await readFile(cachePath, "utf-8");
170
199
  const cached = JSON.parse(content);
171
200
  if (cached.version !== CACHE_VERSION) {
172
201
  return err(
@@ -712,6 +741,388 @@ var init_junit_formatter = __esm({
712
741
  "src/cli/junit-formatter.ts"() {
713
742
  }
714
743
  });
744
+
745
+ // src/core/verifier/ai-verifier.ts
746
+ var SKIP_PATTERNS, BATCH_PROMPT, SINGLE_ITEM_TEMPLATE, AIVerifier;
747
+ var init_ai_verifier = __esm({
748
+ "src/core/verifier/ai-verifier.ts"() {
749
+ init_types();
750
+ SKIP_PATTERNS = {
751
+ paths: [
752
+ /\.test\.(ts|js|tsx|jsx)$/,
753
+ /\.spec\.(ts|js|tsx|jsx)$/,
754
+ /tests?\//i,
755
+ /fixtures?\//i,
756
+ /mocks?\//i,
757
+ /__tests__\//,
758
+ /node_modules\//,
759
+ /dist\//,
760
+ /\.d\.ts$/,
761
+ /examples?\//i
762
+ ],
763
+ // Content patterns that indicate false positive
764
+ content: [
765
+ /\/\/ SAFE:/i,
766
+ // Explicit safe marker
767
+ /\/\/ nosec/i,
768
+ // Security ignore
769
+ /eslint-disable/i,
770
+ /sanitized?|escaped?|validated?/i
771
+ // Near sanitization
772
+ ]
773
+ };
774
+ BATCH_PROMPT = `You are a security code reviewer. Analyze these potential vulnerabilities and determine which are real issues vs false positives.
775
+
776
+ For each item, consider:
777
+ - Is user input actually reaching this code?
778
+ - Is there sanitization, validation, or encoding nearby?
779
+ - Is this test code, example code, or production code?
780
+ - Is there context that makes this safe?
781
+
782
+ Be rigorous. Most pattern matches are false positives.
783
+
784
+ ITEMS TO ANALYZE:
785
+ {{items}}
786
+
787
+ Respond with a JSON array. Each object MUST have these exact fields:
788
+ [
789
+ {
790
+ "id": "1",
791
+ "isVulnerable": true/false,
792
+ "confidence": "high"/"medium"/"low",
793
+ "reasoning": "brief explanation"
794
+ },
795
+ ...
796
+ ]
797
+
798
+ Only return the JSON array, no other text.`;
799
+ SINGLE_ITEM_TEMPLATE = `
800
+ ---
801
+ ID: {{id}}
802
+ CATEGORY: {{category}}
803
+ FILE: {{filePath}}:{{lineNumber}}
804
+ CODE:
805
+ \`\`\`{{language}}
806
+ {{codeContext}}
807
+ \`\`\`
808
+ FLAGGED LINE: {{flaggedLine}}
809
+ ---`;
810
+ AIVerifier = class {
811
+ config;
812
+ batchSize;
813
+ concurrency;
814
+ constructor(config2) {
815
+ this.config = config2;
816
+ this.batchSize = config2.batchSize ?? 10;
817
+ this.concurrency = config2.concurrency ?? 3;
818
+ }
819
+ /**
820
+ * Verify multiple gaps efficiently using filtering, batching, and parallelism.
821
+ *
822
+ * Flow:
823
+ * 1. Pre-filter obvious false positives (test files, etc.)
824
+ * 2. Group remaining gaps into batches of 10
825
+ * 3. Process 3 batches in parallel
826
+ * 4. Return verified gaps and dismissed with reasons
827
+ */
828
+ async verifyAll(gaps, getFileContent) {
829
+ const verified = [];
830
+ const dismissed = [];
831
+ const { toVerify, preFiltered } = this.preFilter(gaps);
832
+ dismissed.push(...preFiltered);
833
+ if (toVerify.length === 0) {
834
+ return {
835
+ verified: [],
836
+ dismissed,
837
+ stats: {
838
+ total: gaps.length,
839
+ preFiltered: preFiltered.length,
840
+ aiDismissed: 0,
841
+ aiVerified: 0
842
+ }
843
+ };
844
+ }
845
+ console.log(`Pre-filtered ${preFiltered.length} gaps. Verifying ${toVerify.length} with AI...`);
846
+ const fileContents = /* @__PURE__ */ new Map();
847
+ const uniquePaths = [...new Set(toVerify.map((g) => g.filePath))];
848
+ await Promise.all(
849
+ uniquePaths.map(async (path2) => {
850
+ try {
851
+ fileContents.set(path2, await getFileContent(path2));
852
+ } catch {
853
+ fileContents.set(path2, "");
854
+ }
855
+ })
856
+ );
857
+ const batches = this.createBatches(toVerify, fileContents);
858
+ console.log(`Created ${batches.length} batches of ~${this.batchSize} gaps each`);
859
+ const results = await this.processParallel(batches, toVerify);
860
+ let aiVerified = 0;
861
+ let aiDismissed = 0;
862
+ for (const gap of toVerify) {
863
+ const gapId = `${gap.filePath}:${gap.lineStart}`;
864
+ const result = results.get(gapId);
865
+ if (!result || result.isVulnerable) {
866
+ verified.push(gap);
867
+ aiVerified++;
868
+ } else {
869
+ dismissed.push({
870
+ gap,
871
+ reason: result.reasoning
872
+ });
873
+ aiDismissed++;
874
+ }
875
+ }
876
+ return {
877
+ verified,
878
+ dismissed,
879
+ stats: {
880
+ total: gaps.length,
881
+ preFiltered: preFiltered.length,
882
+ aiDismissed,
883
+ aiVerified
884
+ }
885
+ };
886
+ }
887
+ /**
888
+ * Pre-filter gaps that are obviously false positives without needing AI.
889
+ */
890
+ preFilter(gaps) {
891
+ const toVerify = [];
892
+ const preFiltered = [];
893
+ for (const gap of gaps) {
894
+ const pathMatch = SKIP_PATTERNS.paths.find((p) => p.test(gap.filePath));
895
+ if (pathMatch) {
896
+ preFiltered.push({
897
+ gap,
898
+ reason: `Skipped: test/example file (${pathMatch.source})`
899
+ });
900
+ continue;
901
+ }
902
+ if (gap.categoryId === "precision-loss" && gap.filePath.endsWith(".ts")) {
903
+ preFiltered.push({
904
+ gap,
905
+ reason: "TypeScript type annotation, not runtime code"
906
+ });
907
+ continue;
908
+ }
909
+ toVerify.push(gap);
910
+ }
911
+ return { toVerify, preFiltered };
912
+ }
913
+ /**
914
+ * Create batches of gaps for batch API calls.
915
+ */
916
+ createBatches(gaps, fileContents) {
917
+ const batches = [];
918
+ for (let i = 0; i < gaps.length; i += this.batchSize) {
919
+ const batchGaps = gaps.slice(i, i + this.batchSize);
920
+ const items = [];
921
+ const gapIds = [];
922
+ for (let j = 0; j < batchGaps.length; j++) {
923
+ const gap = batchGaps[j];
924
+ const content = fileContents.get(gap.filePath) ?? "";
925
+ const gapId = `${gap.filePath}:${gap.lineStart}`;
926
+ gapIds.push(gapId);
927
+ const codeContext = this.extractContext(content, gap.lineStart, 10);
928
+ const flaggedLine = this.extractLine(content, gap.lineStart);
929
+ items.push(
930
+ SINGLE_ITEM_TEMPLATE.replace("{{id}}", String(j + 1)).replace("{{category}}", gap.categoryName).replace("{{filePath}}", gap.filePath).replace("{{lineNumber}}", String(gap.lineStart)).replace("{{language}}", this.getLanguage(gap.filePath)).replace("{{codeContext}}", codeContext).replace("{{flaggedLine}}", flaggedLine)
931
+ );
932
+ }
933
+ const prompt = BATCH_PROMPT.replace("{{items}}", items.join("\n"));
934
+ batches.push({ prompt, gapIds });
935
+ }
936
+ return batches;
937
+ }
938
+ /**
939
+ * Process batches in parallel with limited concurrency.
940
+ */
941
+ async processParallel(batches, gaps) {
942
+ const results = /* @__PURE__ */ new Map();
943
+ let completed = 0;
944
+ for (let i = 0; i < batches.length; i += this.concurrency) {
945
+ const wave = batches.slice(i, i + this.concurrency);
946
+ const waveResults = await Promise.all(
947
+ wave.map(async (batch) => {
948
+ try {
949
+ const response = await this.callAI(batch.prompt);
950
+ return { gapIds: batch.gapIds, response };
951
+ } catch (error) {
952
+ console.error(`Batch failed: ${error instanceof Error ? error.message : String(error)}`);
953
+ return { gapIds: batch.gapIds, response: null };
954
+ }
955
+ })
956
+ );
957
+ for (const { gapIds, response } of waveResults) {
958
+ if (response) {
959
+ const parsed = this.parseBatchResponse(response);
960
+ for (let j = 0; j < gapIds.length && j < parsed.length; j++) {
961
+ const gapId = gapIds[j];
962
+ const result = parsed[j];
963
+ if (result) {
964
+ results.set(gapId, result);
965
+ }
966
+ }
967
+ }
968
+ }
969
+ completed += wave.length;
970
+ console.log(`Processed ${completed}/${batches.length} batches...`);
971
+ }
972
+ return results;
973
+ }
974
+ extractContext(content, lineNumber, radius) {
975
+ const lines = content.split("\n");
976
+ const start = Math.max(0, lineNumber - radius - 1);
977
+ const end = Math.min(lines.length, lineNumber + radius);
978
+ return lines.slice(start, end).map((line, i) => {
979
+ const num = start + i + 1;
980
+ const marker = num === lineNumber ? ">" : " ";
981
+ return `${marker}${num.toString().padStart(4)}| ${line}`;
982
+ }).join("\n");
983
+ }
984
+ extractLine(content, lineNumber) {
985
+ const lines = content.split("\n");
986
+ return lines[lineNumber - 1] ?? "";
987
+ }
988
+ getLanguage(filePath) {
989
+ if (filePath.endsWith(".ts") || filePath.endsWith(".tsx")) return "typescript";
990
+ if (filePath.endsWith(".js") || filePath.endsWith(".jsx")) return "javascript";
991
+ if (filePath.endsWith(".py")) return "python";
992
+ if (filePath.endsWith(".go")) return "go";
993
+ return "text";
994
+ }
995
+ async callAI(prompt) {
996
+ const apiKey = this.config.apiKey ?? this.getApiKeyFromEnv();
997
+ if (!apiKey) {
998
+ throw new Error(`No API key configured for ${this.config.provider}`);
999
+ }
1000
+ if (this.config.provider === "anthropic") {
1001
+ return this.callAnthropic(prompt, apiKey);
1002
+ } else {
1003
+ return this.callOpenAI(prompt, apiKey);
1004
+ }
1005
+ }
1006
+ async callAnthropic(prompt, apiKey) {
1007
+ const controller = new AbortController();
1008
+ const timeout = setTimeout(() => controller.abort(), 6e4);
1009
+ try {
1010
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
1011
+ method: "POST",
1012
+ headers: {
1013
+ "Content-Type": "application/json",
1014
+ "x-api-key": apiKey,
1015
+ "anthropic-version": "2023-06-01"
1016
+ },
1017
+ body: JSON.stringify({
1018
+ model: this.config.model ?? "claude-sonnet-4-20250514",
1019
+ max_tokens: 4096,
1020
+ // Larger for batch responses
1021
+ messages: [{ role: "user", content: prompt }]
1022
+ }),
1023
+ signal: controller.signal
1024
+ });
1025
+ if (!response.ok) {
1026
+ const body = await response.text();
1027
+ throw new Error(`Anthropic API error: ${response.status} - ${body}`);
1028
+ }
1029
+ const data = await response.json();
1030
+ return data.content[0]?.text ?? "";
1031
+ } finally {
1032
+ clearTimeout(timeout);
1033
+ }
1034
+ }
1035
+ async callOpenAI(prompt, apiKey) {
1036
+ const controller = new AbortController();
1037
+ const timeout = setTimeout(() => controller.abort(), 6e4);
1038
+ try {
1039
+ const response = await fetch("https://api.openai.com/v1/chat/completions", {
1040
+ method: "POST",
1041
+ headers: {
1042
+ "Content-Type": "application/json",
1043
+ Authorization: `Bearer ${apiKey}`
1044
+ },
1045
+ body: JSON.stringify({
1046
+ model: this.config.model ?? "gpt-4o",
1047
+ messages: [{ role: "user", content: prompt }],
1048
+ max_tokens: 4096
1049
+ }),
1050
+ signal: controller.signal
1051
+ });
1052
+ if (!response.ok) {
1053
+ const body = await response.text();
1054
+ throw new Error(`OpenAI API error: ${response.status} - ${body}`);
1055
+ }
1056
+ const data = await response.json();
1057
+ return data.choices[0]?.message?.content ?? "";
1058
+ } finally {
1059
+ clearTimeout(timeout);
1060
+ }
1061
+ }
1062
+ getApiKeyFromEnv() {
1063
+ if (this.config.provider === "anthropic") {
1064
+ return process.env["ANTHROPIC_API_KEY"] ?? "";
1065
+ }
1066
+ return process.env["OPENAI_API_KEY"] ?? "";
1067
+ }
1068
+ parseBatchResponse(response) {
1069
+ try {
1070
+ const jsonMatch = response.match(/\[[\s\S]*\]/);
1071
+ if (!jsonMatch) {
1072
+ console.error("No JSON array found in batch response");
1073
+ return [];
1074
+ }
1075
+ const parsed = JSON.parse(jsonMatch[0]);
1076
+ return parsed.map((item) => ({
1077
+ id: String(item.id),
1078
+ isVulnerable: Boolean(item.isVulnerable),
1079
+ confidence: item.confidence ?? "medium",
1080
+ reasoning: item.reasoning ?? "No reasoning provided"
1081
+ }));
1082
+ } catch (error) {
1083
+ console.error(`Failed to parse batch response: ${error instanceof Error ? error.message : String(error)}`);
1084
+ return [];
1085
+ }
1086
+ }
1087
+ /**
1088
+ * Legacy single-gap verification (kept for backwards compatibility).
1089
+ */
1090
+ async verify(gap, fileContent) {
1091
+ const result = await this.verifyAll([gap], async () => fileContent);
1092
+ if (result.verified.length > 0) {
1093
+ return {
1094
+ isVulnerable: true,
1095
+ confidence: "high",
1096
+ reasoning: "AI confirmed vulnerability",
1097
+ mitigatingFactors: [],
1098
+ exploitScenario: null,
1099
+ recommendation: "Fix this issue"
1100
+ };
1101
+ }
1102
+ const dismissal = result.dismissed[0];
1103
+ return {
1104
+ isVulnerable: false,
1105
+ confidence: "high",
1106
+ reasoning: dismissal?.reason ?? "AI dismissed as false positive",
1107
+ mitigatingFactors: [],
1108
+ exploitScenario: null,
1109
+ recommendation: "No action needed"
1110
+ };
1111
+ }
1112
+ };
1113
+ }
1114
+ });
1115
+
1116
+ // src/core/verifier/index.ts
1117
+ var verifier_exports = {};
1118
+ __export(verifier_exports, {
1119
+ AIVerifier: () => AIVerifier
1120
+ });
1121
+ var init_verifier = __esm({
1122
+ "src/core/verifier/index.ts"() {
1123
+ init_ai_verifier();
1124
+ }
1125
+ });
715
1126
  function App({ results, loading, error }) {
716
1127
  const { exit } = useApp();
717
1128
  const [selectedIndex, setSelectedIndex] = useState(0);
@@ -1062,15 +1473,10 @@ __export(config_exports, {
1062
1473
  validateApiKey: () => validateApiKey
1063
1474
  });
1064
1475
  function ensureConfigDir() {
1065
- if (!existsSync(CONFIG_DIR)) {
1066
- mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
1067
- }
1476
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
1068
1477
  }
1069
1478
  function loadConfig() {
1070
1479
  try {
1071
- if (!existsSync(CONFIG_FILE)) {
1072
- return {};
1073
- }
1074
1480
  const content = readFileSync(CONFIG_FILE, "utf-8");
1075
1481
  const parsed = JSON.parse(content);
1076
1482
  const result = ConfigSchema.safeParse(parsed);
@@ -2556,32 +2962,7 @@ function detectLanguage(filePath) {
2556
2962
  }
2557
2963
  init_errors();
2558
2964
  init_result();
2559
- var SEVERITY_WEIGHTS = {
2560
- critical: 4,
2561
- high: 3,
2562
- medium: 2,
2563
- low: 1
2564
- };
2565
- var CONFIDENCE_WEIGHTS = {
2566
- high: 1,
2567
- medium: 0.7,
2568
- low: 0.4
2569
- };
2570
- var PRIORITY_WEIGHTS = {
2571
- P0: 3,
2572
- P1: 2,
2573
- P2: 1
2574
- };
2575
- var DEFAULT_TEST_PATTERNS = {
2576
- python: ["test_*.py", "*_test.py", "tests/**/*.py", "test/**/*.py"],
2577
- typescript: ["*.test.ts", "*.spec.ts", "__tests__/**/*.ts", "tests/**/*.ts"],
2578
- javascript: ["*.test.js", "*.spec.js", "__tests__/**/*.js", "tests/**/*.js"],
2579
- go: ["*_test.go"],
2580
- java: ["*Test.java", "*Tests.java", "src/test/**/*.java"],
2581
- rust: ["tests/**/*.rs"]
2582
- };
2583
-
2584
- // src/core/scanner/scanner.ts
2965
+ init_types();
2585
2966
  var DEFAULT_OPTIONS = {
2586
2967
  excludeDirs: [
2587
2968
  // Package managers
@@ -2656,7 +3037,12 @@ var Scanner = class {
2656
3037
  const startedAt = /* @__PURE__ */ new Date();
2657
3038
  const opts = this.mergeOptions(targetDirectory, options);
2658
3039
  this.log.info(`Starting scan of ${targetDirectory}`);
2659
- if (!existsSync(targetDirectory)) {
3040
+ try {
3041
+ const dirStat = await stat(targetDirectory);
3042
+ if (!dirStat.isDirectory()) {
3043
+ return err(new AnalysisError(`Not a directory: ${targetDirectory}`));
3044
+ }
3045
+ } catch {
2660
3046
  return err(new AnalysisError(`Directory not found: ${targetDirectory}`));
2661
3047
  }
2662
3048
  const categoriesResult = this.getCategoriesToScan(opts);
@@ -2852,9 +3238,6 @@ var Scanner = class {
2852
3238
  */
2853
3239
  readPinataIgnore(targetDirectory) {
2854
3240
  const ignorePath = resolve(targetDirectory, ".pinataignore");
2855
- if (!existsSync(ignorePath)) {
2856
- return [];
2857
- }
2858
3241
  try {
2859
3242
  const { readFileSync: readFileSync2 } = __require("fs");
2860
3243
  const content = readFileSync2(ignorePath, "utf-8");
@@ -3189,8 +3572,11 @@ function createScanner(categoryStore) {
3189
3572
  return new Scanner(categoryStore);
3190
3573
  }
3191
3574
 
3575
+ // src/core/scanner/index.ts
3576
+ init_types();
3577
+
3192
3578
  // src/core/index.ts
3193
- var VERSION = "0.1.6";
3579
+ var VERSION = "0.2.1";
3194
3580
 
3195
3581
  // src/lib/index.ts
3196
3582
  init_errors();
@@ -4111,11 +4497,11 @@ var AIService = class {
4111
4497
  */
4112
4498
  getApiKeyFromConfig(provider) {
4113
4499
  try {
4114
- const { existsSync: existsSync7, readFileSync: readFileSync2 } = __require("fs");
4500
+ const { existsSync: existsSync4, readFileSync: readFileSync2 } = __require("fs");
4115
4501
  const { homedir: homedir2 } = __require("os");
4116
4502
  const { join: join3 } = __require("path");
4117
4503
  const configPath = join3(homedir2(), ".pinata", "config.json");
4118
- if (!existsSync7(configPath)) {
4504
+ if (!existsSync4(configPath)) {
4119
4505
  return "";
4120
4506
  }
4121
4507
  const content = readFileSync2(configPath, "utf-8");
@@ -4762,13 +5148,13 @@ async function writeGeneratedTests(tests, basePath, outputDir) {
4762
5148
  for (const [outputPath, fileTests] of testsByFile) {
4763
5149
  try {
4764
5150
  const dir = dirname(outputPath);
4765
- if (!existsSync(dir)) {
4766
- await mkdir(dir, { recursive: true });
4767
- }
4768
- const fileExists = existsSync(outputPath);
5151
+ await mkdir(dir, { recursive: true });
4769
5152
  let existingContent = "";
4770
- if (fileExists) {
5153
+ let fileExists = false;
5154
+ try {
4771
5155
  existingContent = await readFile(outputPath, "utf-8");
5156
+ fileExists = true;
5157
+ } catch {
4772
5158
  }
4773
5159
  const contentParts = [];
4774
5160
  const allImports = /* @__PURE__ */ new Set();
@@ -5502,7 +5888,7 @@ function getDefinitionsPath() {
5502
5888
  }
5503
5889
  var program = new Command();
5504
5890
  program.name("pinata").description("AI-powered test coverage analysis and generation").version(VERSION);
5505
- program.command("analyze [path]").description("Analyze codebase for test coverage gaps").option("-o, --output <format>", "Output format: terminal, json, markdown, sarif, html, junit-xml", "terminal").option("-d, --domains <domains>", "Filter to specific domains (comma-separated)").option("-s, --severity <level>", "Minimum severity: critical, high, medium, low", "low").option("-c, --confidence <level>", "Minimum confidence: high, medium, low", "high").option("--fail-on <level>", "Exit non-zero if gaps at level: critical, high, medium").option("--exclude <dirs>", "Directories to exclude (comma-separated)").option("-v, --verbose", "Verbose output").option("-q, --quiet", "Quiet mode (errors only)").action(async (targetPath, options) => {
5891
+ program.command("analyze [path]").description("Analyze codebase for test coverage gaps").option("-o, --output <format>", "Output format: terminal, json, markdown, sarif, html, junit-xml", "terminal").option("-d, --domains <domains>", "Filter to specific domains (comma-separated)").option("-s, --severity <level>", "Minimum severity: critical, high, medium, low", "low").option("-c, --confidence <level>", "Minimum confidence: high, medium, low", "high").option("--fail-on <level>", "Exit non-zero if gaps at level: critical, high, medium").option("--exclude <dirs>", "Directories to exclude (comma-separated)").option("--verify", "Use AI to verify each match (reduces false positives)").option("-v, --verbose", "Verbose output").option("-q, --quiet", "Quiet mode (errors only)").action(async (targetPath, options) => {
5506
5892
  const isQuiet = Boolean(options["quiet"]);
5507
5893
  const isVerbose = Boolean(options["verbose"]);
5508
5894
  if (isQuiet) {
@@ -5586,6 +5972,47 @@ program.command("analyze [path]").description("Analyze codebase for test coverag
5586
5972
  process.exit(1);
5587
5973
  }
5588
5974
  spinner?.stop();
5975
+ const shouldVerify = Boolean(options["verify"]);
5976
+ if (shouldVerify && scanResult.data.gaps.length > 0) {
5977
+ const verifySpinner = showSpinner ? ora("Verifying gaps with AI...").start() : null;
5978
+ try {
5979
+ const { AIVerifier: AIVerifier2 } = await Promise.resolve().then(() => (init_verifier(), verifier_exports));
5980
+ const { readFile: readFile5 } = await import('fs/promises');
5981
+ const verifier = new AIVerifier2({ provider: "anthropic" });
5982
+ const { verified, dismissed, stats } = await verifier.verifyAll(
5983
+ scanResult.data.gaps,
5984
+ async (path2) => readFile5(path2, "utf-8")
5985
+ );
5986
+ scanResult.data.gaps = verified;
5987
+ const severityWeights = { critical: 10, high: 5, medium: 2, low: 1 };
5988
+ let deduction = 0;
5989
+ for (const gap of verified) {
5990
+ deduction += severityWeights[gap.severity] ?? 1;
5991
+ }
5992
+ const newOverall = Math.max(0, 100 - deduction);
5993
+ const newGrade = newOverall >= 90 ? "A" : newOverall >= 80 ? "B" : newOverall >= 70 ? "C" : newOverall >= 60 ? "D" : "F";
5994
+ scanResult.data.score.overall = newOverall;
5995
+ scanResult.data.score.grade = newGrade;
5996
+ verifySpinner?.succeed(
5997
+ `AI Verification: ${stats.total} total \u2192 ${stats.preFiltered} pre-filtered \u2192 ${stats.aiVerified} verified, ${stats.aiDismissed} AI-dismissed`
5998
+ );
5999
+ if (isVerbose && dismissed.length > 0) {
6000
+ console.log(chalk5.gray("\nDismissed as false positives:"));
6001
+ for (const { gap, reason } of dismissed.slice(0, 5)) {
6002
+ console.log(chalk5.gray(` - ${gap.categoryName} at ${gap.filePath}:${gap.lineStart}`));
6003
+ console.log(chalk5.gray(` Reason: ${reason.slice(0, 100)}...`));
6004
+ }
6005
+ if (dismissed.length > 5) {
6006
+ console.log(chalk5.gray(` ... and ${dismissed.length - 5} more`));
6007
+ }
6008
+ }
6009
+ } catch (error) {
6010
+ verifySpinner?.fail("AI verification failed (results unverified)");
6011
+ if (isVerbose) {
6012
+ console.error(chalk5.yellow(`Verification error: ${error instanceof Error ? error.message : String(error)}`));
6013
+ }
6014
+ }
6015
+ }
5589
6016
  const cacheResult = await saveScanResults(process.cwd(), scanResult.data);
5590
6017
  if (!cacheResult.success) {
5591
6018
  logger.debug(`Failed to cache results: ${cacheResult.error.message}`);