eslint-plugin-ai-guard 1.1.14 → 1.2.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/dist/cli/index.js CHANGED
@@ -382,13 +382,53 @@ var RULE_CATEGORY = {
382
382
  "ai-guard/no-duplicate-logic-block": "AI Patterns",
383
383
  "ai-guard/no-dead-branch": "AI Patterns"
384
384
  };
385
+ var CONFIDENCE_TIER = {
386
+ // High confidence — near-zero false positive rate
387
+ "ai-guard/no-hardcoded-secret": "high",
388
+ "ai-guard/no-eval-dynamic": "high",
389
+ "ai-guard/no-floating-promise": "high",
390
+ "ai-guard/no-empty-catch": "high",
391
+ // Medium confidence — context matters, usually valid
392
+ "ai-guard/no-sql-string-concat": "medium",
393
+ "ai-guard/no-await-in-loop": "medium",
394
+ "ai-guard/require-auth-middleware": "medium",
395
+ "ai-guard/require-authz-check": "medium",
396
+ "ai-guard/no-catch-log-rethrow": "medium",
397
+ "ai-guard/no-catch-without-use": "medium",
398
+ "ai-guard/no-unsafe-deserialize": "medium",
399
+ // Low confidence — suggestions, may be intentional
400
+ "ai-guard/no-async-without-await": "low",
401
+ "ai-guard/no-async-array-callback": "low",
402
+ "ai-guard/no-dead-branch": "low",
403
+ "ai-guard/no-broad-exception": "low",
404
+ "ai-guard/no-console-in-handler": "low",
405
+ "ai-guard/no-duplicate-logic-block": "low",
406
+ "ai-guard/no-redundant-await": "low"
407
+ };
408
+ var ECOSYSTEM_FIX_HINTS = {
409
+ "react-hooks/exhaustive-deps": "npm install --save-dev eslint-plugin-react-hooks",
410
+ "react-hooks/rules-of-hooks": "npm install --save-dev eslint-plugin-react-hooks",
411
+ "@typescript-eslint/": "npm install --save-dev @typescript-eslint/eslint-plugin @typescript-eslint/parser",
412
+ "import/": "npm install --save-dev eslint-plugin-import",
413
+ "jsx-a11y/": "npm install --save-dev eslint-plugin-jsx-a11y",
414
+ "prettier/": "npm install --save-dev eslint-plugin-prettier"
415
+ };
416
+ function getEcosystemFix(ruleId) {
417
+ if (!ruleId) return null;
418
+ for (const [prefix, fix] of Object.entries(ECOSYSTEM_FIX_HINTS)) {
419
+ if (ruleId.startsWith(prefix)) return fix;
420
+ }
421
+ return null;
422
+ }
385
423
  var PREFIX = {
386
424
  success: import_chalk.default.green("\u2714"),
387
425
  error: import_chalk.default.red("\u2716"),
388
426
  warn: import_chalk.default.yellow("\u26A0"),
389
427
  info: import_chalk.default.cyan("\u2139"),
390
428
  section: import_chalk.default.bold.white,
391
- bullet: import_chalk.default.gray("\u2022")
429
+ bullet: import_chalk.default.gray("\u2022"),
430
+ ecosystem: import_chalk.default.yellow("\u26A0"),
431
+ parser: import_chalk.default.magenta("\u26A1")
392
432
  };
393
433
  var log = {
394
434
  info(msg) {
@@ -408,6 +448,14 @@ var log = {
408
448
  console.log(import_chalk.default.bold.cyan(` \u2500\u2500 ${title} \u2500\u2500`));
409
449
  console.log("");
410
450
  },
451
+ /**
452
+ * Ecosystem section — distinct styling to communicate it's NOT ai-guard output
453
+ */
454
+ ecosystemSection(title) {
455
+ console.log("");
456
+ console.log(import_chalk.default.bold.yellow(` \u2500\u2500 ${title} \u2500\u2500`) + import_chalk.default.dim(" (not ai-guard findings)"));
457
+ console.log("");
458
+ },
411
459
  rule(ruleName, count) {
412
460
  console.log(
413
461
  ` ${PREFIX.bullet} ${import_chalk.default.yellow(ruleName)} ${import_chalk.default.gray(`(${count} issue${count !== 1 ? "s" : ""})`)}`
@@ -424,9 +472,36 @@ var log = {
424
472
  ` ${import_chalk.default.gray(`${line}:${col}`).padEnd(12)} ${icon} ${import_chalk.default.white(msg)}`
425
473
  );
426
474
  },
475
+ /**
476
+ * Print an ESLint ecosystem issue (missing rule definition, foreign plugin rule, etc.)
477
+ * with a clear disclaimer that this is NOT an ai-guard finding.
478
+ */
479
+ ecosystemIssue(issue, count = 1) {
480
+ const ruleLabel = issue.ruleId ? import_chalk.default.yellow(issue.ruleId) : import_chalk.default.gray("(no rule id)");
481
+ const typeLabel = issue.type === "missing-rule" ? import_chalk.default.dim("[missing rule definition]") : issue.type === "config-error" ? import_chalk.default.dim("[config error]") : import_chalk.default.dim("[foreign rule]");
482
+ const countLabel = count > 1 ? import_chalk.default.gray(` \xD7 ${count}`) : "";
483
+ console.log(` ${PREFIX.ecosystem} ${ruleLabel} ${typeLabel}${countLabel}`);
484
+ console.log(` ${import_chalk.default.gray("File:")} ${import_chalk.default.dim(issue.filePath)}${issue.line > 0 ? import_chalk.default.dim(`:${issue.line}`) : ""}`);
485
+ console.log(` ${import_chalk.default.gray("Msg:")} ${import_chalk.default.dim(issue.message.slice(0, 120))}`);
486
+ const fix = getEcosystemFix(issue.ruleId);
487
+ if (fix) {
488
+ console.log(` ${import_chalk.default.cyan("\u2192 Fix:")} ${import_chalk.default.yellow(fix)}`);
489
+ }
490
+ console.log(
491
+ import_chalk.default.dim(` Note: This comes from your ESLint config, not from ai-guard.`)
492
+ );
493
+ console.log("");
494
+ },
495
+ /**
496
+ * Print a parser/syntax error — clearly NOT an ai-guard finding.
497
+ */
498
+ parserError(pe) {
499
+ console.log(` ${PREFIX.parser} ${import_chalk.default.magenta("Parse failure")} ${import_chalk.default.dim(pe.filePath)}${pe.line > 0 ? import_chalk.default.dim(`:${pe.line}`) : ""}`);
500
+ console.log(` ${import_chalk.default.dim(pe.message.slice(0, 120))}`);
501
+ console.log("");
502
+ },
427
503
  /**
428
504
  * Print a category summary line with icon, label, and count.
429
- * e.g. 🔴 Security 3 errors · 0 warnings
430
505
  */
431
506
  category(icon, label, errors, warnings) {
432
507
  const parts = [];
@@ -437,7 +512,7 @@ var log = {
437
512
  console.log(` ${icon} ${import_chalk.default.bold.white(labelPadded)} ${counts}`);
438
513
  },
439
514
  /**
440
- * Print the scan stats line: files scanned, duration, preset.
515
+ * Print the scan stats line.
441
516
  */
442
517
  scanStats(filesScanned, filesWithIssues, durationMs, preset) {
443
518
  console.log(
@@ -547,9 +622,27 @@ var DEFAULT_IGNORE_PATTERNS = [
547
622
  ];
548
623
  function isSkippablePatternError(error) {
549
624
  const msg = error.message.toLowerCase();
550
- const hasNoFilesSignal = msg.includes("no files") || msg.includes("no files matching");
551
- const hasIgnoredSignal = msg.includes("ignored") || msg.includes("all files matched by") || msg.includes("are ignored") || msg.includes("was ignored") || msg.includes("file ignored");
552
- return hasNoFilesSignal || hasIgnoredSignal;
625
+ return msg.includes("no files") || msg.includes("no files matching") || msg.includes("ignored") || msg.includes("all files matched by") || msg.includes("are ignored") || msg.includes("was ignored") || msg.includes("file ignored");
626
+ }
627
+ function classifyMessage(msg) {
628
+ if (msg.fatal === true) return "parser-error";
629
+ if (msg.ruleId === null && (msg.message.startsWith("Parsing error") || msg.message.includes("Unexpected token") || msg.message.includes("SyntaxError"))) {
630
+ return "parser-error";
631
+ }
632
+ if (msg.ruleId && msg.ruleId.startsWith("ai-guard/")) {
633
+ return "ai-guard";
634
+ }
635
+ return "ecosystem";
636
+ }
637
+ function classifyEcosystemType(msg) {
638
+ const lower = msg.message.toLowerCase();
639
+ if (lower.includes("definition for rule") && lower.includes("was not found")) {
640
+ return "missing-rule";
641
+ }
642
+ if (lower.includes("configuration") || lower.includes("config")) {
643
+ return "config-error";
644
+ }
645
+ return "foreign-rule";
553
646
  }
554
647
  async function loadPluginModuleFromCwd(cwd) {
555
648
  const { createRequire: createRequire2 } = await import("module");
@@ -577,13 +670,22 @@ async function loadPluginModuleFromCwd(cwd) {
577
670
  );
578
671
  }
579
672
  async function runEslint(options) {
673
+ const overallStart = Date.now();
674
+ const timing = {
675
+ pluginLoadMs: 0,
676
+ parserLoadMs: 0,
677
+ lintMs: 0,
678
+ processMs: 0
679
+ };
580
680
  const { ESLint } = await import("eslint").catch(() => {
581
681
  throw new Error(
582
682
  "ESLint is not installed. Run: npm install --save-dev eslint"
583
683
  );
584
684
  });
685
+ const pluginStart = Date.now();
585
686
  const rawPlugin = await loadPluginModuleFromCwd(process.cwd());
586
687
  const plugin = normalizePlugin(rawPlugin);
688
+ timing.pluginLoadMs = Date.now() - pluginStart;
587
689
  const rules = getRules(options.preset);
588
690
  const resolvedTargetPath = import_path.default.resolve(options.targetPath);
589
691
  if (!import_fs.default.existsSync(resolvedTargetPath)) {
@@ -592,18 +694,9 @@ async function runEslint(options) {
592
694
  const targetStat = import_fs.default.statSync(resolvedTargetPath);
593
695
  const isSingleFileTarget = targetStat.isFile();
594
696
  const eslintCwd = isSingleFileTarget ? import_path.default.dirname(resolvedTargetPath) : resolvedTargetPath;
595
- const startMs = Date.now();
596
- const JS_TS_FILES = [
597
- "**/*.js",
598
- "**/*.jsx",
599
- "**/*.ts",
600
- "**/*.tsx",
601
- "**/*.mts",
602
- "**/*.cts",
603
- "**/*.mjs",
604
- "**/*.cjs"
605
- ];
697
+ const parserStart = Date.now();
606
698
  let tsParser = null;
699
+ let tsParserAvailable = false;
607
700
  try {
608
701
  try {
609
702
  tsParser = require(import_path.default.join(
@@ -615,10 +708,23 @@ async function runEslint(options) {
615
708
  } catch {
616
709
  tsParser = require("@typescript-eslint/parser");
617
710
  }
711
+ tsParserAvailable = true;
618
712
  } catch {
713
+ log.debug("@typescript-eslint/parser not found \u2014 TypeScript files will use espree fallback");
619
714
  }
715
+ timing.parserLoadMs = Date.now() - parserStart;
716
+ const JS_TS_FILES = [
717
+ "**/*.js",
718
+ "**/*.jsx",
719
+ "**/*.ts",
720
+ "**/*.tsx",
721
+ "**/*.mts",
722
+ "**/*.cts",
723
+ "**/*.mjs",
724
+ "**/*.cjs"
725
+ ];
620
726
  const configBlocks = [
621
- // JS/JSX files — default espree parser
727
+ // JS/JSX files — default espree parser with JSX support
622
728
  {
623
729
  files: ["**/*.js", "**/*.jsx", "**/*.mjs", "**/*.cjs"],
624
730
  plugins: { "ai-guard": plugin },
@@ -641,9 +747,9 @@ async function runEslint(options) {
641
747
  languageOptions: {
642
748
  parser: tsParser,
643
749
  parserOptions: {
644
- // project: true would be needed for type-aware rules — skip for speed
645
750
  ecmaVersion: "latest",
646
- sourceType: "module"
751
+ sourceType: "module",
752
+ ecmaFeatures: { jsx: true }
647
753
  }
648
754
  },
649
755
  rules
@@ -652,6 +758,13 @@ async function runEslint(options) {
652
758
  configBlocks.splice(1, 0, {
653
759
  files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"],
654
760
  plugins: { "ai-guard": plugin },
761
+ languageOptions: {
762
+ parserOptions: {
763
+ ecmaVersion: "latest",
764
+ sourceType: "module",
765
+ ecmaFeatures: { jsx: true }
766
+ }
767
+ },
655
768
  rules
656
769
  });
657
770
  }
@@ -661,50 +774,107 @@ async function runEslint(options) {
661
774
  overrideConfig: configBlocks
662
775
  });
663
776
  const patterns = isSingleFileTarget ? [import_path.default.basename(resolvedTargetPath)] : JS_TS_FILES;
664
- const perPatternResults = await Promise.all(
665
- patterns.map(async (pattern) => {
666
- try {
667
- return await eslint.lintFiles([pattern]);
668
- } catch (err) {
669
- const error = err instanceof Error ? err : new Error(String(err));
670
- if (isSkippablePatternError(error)) {
671
- log.debug(`Skipping pattern '${pattern}' (no lintable files)`);
672
- return [];
673
- }
674
- throw error;
675
- }
676
- })
677
- );
678
- const rawResults = perPatternResults.flat();
679
- const durationMs = Date.now() - startMs;
777
+ const lintStart = Date.now();
778
+ let rawResults;
779
+ try {
780
+ rawResults = await eslint.lintFiles(patterns);
781
+ } catch (err) {
782
+ const error = err instanceof Error ? err : new Error(String(err));
783
+ if (isSkippablePatternError(error)) {
784
+ log.debug("Single-call lintFiles failed with no-files, falling back to per-pattern");
785
+ const perPatternResults = await Promise.all(
786
+ patterns.map(async (pattern) => {
787
+ try {
788
+ return await eslint.lintFiles([pattern]);
789
+ } catch (patternErr) {
790
+ const patternError = patternErr instanceof Error ? patternErr : new Error(String(patternErr));
791
+ if (isSkippablePatternError(patternError)) {
792
+ log.debug(`Skipping pattern '${pattern}' (no lintable files)`);
793
+ return [];
794
+ }
795
+ throw patternError;
796
+ }
797
+ })
798
+ );
799
+ rawResults = perPatternResults.flat();
800
+ } else {
801
+ throw error;
802
+ }
803
+ }
804
+ timing.lintMs = Date.now() - lintStart;
805
+ const processStart = Date.now();
680
806
  const files = [];
807
+ const ecosystemIssues = [];
808
+ const parserErrors = [];
681
809
  const ruleBreakdown = /* @__PURE__ */ new Map();
682
810
  let totalErrors = 0;
683
811
  let totalWarnings = 0;
684
812
  const filesScanned = rawResults.length;
685
813
  for (const result of rawResults) {
686
814
  if (result.messages.length === 0) continue;
687
- const issues = result.messages.map((m) => ({
688
- ruleId: m.ruleId ?? "unknown",
689
- severity: m.severity,
690
- message: m.message,
691
- line: m.line,
692
- column: m.column
693
- }));
694
- const errorCount = result.errorCount;
695
- const warningCount = result.warningCount;
696
- totalErrors += errorCount;
697
- totalWarnings += warningCount;
698
- for (const issue of issues) {
815
+ const relPath = import_path.default.relative(process.cwd(), result.filePath);
816
+ const aiGuardIssues = [];
817
+ let fileErrors = 0;
818
+ let fileWarnings = 0;
819
+ for (const m of result.messages) {
820
+ const source = classifyMessage(m);
821
+ if (source === "parser-error") {
822
+ parserErrors.push({
823
+ filePath: relPath,
824
+ message: m.message,
825
+ line: m.line ?? 0
826
+ });
827
+ continue;
828
+ }
829
+ if (source === "ecosystem") {
830
+ ecosystemIssues.push({
831
+ type: classifyEcosystemType(m),
832
+ ruleId: m.ruleId,
833
+ message: m.message,
834
+ filePath: relPath,
835
+ line: m.line ?? 0,
836
+ column: m.column ?? 0
837
+ });
838
+ continue;
839
+ }
840
+ const issue = {
841
+ ruleId: m.ruleId ?? "unknown",
842
+ severity: m.severity,
843
+ message: m.message,
844
+ line: m.line,
845
+ column: m.column
846
+ };
847
+ aiGuardIssues.push(issue);
848
+ if (m.severity === 2) {
849
+ fileErrors++;
850
+ totalErrors++;
851
+ } else {
852
+ fileWarnings++;
853
+ totalWarnings++;
854
+ }
699
855
  ruleBreakdown.set(
700
856
  issue.ruleId,
701
857
  (ruleBreakdown.get(issue.ruleId) ?? 0) + 1
702
858
  );
703
859
  }
704
- const relPath = import_path.default.relative(process.cwd(), result.filePath);
705
- files.push({ filePath: relPath, issues, errorCount, warningCount });
860
+ if (aiGuardIssues.length > 0) {
861
+ files.push({
862
+ filePath: relPath,
863
+ issues: aiGuardIssues,
864
+ errorCount: fileErrors,
865
+ warningCount: fileWarnings
866
+ });
867
+ }
706
868
  }
707
869
  const topFiles = [...files].sort((a, b) => b.issues.length - a.issues.length).slice(0, 10).map((f) => ({ path: f.filePath, count: f.issues.length }));
870
+ timing.processMs = Date.now() - processStart;
871
+ if (options.debugTiming) {
872
+ log.debug(`Plugin load: ${timing.pluginLoadMs}ms`);
873
+ log.debug(`Parser load: ${timing.parserLoadMs}ms`);
874
+ log.debug(`Lint: ${timing.lintMs}ms`);
875
+ log.debug(`Process: ${timing.processMs}ms`);
876
+ log.debug(`Total: ${Date.now() - overallStart}ms`);
877
+ }
708
878
  return {
709
879
  files,
710
880
  totalErrors,
@@ -713,7 +883,11 @@ async function runEslint(options) {
713
883
  filesScanned,
714
884
  ruleBreakdown,
715
885
  topFiles,
716
- durationMs
886
+ durationMs: Date.now() - overallStart,
887
+ timing,
888
+ ecosystemIssues,
889
+ parserErrors,
890
+ tsParserAvailable
717
891
  };
718
892
  }
719
893
 
@@ -723,7 +897,7 @@ function registerRunCommand(program2) {
723
897
  "--max-warnings <n>",
724
898
  "Fail with exit code 1 if warnings exceed this count",
725
899
  (value) => Number.parseInt(value, 10)
726
- ).action(async (opts) => {
900
+ ).option("--verbose", "Show all issues \u2014 disable grouping of repeated warnings").option("--debug-timing", "Print per-phase timing diagnostics").action(async (opts) => {
727
901
  if (opts.maxWarnings !== void 0 && (!Number.isInteger(opts.maxWarnings) || opts.maxWarnings < 0)) {
728
902
  log.blank();
729
903
  log.error("--max-warnings must be a non-negative integer.");
@@ -743,7 +917,11 @@ function registerRunCommand(program2) {
743
917
  const spinner = opts.json ? null : (0, import_ora.default)({ text: "Scanning\u2026", color: "cyan" }).start();
744
918
  let result;
745
919
  try {
746
- result = await runEslint({ preset, targetPath: opts.path });
920
+ result = await runEslint({
921
+ preset,
922
+ targetPath: opts.path,
923
+ debugTiming: opts.debugTiming
924
+ });
747
925
  spinner?.stop();
748
926
  } catch (err) {
749
927
  spinner?.stop();
@@ -775,17 +953,26 @@ function registerRunCommand(program2) {
775
953
  totalWarnings: result.totalWarnings,
776
954
  totalIssues: result.totalIssues,
777
955
  durationMs: result.durationMs,
956
+ timing: result.timing,
778
957
  ruleBreakdown: Object.fromEntries(result.ruleBreakdown),
779
958
  topFiles: result.topFiles,
780
- files: result.files
959
+ files: result.files,
960
+ ecosystemIssues: result.ecosystemIssues,
961
+ parserErrors: result.parserErrors,
962
+ tsParserAvailable: result.tsParserAvailable
781
963
  };
782
964
  console.log(JSON.stringify(jsonOutput, null, 2));
783
965
  process.exit(getRunExitCode(result, opts.maxWarnings));
784
966
  return;
785
967
  }
786
968
  log.scanStats(result.filesScanned, result.files.length, result.durationMs, preset);
969
+ if (!result.tsParserAvailable) {
970
+ log.print(
971
+ import_chalk2.default.dim(` \u2139 TypeScript parser not found \u2014 .ts/.tsx files use espree fallback`) + import_chalk2.default.dim(` (install @typescript-eslint/parser for full TS support)`)
972
+ );
973
+ }
787
974
  log.blank();
788
- if (result.totalIssues === 0) {
975
+ if (result.totalIssues === 0 && result.ecosystemIssues.length === 0 && result.parserErrors.length === 0) {
789
976
  log.print(
790
977
  ` ${import_chalk2.default.green("\u2714")} ${import_chalk2.default.bold.green("No AI issues found")} ${import_chalk2.default.gray(`\u2014 ${result.filesScanned} file${result.filesScanned !== 1 ? "s" : ""} scanned, all clean`)}`
791
978
  );
@@ -793,80 +980,117 @@ function registerRunCommand(program2) {
793
980
  process.exit(0);
794
981
  return;
795
982
  }
796
- log.section("Summary by Category");
797
- const categoryErrors = {};
798
- const categoryWarnings = {};
799
- for (const file of result.files) {
800
- for (const issue of file.issues) {
801
- const cat = RULE_CATEGORY[issue.ruleId] ?? "Other";
802
- if (issue.severity === 2) {
803
- categoryErrors[cat] = (categoryErrors[cat] ?? 0) + 1;
804
- } else {
805
- categoryWarnings[cat] = (categoryWarnings[cat] ?? 0) + 1;
983
+ if (result.totalIssues === 0) {
984
+ log.print(
985
+ ` ${import_chalk2.default.green("\u2714")} ${import_chalk2.default.bold.green("No AI issues found")} ${import_chalk2.default.gray(`\u2014 ${result.filesScanned} file${result.filesScanned !== 1 ? "s" : ""} scanned, all clean`)}`
986
+ );
987
+ log.blank();
988
+ }
989
+ if (result.totalIssues > 0) {
990
+ log.section("Summary by Category");
991
+ const categoryErrors = {};
992
+ const categoryWarnings = {};
993
+ for (const file of result.files) {
994
+ for (const issue of file.issues) {
995
+ const cat = RULE_CATEGORY[issue.ruleId] ?? "Other";
996
+ if (issue.severity === 2) {
997
+ categoryErrors[cat] = (categoryErrors[cat] ?? 0) + 1;
998
+ } else {
999
+ categoryWarnings[cat] = (categoryWarnings[cat] ?? 0) + 1;
1000
+ }
806
1001
  }
807
1002
  }
808
- }
809
- const allCategories = /* @__PURE__ */ new Set([
810
- ...CATEGORY_ORDER,
811
- ...Object.keys(categoryErrors),
812
- ...Object.keys(categoryWarnings)
813
- ]);
814
- const sortedCategories = [...allCategories].filter(
815
- (cat) => (categoryErrors[cat] ?? 0) + (categoryWarnings[cat] ?? 0) > 0
816
- );
817
- sortedCategories.sort((a, b) => {
818
- const ai = CATEGORY_ORDER.indexOf(a);
819
- const bi = CATEGORY_ORDER.indexOf(b);
820
- if (ai !== -1 && bi !== -1) return ai - bi;
821
- if (ai !== -1) return -1;
822
- if (bi !== -1) return 1;
823
- return a.localeCompare(b);
824
- });
825
- for (const cat of sortedCategories) {
826
- const icon = CATEGORY_ICONS[cat] ?? "\u26AA";
827
- log.category(icon, cat, categoryErrors[cat] ?? 0, categoryWarnings[cat] ?? 0);
828
- }
829
- log.blank();
830
- log.print(
831
- ` ${import_chalk2.default.bold("Total:")} ${formatIssueCount(result.totalErrors, result.totalWarnings)}`
832
- );
833
- log.blank();
834
- if (result.ruleBreakdown.size > 0) {
835
- log.section("By Rule");
836
- const sorted = [...result.ruleBreakdown.entries()].sort(
837
- (a, b) => b[1] - a[1]
1003
+ const allCategories = /* @__PURE__ */ new Set([
1004
+ ...CATEGORY_ORDER,
1005
+ ...Object.keys(categoryErrors),
1006
+ ...Object.keys(categoryWarnings)
1007
+ ]);
1008
+ const sortedCategories = [...allCategories].filter(
1009
+ (cat) => (categoryErrors[cat] ?? 0) + (categoryWarnings[cat] ?? 0) > 0
838
1010
  );
839
- for (const [rule, count] of sorted) {
840
- const shortRule = rule.replace(/^ai-guard\//, "");
841
- log.print(
842
- ` ${import_chalk2.default.gray("\u2022")} ${import_chalk2.default.yellow(shortRule)}${import_chalk2.default.gray(":")} ${import_chalk2.default.white(String(count))}`
1011
+ sortedCategories.sort((a, b) => {
1012
+ const ai = CATEGORY_ORDER.indexOf(a);
1013
+ const bi = CATEGORY_ORDER.indexOf(b);
1014
+ if (ai !== -1 && bi !== -1) return ai - bi;
1015
+ if (ai !== -1) return -1;
1016
+ if (bi !== -1) return 1;
1017
+ return a.localeCompare(b);
1018
+ });
1019
+ for (const cat of sortedCategories) {
1020
+ const icon = CATEGORY_ICONS[cat] ?? "\u26AA";
1021
+ log.category(icon, cat, categoryErrors[cat] ?? 0, categoryWarnings[cat] ?? 0);
1022
+ }
1023
+ log.blank();
1024
+ log.print(
1025
+ ` ${import_chalk2.default.bold("Total:")} ${formatIssueCount(result.totalErrors, result.totalWarnings)}`
1026
+ );
1027
+ log.blank();
1028
+ if (result.ruleBreakdown.size > 0) {
1029
+ log.section("By Rule");
1030
+ const sorted = [...result.ruleBreakdown.entries()].sort(
1031
+ (a, b) => b[1] - a[1]
843
1032
  );
1033
+ for (const [rule, count] of sorted) {
1034
+ const shortRule = rule.replace(/^ai-guard\//, "");
1035
+ const tier = CONFIDENCE_TIER[rule];
1036
+ const tierLabel = tier === "high" ? import_chalk2.default.red.dim("[high]") : tier === "medium" ? import_chalk2.default.yellow.dim("[medium]") : import_chalk2.default.gray.dim("[low]");
1037
+ log.print(
1038
+ ` ${import_chalk2.default.gray("\u2022")} ${import_chalk2.default.yellow(shortRule)} ${tierLabel}${import_chalk2.default.gray(":")} ${import_chalk2.default.white(String(count))}`
1039
+ );
1040
+ }
1041
+ log.blank();
1042
+ }
1043
+ if (result.topFiles.length > 0) {
1044
+ log.section("Top Files");
1045
+ for (const { path: fp, count } of result.topFiles) {
1046
+ log.print(
1047
+ ` ${import_chalk2.default.gray("\u2022")} ${import_chalk2.default.white(fp)} ${import_chalk2.default.gray(`(${count})`)}`
1048
+ );
1049
+ }
1050
+ log.blank();
844
1051
  }
1052
+ log.section("Issues by File");
845
1053
  log.blank();
1054
+ const verbose = opts.verbose ?? false;
1055
+ renderIssuesByFile(result, verbose);
846
1056
  }
847
- if (result.topFiles.length > 0) {
848
- log.section("Top Files");
849
- for (const { path: fp, count } of result.topFiles) {
850
- log.print(
851
- ` ${import_chalk2.default.gray("\u2022")} ${import_chalk2.default.white(fp)} ${import_chalk2.default.gray(`(${count})`)}`
852
- );
1057
+ if (result.ecosystemIssues.length > 0) {
1058
+ log.ecosystemSection("ESLint Config Issues");
1059
+ log.print(
1060
+ import_chalk2.default.dim(` These issues come from your project's ESLint config, not from ai-guard.`)
1061
+ );
1062
+ log.blank();
1063
+ const seen = /* @__PURE__ */ new Map();
1064
+ for (const issue of result.ecosystemIssues) {
1065
+ const key = `${issue.ruleId ?? "null"}::${issue.message}`;
1066
+ const existing = seen.get(key);
1067
+ if (existing) {
1068
+ existing.count++;
1069
+ } else {
1070
+ seen.set(key, { issue, count: 1 });
1071
+ }
1072
+ }
1073
+ for (const { issue, count } of seen.values()) {
1074
+ log.ecosystemIssue(issue, count);
853
1075
  }
854
1076
  log.blank();
855
1077
  }
856
- log.section("Issues by File");
857
- log.blank();
858
- for (const file of result.files) {
1078
+ if (result.parserErrors.length > 0) {
1079
+ log.ecosystemSection("Parser Errors");
859
1080
  log.print(
860
- ` ${import_chalk2.default.bold.white(file.filePath)} ` + import_chalk2.default.gray(
861
- `(${file.errorCount} error${file.errorCount !== 1 ? "s" : ""}, ${file.warningCount} warning${file.warningCount !== 1 ? "s" : ""})`
862
- )
1081
+ import_chalk2.default.dim(` These files could not be parsed \u2014 not ai-guard findings.`)
863
1082
  );
864
- for (const issue of file.issues) {
865
- const sev = issue.severity === 2 ? import_chalk2.default.red("error") : import_chalk2.default.yellow(" warn");
866
- const loc = import_chalk2.default.gray(`${String(issue.line)}:${String(issue.column)}`).padEnd(12);
867
- const ruleShort = import_chalk2.default.gray(issue.ruleId.replace(/^ai-guard\//, ""));
1083
+ log.blank();
1084
+ for (const pe of result.parserErrors) {
1085
+ log.parserError(pe);
1086
+ }
1087
+ if (!result.tsParserAvailable) {
1088
+ log.blank();
868
1089
  log.print(
869
- ` ${loc} ${sev} ${import_chalk2.default.white(issue.message)} ${ruleShort}`
1090
+ ` ${import_chalk2.default.cyan("\u2192 Fix:")} ${import_chalk2.default.yellow("npm install --save-dev @typescript-eslint/parser")}`
1091
+ );
1092
+ log.print(
1093
+ import_chalk2.default.dim(` TypeScript parser resolves most .ts/.tsx parse failures.`)
870
1094
  );
871
1095
  }
872
1096
  log.blank();
@@ -874,13 +1098,83 @@ function registerRunCommand(program2) {
874
1098
  log.divider();
875
1099
  log.blank();
876
1100
  log.section("Next Steps");
877
- log.info(`Run ${import_chalk2.default.cyan("ai-guard baseline")} to save these issues and track only new ones`);
878
- log.info(`Run ${import_chalk2.default.cyan("ai-guard report")} to generate a shareable HTML report`);
879
- log.info(`Run ${import_chalk2.default.cyan("ai-guard ignore")} to suppress dist/build noise`);
1101
+ if (result.totalIssues > 0) {
1102
+ log.info(`Run ${import_chalk2.default.cyan("ai-guard baseline")} to save these issues and track only new ones`);
1103
+ log.info(`Run ${import_chalk2.default.cyan("ai-guard report")} to generate a shareable HTML report`);
1104
+ log.info(`Run ${import_chalk2.default.cyan("ai-guard ignore")} to suppress dist/build noise`);
1105
+ }
1106
+ if (result.ecosystemIssues.length > 0) {
1107
+ log.info(`Run ${import_chalk2.default.cyan("ai-guard doctor")} to diagnose ESLint config issues`);
1108
+ }
1109
+ if (!opts.verbose && hasGroupedWarnings(result)) {
1110
+ log.info(`Run with ${import_chalk2.default.cyan("--verbose")} to expand grouped warnings`);
1111
+ }
880
1112
  log.blank();
881
1113
  process.exit(getRunExitCode(result, opts.maxWarnings));
882
1114
  });
883
1115
  }
1116
+ var GROUP_THRESHOLD = 4;
1117
+ function hasGroupedWarnings(result) {
1118
+ for (const file of result.files) {
1119
+ const ruleCounts = /* @__PURE__ */ new Map();
1120
+ for (const issue of file.issues) {
1121
+ ruleCounts.set(issue.ruleId, (ruleCounts.get(issue.ruleId) ?? 0) + 1);
1122
+ }
1123
+ for (const count of ruleCounts.values()) {
1124
+ if (count >= GROUP_THRESHOLD) return true;
1125
+ }
1126
+ }
1127
+ return false;
1128
+ }
1129
+ function renderIssuesByFile(result, verbose) {
1130
+ for (const file of result.files) {
1131
+ log.print(
1132
+ ` ${import_chalk2.default.bold.white(file.filePath)} ` + import_chalk2.default.gray(
1133
+ `(${file.errorCount} error${file.errorCount !== 1 ? "s" : ""}, ${file.warningCount} warning${file.warningCount !== 1 ? "s" : ""})`
1134
+ )
1135
+ );
1136
+ if (!verbose) {
1137
+ const ruleCounts = /* @__PURE__ */ new Map();
1138
+ for (const issue of file.issues) {
1139
+ const arr = ruleCounts.get(issue.ruleId) ?? [];
1140
+ arr.push(issue);
1141
+ ruleCounts.set(issue.ruleId, arr);
1142
+ }
1143
+ for (const issues of ruleCounts.values()) {
1144
+ const tier = CONFIDENCE_TIER[issues[0].ruleId] ?? "low";
1145
+ if (tier === "low" && issues.length >= GROUP_THRESHOLD) {
1146
+ const sev = issues[0].severity === 2 ? import_chalk2.default.red("error") : import_chalk2.default.yellow(" warn");
1147
+ const shortRule = import_chalk2.default.gray(issues[0].ruleId.replace(/^ai-guard\//, ""));
1148
+ log.print(
1149
+ ` ${sev} ${import_chalk2.default.white(issues[0].message)} ${shortRule}`
1150
+ );
1151
+ log.print(
1152
+ import_chalk2.default.dim(` \u21B3 ${issues.length} occurrences \u2014 run with --verbose to expand`)
1153
+ );
1154
+ } else {
1155
+ for (const issue of issues) {
1156
+ renderSingleIssue(issue);
1157
+ }
1158
+ }
1159
+ }
1160
+ } else {
1161
+ for (const issue of file.issues) {
1162
+ renderSingleIssue(issue);
1163
+ }
1164
+ }
1165
+ log.blank();
1166
+ }
1167
+ }
1168
+ function renderSingleIssue(issue) {
1169
+ const sev = issue.severity === 2 ? import_chalk2.default.red("error") : import_chalk2.default.yellow(" warn");
1170
+ const loc = import_chalk2.default.gray(`${String(issue.line)}:${String(issue.column)}`).padEnd(12);
1171
+ const ruleShort = import_chalk2.default.gray(issue.ruleId.replace(/^ai-guard\//, ""));
1172
+ const tier = CONFIDENCE_TIER[issue.ruleId];
1173
+ const tierTag = tier === "high" ? "" : tier === "medium" ? "" : import_chalk2.default.dim(" [low]");
1174
+ log.print(
1175
+ ` ${loc} ${sev} ${import_chalk2.default.white(issue.message)}${tierTag} ${ruleShort}`
1176
+ );
1177
+ }
884
1178
  function getRunExitCode(result, maxWarnings) {
885
1179
  if (result.totalErrors > 0) return 1;
886
1180
  if (maxWarnings !== void 0 && result.totalWarnings > maxWarnings) return 1;
@@ -1315,6 +1609,7 @@ function registerInitCommand(program2) {
1315
1609
  var import_chalk4 = __toESM(require("chalk"));
1316
1610
  var import_path5 = __toESM(require("path"));
1317
1611
  init_config_manager();
1612
+ var import_meta = {};
1318
1613
  async function probeConfigLoad(configPath, cwd) {
1319
1614
  try {
1320
1615
  const { ESLint } = await import("eslint");
@@ -1425,6 +1720,73 @@ ${env.allConfigPaths.map((p) => ` ${import_path5.default.relative(cwd, p)}
1425
1720
  note: probe.ok ? void 0 : "Run: ai-guard init --dry-run to preview a fresh config"
1426
1721
  });
1427
1722
  }
1723
+ {
1724
+ let utilsOk = false;
1725
+ let utilsVersion = null;
1726
+ try {
1727
+ const { createRequire: createRequire2 } = await import("module");
1728
+ const req = createRequire2(import_path5.default.join(cwd, "package.json"));
1729
+ const pkgPath = req.resolve("@typescript-eslint/utils/package.json");
1730
+ const pkg = JSON.parse(
1731
+ (await import("fs")).readFileSync(pkgPath, "utf-8")
1732
+ );
1733
+ utilsVersion = pkg.version ?? null;
1734
+ utilsOk = true;
1735
+ } catch {
1736
+ }
1737
+ if (!utilsOk) {
1738
+ try {
1739
+ const selfDir = import_path5.default.dirname(
1740
+ new URL(import_meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")
1741
+ );
1742
+ const pkgPath = import_path5.default.join(selfDir, "../../node_modules/@typescript-eslint/utils/package.json");
1743
+ if ((await import("fs")).existsSync(pkgPath)) {
1744
+ utilsOk = true;
1745
+ }
1746
+ } catch {
1747
+ }
1748
+ }
1749
+ checks.push({
1750
+ label: "@typescript-eslint/utils available at runtime",
1751
+ pass: utilsOk,
1752
+ detail: utilsOk ? `Found${utilsVersion ? ` v${utilsVersion}` : ""}` : "Not found \u2014 rule engine may crash on fresh installs",
1753
+ fix: utilsOk ? void 0 : "npm install @typescript-eslint/utils",
1754
+ note: utilsOk ? void 0 : "This is a runtime dependency of eslint-plugin-ai-guard rules."
1755
+ });
1756
+ }
1757
+ {
1758
+ let parserOk = false;
1759
+ let parserVersion = null;
1760
+ try {
1761
+ const { createRequire: createRequire2 } = await import("module");
1762
+ const req = createRequire2(import_path5.default.join(cwd, "package.json"));
1763
+ const pkgPath = req.resolve("@typescript-eslint/parser/package.json");
1764
+ const pkg = JSON.parse(
1765
+ (await import("fs")).readFileSync(pkgPath, "utf-8")
1766
+ );
1767
+ parserVersion = pkg.version ?? null;
1768
+ parserOk = true;
1769
+ } catch {
1770
+ }
1771
+ const hasTsFiles = (() => {
1772
+ try {
1773
+ const { readdirSync } = require("fs");
1774
+ const files = readdirSync(cwd);
1775
+ return files.some((f) => f.endsWith(".ts") || f.endsWith(".tsx"));
1776
+ } catch {
1777
+ return false;
1778
+ }
1779
+ })();
1780
+ if (!parserOk && hasTsFiles) {
1781
+ checks.push({
1782
+ label: "@typescript-eslint/parser installed (TypeScript project)",
1783
+ pass: false,
1784
+ detail: ".ts/.tsx files detected but TypeScript parser not found",
1785
+ fix: "npm install --save-dev @typescript-eslint/parser",
1786
+ note: "TypeScript files will use espree fallback without this \u2014 some rules may not work correctly."
1787
+ });
1788
+ }
1789
+ }
1428
1790
  log.section("Diagnostics");
1429
1791
  let allPassed = true;
1430
1792
  for (const check of checks) {
@@ -2419,6 +2781,37 @@ function buildReportData(result, preset, scannedPath, cwd) {
2419
2781
  example
2420
2782
  };
2421
2783
  });
2784
+ const CONFIDENCE_ASSIGNMENT = {
2785
+ "ai-guard/no-hardcoded-secret": "high",
2786
+ "ai-guard/no-eval-dynamic": "high",
2787
+ "ai-guard/no-floating-promise": "high",
2788
+ "ai-guard/no-empty-catch": "high",
2789
+ "ai-guard/no-sql-string-concat": "medium",
2790
+ "ai-guard/no-await-in-loop": "medium",
2791
+ "ai-guard/require-auth-middleware": "medium",
2792
+ "ai-guard/require-authz-check": "medium",
2793
+ "ai-guard/no-catch-log-rethrow": "medium",
2794
+ "ai-guard/no-catch-without-use": "medium",
2795
+ "ai-guard/no-unsafe-deserialize": "medium",
2796
+ "ai-guard/no-async-without-await": "low",
2797
+ "ai-guard/no-async-array-callback": "low",
2798
+ "ai-guard/no-dead-branch": "low",
2799
+ "ai-guard/no-broad-exception": "low",
2800
+ "ai-guard/no-console-in-handler": "low",
2801
+ "ai-guard/no-duplicate-logic-block": "low",
2802
+ "ai-guard/no-redundant-await": "low"
2803
+ };
2804
+ let highCount = 0;
2805
+ let mediumCount = 0;
2806
+ let lowCount = 0;
2807
+ for (const [rule, count] of result.ruleBreakdown.entries()) {
2808
+ const tier = CONFIDENCE_ASSIGNMENT[rule] ?? "low";
2809
+ if (tier === "high") highCount += count;
2810
+ else if (tier === "medium") mediumCount += count;
2811
+ else lowCount += count;
2812
+ }
2813
+ const ecosystemCount = result.ecosystemIssues?.length ?? 0;
2814
+ const parserErrorCount = result.parserErrors?.length ?? 0;
2422
2815
  return {
2423
2816
  projectName,
2424
2817
  version,
@@ -2433,7 +2826,12 @@ function buildReportData(result, preset, scannedPath, cwd) {
2433
2826
  ruleBreakdown: Object.fromEntries(result.ruleBreakdown),
2434
2827
  topFiles,
2435
2828
  topRules,
2436
- categoryBreakdown
2829
+ categoryBreakdown,
2830
+ highCount,
2831
+ mediumCount,
2832
+ lowCount,
2833
+ ecosystemCount,
2834
+ parserErrorCount
2437
2835
  };
2438
2836
  }
2439
2837
  function generateHtml(data) {
@@ -2533,12 +2931,23 @@ function generateHtml(data) {
2533
2931
  <p style="color:#6b7280;font-size:0.85rem;margin-top:0.25rem;">Scanned: <code style="color:#94a3b8;">${escHtml(data.scannedPath)}</code> \xB7 Preset: <code style="color:#94a3b8;">${escHtml(data.preset)}</code> \xB7 ${data.durationMs}ms</p>
2534
2932
  </div>
2535
2933
  <div style="text-align:center;">
2536
- <div style="font-size:0.75rem;color:#6b7280;margin-bottom:0.5rem;letter-spacing:0.05em;">AI CODE HEALTH SCORE</div>
2537
- <div style="width:100px;height:100px;border-radius:50%;background:conic-gradient(${scoreColor} ${data.score}%, #1f2937 0%);display:flex;align-items:center;justify-content:center;box-shadow:0 0 30px ${scoreColor}33;">
2538
- <div style="width:80px;height:80px;border-radius:50%;background:#030712;display:flex;flex-direction:column;align-items:center;justify-content:center;">
2539
- <span style="font-size:1.6rem;font-weight:800;color:${scoreColor};">${data.score}</span>
2540
- <span style="font-size:0.65rem;color:${scoreColor};font-weight:600;">${scoreLabel}</span>
2934
+ <div style="font-size:0.75rem;color:#6b7280;margin-bottom:0.75rem;letter-spacing:0.05em;">SIGNAL CONFIDENCE</div>
2935
+ <div style="display:flex;flex-direction:column;gap:0.4rem;">
2936
+ <div style="display:flex;align-items:center;gap:0.5rem;">
2937
+ <span style="width:8px;height:8px;border-radius:50%;background:#ef4444;display:inline-block;"></span>
2938
+ <span style="font-size:0.8rem;color:#f87171;font-weight:600;">${data.highCount} high-confidence</span>
2939
+ </div>
2940
+ <div style="display:flex;align-items:center;gap:0.5rem;">
2941
+ <span style="width:8px;height:8px;border-radius:50%;background:#f59e0b;display:inline-block;"></span>
2942
+ <span style="font-size:0.8rem;color:#fbbf24;font-weight:600;">${data.mediumCount} medium-confidence</span>
2943
+ </div>
2944
+ <div style="display:flex;align-items:center;gap:0.5rem;">
2945
+ <span style="width:8px;height:8px;border-radius:50%;background:#6b7280;display:inline-block;"></span>
2946
+ <span style="font-size:0.8rem;color:#9ca3af;font-weight:600;">${data.lowCount} suggestions</span>
2541
2947
  </div>
2948
+ ${data.ecosystemCount > 0 ? `<div style="display:flex;align-items:center;gap:0.5rem;margin-top:0.25rem;">
2949
+ <span style="font-size:0.75rem;color:#4b5563;">\u26A0 ${data.ecosystemCount} config issue${data.ecosystemCount !== 1 ? "s" : ""} (not ai-guard)</span>
2950
+ </div>` : ""}
2542
2951
  </div>
2543
2952
  </div>
2544
2953
  </div>