@nolans01/agent-validator 0.1.0 → 0.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/index.js CHANGED
@@ -97,11 +97,20 @@ async function resolveFileList(options) {
97
97
  }
98
98
  return resolved;
99
99
  }
100
+ var DEFAULT_EXCLUDE = [
101
+ "**/node_modules/**",
102
+ "**/vendor/**",
103
+ "**/venv/**",
104
+ "**/.venv/**",
105
+ "**/dist/**",
106
+ "**/build/**",
107
+ "**/coverage/**",
108
+ "**/.git/**"
109
+ ];
100
110
  async function resolveDirectory(options) {
101
111
  const dirs = options.targets ?? ["."];
102
112
  const patterns = dirs.map((d) => `${d}/**/*`);
103
- const defaultExclude = ["**/node_modules/**", "**/dist/**", "**/.git/**"];
104
- const ignore = [...defaultExclude, ...options.exclude ?? []];
113
+ const ignore = [...DEFAULT_EXCLUDE, ...options.exclude ?? []];
105
114
  return glob(patterns, {
106
115
  cwd: options.workdir,
107
116
  nodir: true,
@@ -248,6 +257,101 @@ function buildMypyWhy(severity, code) {
248
257
  return parts.join(" ");
249
258
  }
250
259
 
260
+ // src/adapters/ruby-syntax.ts
261
+ import { execa as execa5 } from "execa";
262
+
263
+ // src/utils.ts
264
+ function chunk(arr, size) {
265
+ if (size <= 0) return [arr];
266
+ const chunks = [];
267
+ for (let i = 0; i < arr.length; i += size) {
268
+ chunks.push(arr.slice(i, i + size));
269
+ }
270
+ return chunks;
271
+ }
272
+
273
+ // src/adapters/ruby-syntax.ts
274
+ var RUBY_SYNTAX_LINE = /^(.*?):(\d+):.*(?:syntax error|unterminated|unexpected)/i;
275
+ var RubySyntaxAdapter = class {
276
+ name = "ruby";
277
+ supportedLanguages = ["ruby"];
278
+ async isAvailable() {
279
+ return isBinaryAvailable("ruby");
280
+ }
281
+ async run(files, config) {
282
+ const findings = [];
283
+ for (const batch of chunk(files, 8)) {
284
+ const results = await Promise.all(batch.map(async (file) => ({
285
+ file,
286
+ result: await execa5("ruby", ["-c", file], {
287
+ cwd: config.workdir,
288
+ reject: false
289
+ })
290
+ })));
291
+ for (const { file, result } of results) {
292
+ if (result.exitCode === 0) continue;
293
+ findings.push(buildSyntaxFinding(file, `${result.stderr}
294
+ ${result.stdout}`));
295
+ }
296
+ }
297
+ return { findings };
298
+ }
299
+ };
300
+ function buildSyntaxFinding(file, output) {
301
+ const relevantLine = output.split("\n").find((line) => RUBY_SYNTAX_LINE.test(line));
302
+ const match = relevantLine ? RUBY_SYNTAX_LINE.exec(relevantLine) : null;
303
+ return {
304
+ file: match?.[1] || file,
305
+ line: match ? Number.parseInt(match[2], 10) : 0,
306
+ severity: "blocker",
307
+ metric: "ruby_syntax",
308
+ message: relevantLine?.trim() || `Ruby could not parse ${file}`,
309
+ why: "Ruby files must parse successfully before Rails can load the application.",
310
+ suggestion: "Fix the reported Ruby syntax error.",
311
+ metadata: { source: "ruby" }
312
+ };
313
+ }
314
+
315
+ // src/adapters/zeitwerk.ts
316
+ import fs3 from "fs";
317
+ import path4 from "path";
318
+ import { execa as execa6 } from "execa";
319
+ var ZeitwerkAdapter = class {
320
+ name = "zeitwerk";
321
+ supportedLanguages = ["ruby"];
322
+ isRailsProject(workdir) {
323
+ return fs3.existsSync(path4.join(workdir, "Gemfile")) && fs3.existsSync(path4.join(workdir, "config", "application.rb"));
324
+ }
325
+ async isAvailable() {
326
+ return isBinaryAvailable("bundle");
327
+ }
328
+ async run(_files, config) {
329
+ const result = await execa6("bundle", ["exec", "rails", "zeitwerk:check"], {
330
+ cwd: config.workdir,
331
+ env: { RAILS_ENV: "test" },
332
+ reject: false
333
+ });
334
+ if (result.exitCode === 0) return { findings: [] };
335
+ const detail = lastOutputLine(`${result.stderr}
336
+ ${result.stdout}`);
337
+ return {
338
+ findings: [{
339
+ file: "config/application.rb",
340
+ line: 0,
341
+ severity: "blocker",
342
+ metric: "rails_zeitwerk",
343
+ message: detail ? `Rails Zeitwerk check failed: ${detail}` : "Rails Zeitwerk check failed",
344
+ why: "Rails must boot and autoload application constants consistently.",
345
+ suggestion: "Run RAILS_ENV=test bundle exec rails zeitwerk:check in the project environment.",
346
+ metadata: { source: "zeitwerk", exitCode: result.exitCode }
347
+ }]
348
+ };
349
+ }
350
+ };
351
+ function lastOutputLine(output) {
352
+ return output.split("\n").map((line) => line.trim()).filter(Boolean).at(-1)?.slice(0, 500);
353
+ }
354
+
251
355
  // src/scorer.ts
252
356
  function calculateComplexityScore(input) {
253
357
  if (input.totalFunctions === 0) return 100;
@@ -318,7 +422,7 @@ function calculateTestQualityScore(input) {
318
422
  }
319
423
  function overallStatus(results) {
320
424
  if (results.some((r) => r.status === "fail")) return "fail";
321
- if (results.some((r) => r.status === "warn")) return "warn";
425
+ if (results.some((r) => r.status === "warn" || r.status === "skip")) return "warn";
322
426
  return "pass";
323
427
  }
324
428
 
@@ -331,11 +435,14 @@ var DEFAULT_COMPLEXITY = {
331
435
  };
332
436
  var DEFAULT_SECURITY = {
333
437
  semgrepRules: ["p/security-audit", "p/secrets"],
334
- gitleaksEnabled: true
438
+ gitleaksEnabled: true,
439
+ brakemanEnabled: true
335
440
  };
336
441
  var DEFAULT_TYPE_SAFETY = {
337
442
  strict: false,
338
- mypyEnabled: true
443
+ mypyEnabled: true,
444
+ rubySyntaxEnabled: true,
445
+ railsZeitwerkEnabled: true
339
446
  };
340
447
  var DEFAULT_JSCPD_EXCLUDE = [
341
448
  "**/test/**",
@@ -391,6 +498,8 @@ var TypeSafetyGate = class {
391
498
  name = "type_safety";
392
499
  tsc = new TscAdapter();
393
500
  mypy = new MypyAdapter();
501
+ ruby = new RubySyntaxAdapter();
502
+ zeitwerk = new ZeitwerkAdapter();
394
503
  async run(ctx) {
395
504
  const start = Date.now();
396
505
  const config = ctx.config.typeSafety ?? DEFAULT_TYPE_SAFETY;
@@ -400,6 +509,9 @@ var TypeSafetyGate = class {
400
509
  if (ctx.language === "python" && config.mypyEnabled) {
401
510
  return this.runMypy(ctx, config, start);
402
511
  }
512
+ if (ctx.language === "ruby" && config.rubySyntaxEnabled !== false) {
513
+ return this.runRuby(ctx, config, start);
514
+ }
403
515
  const suggestion = ctx.language === "python" ? "Enable mypyEnabled in profile or install mypy: pip install mypy" : "No type checker supported for this language yet.";
404
516
  return toolMissingResult(this.name, start, `No type checker available for language: ${ctx.language}`, suggestion);
405
517
  }
@@ -440,6 +552,44 @@ var TypeSafetyGate = class {
440
552
  const result = await this.mypy.run(files, adapterConfig);
441
553
  return this.buildResult(result.findings, start);
442
554
  }
555
+ async runRuby(ctx, config, start) {
556
+ if (!await this.ruby.isAvailable()) {
557
+ return toolMissingResult(
558
+ this.name,
559
+ start,
560
+ "Ruby is not installed in the validation environment",
561
+ "Run the validator from the project Ruby/Docker environment."
562
+ );
563
+ }
564
+ const files = this.getFiles(ctx, this.ruby);
565
+ if (files.length === 0) return this.emptyResult(start);
566
+ const adapterConfig = { workdir: ctx.workdir, thresholds: {} };
567
+ const syntaxResult = await this.ruby.run(files, adapterConfig);
568
+ const findings = [...syntaxResult.findings];
569
+ if (findings.length > 0) return this.buildResult(findings, start);
570
+ if (config.railsZeitwerkEnabled !== false && this.zeitwerk.isRailsProject(ctx.workdir)) {
571
+ if (await this.zeitwerk.isAvailable()) {
572
+ const zeitwerkResult = await this.zeitwerk.run(files, adapterConfig);
573
+ findings.push(...zeitwerkResult.findings);
574
+ } else {
575
+ findings.push({
576
+ file: "Gemfile",
577
+ line: 0,
578
+ severity: "warning",
579
+ metric: "rails_check_skipped",
580
+ message: "Rails Zeitwerk check was skipped because Bundler is unavailable",
581
+ why: "Ruby syntax alone does not verify that Rails can boot and autoload constants.",
582
+ suggestion: "Run the validator inside the project Docker image with Bundler available.",
583
+ metadata: { source: "zeitwerk" }
584
+ });
585
+ }
586
+ }
587
+ const result = this.buildResult(findings, start);
588
+ if (findings.some((finding) => finding.metric === "rails_check_skipped") && result.status === "pass") {
589
+ result.status = "warn";
590
+ }
591
+ return result;
592
+ }
443
593
  buildResult(findings, start) {
444
594
  const score = calculateTypeSafetyScore({ findings });
445
595
  const status = deriveStatus(score, findings);
@@ -448,19 +598,7 @@ var TypeSafetyGate = class {
448
598
  };
449
599
 
450
600
  // src/adapters/lizard.ts
451
- import { execa as execa5 } from "execa";
452
-
453
- // src/utils.ts
454
- function chunk(arr, size) {
455
- if (size <= 0) return [arr];
456
- const chunks = [];
457
- for (let i = 0; i < arr.length; i += size) {
458
- chunks.push(arr.slice(i, i + size));
459
- }
460
- return chunks;
461
- }
462
-
463
- // src/adapters/lizard.ts
601
+ import { execa as execa7 } from "execa";
464
602
  var LizardAdapter = class {
465
603
  name = "lizard";
466
604
  supportedLanguages = [
@@ -481,7 +619,7 @@ var LizardAdapter = class {
481
619
  const allFindings = [];
482
620
  let totalFunctions = 0;
483
621
  for (const batch of chunk(files, 50)) {
484
- const result = await execa5("lizard", [...batch, "--csv"], {
622
+ const result = await execa7("lizard", [...batch, "--csv"], {
485
623
  cwd: config.workdir,
486
624
  reject: false
487
625
  });
@@ -641,7 +779,7 @@ var ComplexityGate = class {
641
779
  };
642
780
 
643
781
  // src/adapters/semgrep.ts
644
- import { execa as execa6 } from "execa";
782
+ import { execa as execa8 } from "execa";
645
783
  var SemgrepAdapter = class {
646
784
  name = "semgrep";
647
785
  supportedLanguages = [
@@ -663,15 +801,17 @@ var SemgrepAdapter = class {
663
801
  const allFindings = [];
664
802
  for (const batch of chunk(files, 50)) {
665
803
  const configArgs = rules.flatMap((r) => ["--config", r]);
666
- const result = await execa6(
804
+ const result = await execa8(
667
805
  "semgrep",
668
806
  [...configArgs, "--json", ...batch],
669
807
  { cwd: config.workdir, reject: false }
670
808
  );
671
- if (result.stdout) {
672
- const parsed = parseSemgrepJson(result.stdout);
673
- allFindings.push(...parsed);
809
+ const parsed = parseSemgrepJson(result.stdout || "");
810
+ if (!parsed || ![0, 1].includes(result.exitCode ?? -1) || parsed.errors.length > 0) {
811
+ allFindings.push(toolErrorFinding(result.exitCode));
812
+ continue;
674
813
  }
814
+ allFindings.push(...parsed.findings);
675
815
  }
676
816
  return { findings: allFindings };
677
817
  }
@@ -681,12 +821,12 @@ function parseSemgrepJson(json) {
681
821
  try {
682
822
  output = JSON.parse(json);
683
823
  } catch {
684
- return [];
824
+ return null;
685
825
  }
686
826
  if (!output.results || !Array.isArray(output.results)) {
687
- return [];
827
+ return null;
688
828
  }
689
- return output.results.map((r) => ({
829
+ const findings = output.results.map((r) => ({
690
830
  file: r.path,
691
831
  line: r.start.line,
692
832
  end_line: r.end.line,
@@ -703,6 +843,19 @@ function parseSemgrepJson(json) {
703
843
  source: "semgrep"
704
844
  }
705
845
  }));
846
+ return { findings, errors: Array.isArray(output.errors) ? output.errors : [] };
847
+ }
848
+ function toolErrorFinding(exitCode) {
849
+ return {
850
+ file: "",
851
+ line: 0,
852
+ severity: "blocker",
853
+ metric: "security_tool_error",
854
+ message: "Semgrep failed or produced an invalid report",
855
+ why: "A failed security scan cannot be interpreted as having no vulnerabilities.",
856
+ suggestion: "Run Semgrep directly and verify network access, rules, and JSON output.",
857
+ metadata: { source: "semgrep", exitCode }
858
+ };
706
859
  }
707
860
  function mapSeverity(severity) {
708
861
  switch (severity) {
@@ -731,7 +884,10 @@ function buildWhy2(result) {
731
884
  }
732
885
 
733
886
  // src/adapters/gitleaks.ts
734
- import { execa as execa7 } from "execa";
887
+ import { execa as execa9 } from "execa";
888
+ import fs4 from "fs";
889
+ import os from "os";
890
+ import path5 from "path";
735
891
  var GitleaksAdapter = class {
736
892
  name = "gitleaks";
737
893
  supportedLanguages = [
@@ -746,19 +902,32 @@ var GitleaksAdapter = class {
746
902
  async isAvailable() {
747
903
  return isBinaryAvailable("gitleaks");
748
904
  }
749
- async run(files, config) {
750
- const result = await execa7(
751
- "gitleaks",
752
- ["detect", "--source", config.workdir, "--no-git", "-f", "json", "--report-path", "/dev/stdout"],
753
- { cwd: config.workdir, reject: false }
754
- );
755
- if (!result.stdout || result.stdout.trim() === "") {
756
- return { findings: [] };
905
+ async run(_files, config) {
906
+ const reportDirectory = fs4.mkdtempSync(path5.join(os.tmpdir(), "validator-gitleaks-"));
907
+ const reportPath = path5.join(reportDirectory, "report.json");
908
+ try {
909
+ const result = await execa9(
910
+ "gitleaks",
911
+ ["detect", "--source", config.workdir, "--no-git", "-f", "json", "--report-path", reportPath],
912
+ { cwd: config.workdir, reject: false }
913
+ );
914
+ const output = fs4.existsSync(reportPath) ? fs4.readFileSync(reportPath, "utf8") : result.stdout;
915
+ if ((!output || output.trim() === "") && result.exitCode === 0) {
916
+ return { findings: [] };
917
+ }
918
+ const allLeaks = parseGitleaksJson(output);
919
+ if (!allLeaks || ![0, 1].includes(result.exitCode ?? -1)) {
920
+ return { findings: [toolErrorFinding2(result.exitCode)] };
921
+ }
922
+ return {
923
+ findings: allLeaks.map((finding) => ({
924
+ ...finding,
925
+ file: path5.isAbsolute(finding.file) ? path5.relative(config.workdir, finding.file) : finding.file
926
+ }))
927
+ };
928
+ } finally {
929
+ fs4.rmSync(reportDirectory, { recursive: true, force: true });
757
930
  }
758
- const allLeaks = parseGitleaksJson(result.stdout);
759
- const fileSet = new Set(files);
760
- const filtered = allLeaks.filter((f) => fileSet.has(f.file));
761
- return { findings: filtered };
762
931
  }
763
932
  };
764
933
  function parseGitleaksJson(json) {
@@ -766,10 +935,10 @@ function parseGitleaksJson(json) {
766
935
  try {
767
936
  leaks = JSON.parse(json);
768
937
  } catch {
769
- return [];
938
+ return null;
770
939
  }
771
940
  if (!Array.isArray(leaks)) {
772
- return [];
941
+ return null;
773
942
  }
774
943
  return leaks.map((leak) => ({
775
944
  file: leak.File,
@@ -788,21 +957,112 @@ function parseGitleaksJson(json) {
788
957
  }
789
958
  }));
790
959
  }
960
+ function toolErrorFinding2(exitCode) {
961
+ return {
962
+ file: "",
963
+ line: 0,
964
+ severity: "blocker",
965
+ metric: "security_tool_error",
966
+ message: "Gitleaks failed or produced an invalid report",
967
+ why: "A failed secret scan cannot be interpreted as having no exposed credentials.",
968
+ suggestion: "Run Gitleaks directly and verify its configuration and JSON output.",
969
+ metadata: { source: "gitleaks", exitCode }
970
+ };
971
+ }
972
+
973
+ // src/adapters/brakeman.ts
974
+ import fs5 from "fs";
975
+ import path6 from "path";
976
+ import { execa as execa10 } from "execa";
977
+ var BrakemanAdapter = class {
978
+ name = "brakeman";
979
+ supportedLanguages = ["ruby"];
980
+ isRailsProject(workdir) {
981
+ return fs5.existsSync(path6.join(workdir, "Gemfile")) && fs5.existsSync(path6.join(workdir, "config", "application.rb"));
982
+ }
983
+ async isAvailable() {
984
+ return isBinaryAvailable("brakeman");
985
+ }
986
+ async run(_files, config) {
987
+ const result = await execa10(
988
+ "brakeman",
989
+ ["--format", "json", "--quiet", "--no-exit-on-warn"],
990
+ { cwd: config.workdir, reject: false }
991
+ );
992
+ const report = parseReport(result.stdout || "");
993
+ if (!report || result.exitCode !== 0 || report.errors?.length) {
994
+ return { findings: [toolErrorFinding3(result.exitCode)] };
995
+ }
996
+ return { findings: report.warnings.map(toFinding) };
997
+ }
998
+ };
999
+ function parseReport(output) {
1000
+ try {
1001
+ const report = JSON.parse(output);
1002
+ return Array.isArray(report.warnings) ? report : null;
1003
+ } catch {
1004
+ return null;
1005
+ }
1006
+ }
1007
+ function toFinding(warning) {
1008
+ return {
1009
+ file: warning.file,
1010
+ line: warning.line ?? 0,
1011
+ severity: warning.confidence === "High" ? "blocker" : warning.confidence === "Medium" ? "warning" : "info",
1012
+ metric: "rails_security",
1013
+ message: warning.message,
1014
+ why: `${warning.warning_type} detected by Brakeman's ${warning.check_name} check.`,
1015
+ suggestion: "Review the affected Rails code and apply the mitigation recommended by Brakeman.",
1016
+ metadata: {
1017
+ source: "brakeman",
1018
+ warningCode: warning.warning_code,
1019
+ fingerprint: warning.fingerprint,
1020
+ confidence: warning.confidence
1021
+ }
1022
+ };
1023
+ }
1024
+ function toolErrorFinding3(exitCode) {
1025
+ return {
1026
+ file: "",
1027
+ line: 0,
1028
+ severity: "blocker",
1029
+ metric: "security_tool_error",
1030
+ message: "Brakeman failed or produced an invalid report",
1031
+ why: "A failed Rails security scan cannot be interpreted as having no vulnerabilities.",
1032
+ suggestion: "Run brakeman --format json --no-exit-on-warn directly and fix the reported execution error.",
1033
+ metadata: { source: "brakeman", exitCode }
1034
+ };
1035
+ }
791
1036
 
792
1037
  // src/gates/security.ts
793
1038
  var SecurityGate = class {
794
1039
  name = "security";
795
1040
  semgrep = new SemgrepAdapter();
796
1041
  gitleaks = new GitleaksAdapter();
1042
+ brakeman = new BrakemanAdapter();
797
1043
  async run(ctx) {
798
1044
  const start = Date.now();
799
1045
  const securityConfig = ctx.config.security ?? DEFAULT_SECURITY;
800
1046
  const semgrepAvailable = await this.semgrep.isAvailable();
801
1047
  const gitleaksAvailable = securityConfig.gitleaksEnabled ? await this.gitleaks.isAvailable() : false;
802
- if (!semgrepAvailable && !gitleaksAvailable) {
803
- return toolMissingResult(this.name, start, "Neither semgrep nor gitleaks is installed", "Install with: pip install semgrep OR brew install gitleaks");
1048
+ const brakemanApplicable = ctx.language === "ruby" && securityConfig.brakemanEnabled !== false && this.brakeman.isRailsProject(ctx.workdir);
1049
+ const brakemanAvailable = brakemanApplicable ? await this.brakeman.isAvailable() : false;
1050
+ if (!semgrepAvailable && !gitleaksAvailable && !brakemanAvailable) {
1051
+ return toolMissingResult(this.name, start, "No security tools are installed", "Install Semgrep, Gitleaks, or Brakeman.");
804
1052
  }
805
1053
  const allFindings = [];
1054
+ if (brakemanApplicable && !brakemanAvailable) {
1055
+ allFindings.push({
1056
+ file: "Gemfile",
1057
+ line: 0,
1058
+ severity: "info",
1059
+ metric: "rails_security_skipped",
1060
+ message: "Brakeman is not installed; Rails-specific security checks were skipped",
1061
+ why: "Generic security rules do not cover all Rails-specific vulnerability patterns.",
1062
+ suggestion: "Install Brakeman or use the Rails validator image.",
1063
+ metadata: { source: "brakeman" }
1064
+ });
1065
+ }
806
1066
  if (semgrepAvailable) {
807
1067
  const supportedFiles = ctx.files.filter((f) => this.semgrep.supportedLanguages.includes(f.language)).map((f) => f.relativePath);
808
1068
  if (supportedFiles.length > 0) {
@@ -823,15 +1083,23 @@ var SecurityGate = class {
823
1083
  });
824
1084
  allFindings.push(...result.findings);
825
1085
  }
1086
+ if (brakemanAvailable) {
1087
+ const result = await this.brakeman.run([], {
1088
+ workdir: ctx.workdir,
1089
+ thresholds: {}
1090
+ });
1091
+ allFindings.push(...result.findings);
1092
+ }
826
1093
  const score = calculateSecurityScore({ findings: allFindings });
827
- const status = deriveStatus(score, allFindings);
1094
+ const derivedStatus = deriveStatus(score, allFindings);
1095
+ const status = allFindings.some((finding) => finding.metric === "rails_security_skipped") && derivedStatus === "pass" ? "warn" : derivedStatus;
828
1096
  const duration_ms = Date.now() - start;
829
1097
  return { gate: this.name, score, status, duration_ms, findings: allFindings };
830
1098
  }
831
1099
  };
832
1100
 
833
1101
  // src/adapters/madge.ts
834
- import { execa as execa8 } from "execa";
1102
+ import { execa as execa11 } from "execa";
835
1103
  function parseCycles(stdout) {
836
1104
  if (!stdout.trim()) return null;
837
1105
  try {
@@ -863,7 +1131,7 @@ var MadgeAdapter = class {
863
1131
  }
864
1132
  async run(files, config) {
865
1133
  if (files.length === 0) return { findings: [] };
866
- const result = await execa8("madge", ["--circular", "--json", config.workdir], {
1134
+ const result = await execa11("madge", ["--circular", "--json", config.workdir], {
867
1135
  cwd: config.workdir,
868
1136
  reject: false
869
1137
  });
@@ -876,10 +1144,10 @@ var MadgeAdapter = class {
876
1144
  };
877
1145
 
878
1146
  // src/adapters/jscpd.ts
879
- import { execa as execa9 } from "execa";
880
- import fs3 from "fs";
881
- import os from "os";
882
- import path4 from "path";
1147
+ import { execa as execa12 } from "execa";
1148
+ import fs6 from "fs";
1149
+ import os2 from "os";
1150
+ import path7 from "path";
883
1151
  function buildArgs(config, tmpDir) {
884
1152
  const minLines = config.minLines ?? 5;
885
1153
  const minTokens = config.minTokens ?? 50;
@@ -901,9 +1169,9 @@ function buildArgs(config, tmpDir) {
901
1169
  args.push(config.workdir);
902
1170
  return args;
903
1171
  }
904
- function parseReport(reportPath) {
905
- if (!fs3.existsSync(reportPath)) return null;
906
- const raw = fs3.readFileSync(reportPath, "utf-8");
1172
+ function parseReport2(reportPath) {
1173
+ if (!fs6.existsSync(reportPath)) return null;
1174
+ const raw = fs6.readFileSync(reportPath, "utf-8");
907
1175
  try {
908
1176
  const report = JSON.parse(raw);
909
1177
  if (!report.duplicates || !Array.isArray(report.duplicates)) return null;
@@ -913,8 +1181,8 @@ function parseReport(reportPath) {
913
1181
  }
914
1182
  }
915
1183
  function cloneToFinding(clone, workdir) {
916
- const firstRel = path4.relative(workdir, clone.firstFile.name);
917
- const secondRel = path4.relative(workdir, clone.secondFile.name);
1184
+ const firstRel = path7.relative(workdir, clone.firstFile.name);
1185
+ const secondRel = path7.relative(workdir, clone.secondFile.name);
918
1186
  return {
919
1187
  file: firstRel,
920
1188
  line: clone.firstFile.startLoc.line,
@@ -942,28 +1210,28 @@ var JscpdAdapter = class {
942
1210
  async run(files, config) {
943
1211
  if (files.length === 0) return { findings: [] };
944
1212
  const jscpdConfig = config;
945
- const tmpDir = fs3.mkdtempSync(path4.join(os.tmpdir(), "jscpd-"));
1213
+ const tmpDir = fs6.mkdtempSync(path7.join(os2.tmpdir(), "jscpd-"));
946
1214
  try {
947
1215
  const args = buildArgs(jscpdConfig, tmpDir);
948
- await execa9("jscpd", args, { cwd: config.workdir, reject: false });
949
- const report = parseReport(path4.join(tmpDir, "jscpd-report.json"));
1216
+ await execa12("jscpd", args, { cwd: config.workdir, reject: false });
1217
+ const report = parseReport2(path7.join(tmpDir, "jscpd-report.json"));
950
1218
  if (!report) return { findings: [] };
951
1219
  const fileSet = new Set(files);
952
1220
  return {
953
1221
  findings: report.duplicates.filter((clone) => {
954
- const firstRel = path4.relative(config.workdir, clone.firstFile.name);
955
- const secondRel = path4.relative(config.workdir, clone.secondFile.name);
1222
+ const firstRel = path7.relative(config.workdir, clone.firstFile.name);
1223
+ const secondRel = path7.relative(config.workdir, clone.secondFile.name);
956
1224
  return fileSet.has(firstRel) || fileSet.has(secondRel);
957
1225
  }).map((clone) => cloneToFinding(clone, config.workdir))
958
1226
  };
959
1227
  } finally {
960
- fs3.rmSync(tmpDir, { recursive: true, force: true });
1228
+ fs6.rmSync(tmpDir, { recursive: true, force: true });
961
1229
  }
962
1230
  }
963
1231
  };
964
1232
 
965
1233
  // src/adapters/knip.ts
966
- import { execa as execa10 } from "execa";
1234
+ import { execa as execa13 } from "execa";
967
1235
  function collectUnusedFiles(report, fileSet) {
968
1236
  if (!report.files || !Array.isArray(report.files)) return [];
969
1237
  return report.files.filter((file) => fileSet.has(file)).map((file) => ({
@@ -1019,7 +1287,7 @@ var KnipAdapter = class {
1019
1287
  }
1020
1288
  async run(files, config) {
1021
1289
  if (files.length === 0) return { findings: [] };
1022
- const result = await execa10("knip", ["--reporter", "json"], {
1290
+ const result = await execa13("knip", ["--reporter", "json"], {
1023
1291
  cwd: config.workdir,
1024
1292
  reject: false
1025
1293
  });
@@ -1101,9 +1369,9 @@ var ArchitectureGate = class {
1101
1369
  };
1102
1370
 
1103
1371
  // src/adapters/stryker.ts
1104
- import { execa as execa11 } from "execa";
1105
- import fs4 from "fs";
1106
- import path5 from "path";
1372
+ import { execa as execa14 } from "execa";
1373
+ import fs7 from "fs";
1374
+ import path8 from "path";
1107
1375
 
1108
1376
  // src/adapters/mutation-shared.ts
1109
1377
  function scoreSeverity(score) {
@@ -1119,7 +1387,7 @@ function timeoutResult(tool, timeout) {
1119
1387
  findings: [{
1120
1388
  file: "",
1121
1389
  line: 0,
1122
- severity: "info",
1390
+ severity: "blocker",
1123
1391
  metric: "mutation_timeout",
1124
1392
  message: tool + " timed out after " + timeout + "ms",
1125
1393
  why: "Mutation testing exceeded the configured timeout.",
@@ -1217,16 +1485,16 @@ function survivorToFinding(file, mutant) {
1217
1485
  }
1218
1486
  function findReportPath(workdir) {
1219
1487
  const candidates = [
1220
- path5.join(workdir, "reports", "mutation", "mutation.json"),
1221
- path5.join(workdir, "reports", "mutation.json")
1488
+ path8.join(workdir, "reports", "mutation", "mutation.json"),
1489
+ path8.join(workdir, "reports", "mutation.json")
1222
1490
  ];
1223
- return candidates.find((p) => fs4.existsSync(p));
1491
+ return candidates.find((p) => fs7.existsSync(p));
1224
1492
  }
1225
1493
  function readReport(workdir) {
1226
1494
  const reportPath = findReportPath(workdir);
1227
1495
  if (!reportPath) return null;
1228
1496
  try {
1229
- const raw = fs4.readFileSync(reportPath, "utf-8");
1497
+ const raw = fs7.readFileSync(reportPath, "utf-8");
1230
1498
  return JSON.parse(raw);
1231
1499
  } catch {
1232
1500
  return null;
@@ -1257,8 +1525,8 @@ var StrykerAdapter = class {
1257
1525
  supportedLanguages = ["typescript", "javascript"];
1258
1526
  async isAvailable(workdir) {
1259
1527
  if (workdir) {
1260
- const localBin = path5.join(workdir, "node_modules", ".bin", "stryker");
1261
- if (fs4.existsSync(localBin)) return true;
1528
+ const localBin = path8.join(workdir, "node_modules", ".bin", "stryker");
1529
+ if (fs7.existsSync(localBin)) return true;
1262
1530
  }
1263
1531
  return await isBinaryAvailable("stryker");
1264
1532
  }
@@ -1274,10 +1542,10 @@ var StrykerAdapter = class {
1274
1542
  async executeStryker(files, config, timeout) {
1275
1543
  const mutatePattern = files.join(",");
1276
1544
  const args = ["run", "--reporters", "json", "--mutate", mutatePattern];
1277
- const useNpx = fs4.existsSync(path5.join(config.workdir, "node_modules", ".bin", "stryker"));
1545
+ const useNpx = fs7.existsSync(path8.join(config.workdir, "node_modules", ".bin", "stryker"));
1278
1546
  const command = useNpx ? "npx" : "stryker";
1279
1547
  const execArgs = useNpx ? ["stryker", ...args] : args;
1280
- const result = await execa11(command, execArgs, {
1548
+ const result = await execa14(command, execArgs, {
1281
1549
  cwd: config.workdir,
1282
1550
  reject: false,
1283
1551
  timeout
@@ -1287,7 +1555,7 @@ var StrykerAdapter = class {
1287
1555
  };
1288
1556
 
1289
1557
  // src/adapters/mutmut.ts
1290
- import { execa as execa12 } from "execa";
1558
+ import { execa as execa15 } from "execa";
1291
1559
  function matchToResult(match) {
1292
1560
  const classname = match[1];
1293
1561
  const name = match[2];
@@ -1361,13 +1629,13 @@ var MutmutAdapter = class {
1361
1629
  }
1362
1630
  async executeMutmut(files, config, timeout) {
1363
1631
  const pathsToMutate = files.join(",");
1364
- const runResult = await execa12("mutmut", ["run", `--paths-to-mutate=${pathsToMutate}`, "--CI", "--no-progress"], {
1632
+ const runResult = await execa15("mutmut", ["run", `--paths-to-mutate=${pathsToMutate}`, "--CI", "--no-progress"], {
1365
1633
  cwd: config.workdir,
1366
1634
  reject: false,
1367
1635
  timeout
1368
1636
  });
1369
1637
  if (runResult.timedOut) return null;
1370
- const xmlResult = await execa12("mutmut", ["junitxml"], {
1638
+ const xmlResult = await execa15("mutmut", ["junitxml"], {
1371
1639
  cwd: config.workdir,
1372
1640
  reject: false
1373
1641
  });
@@ -1395,8 +1663,10 @@ var MutmutAdapter = class {
1395
1663
  };
1396
1664
 
1397
1665
  // src/adapters/mutant.ts
1398
- import { execa as execa13 } from "execa";
1399
- var RESULT_LINE_RE = /^(alive|killed|timeout):(.+):(.+):(\d+)/;
1666
+ import { execa as execa16 } from "execa";
1667
+ import fs8 from "fs";
1668
+ import path9 from "path";
1669
+ var RESULT_LINE_RE = /^(alive|evil|killed|timeout):(.+):([^:]+\.rb):(\d+)(?::.*)?$/;
1400
1670
  var COVERAGE_RE = /Coverage:\s+([\d.]+)%/;
1401
1671
  function parseOutput(stdout) {
1402
1672
  const entries = [];
@@ -1405,7 +1675,7 @@ function parseOutput(stdout) {
1405
1675
  const resultMatch = RESULT_LINE_RE.exec(line);
1406
1676
  if (resultMatch) {
1407
1677
  entries.push({
1408
- status: resultMatch[1],
1678
+ status: resultMatch[1] === "evil" ? "alive" : resultMatch[1],
1409
1679
  subject: resultMatch[2],
1410
1680
  file: resultMatch[3],
1411
1681
  line: parseInt(resultMatch[4], 10)
@@ -1440,7 +1710,7 @@ function groupByFile2(entries) {
1440
1710
  const fileStats = /* @__PURE__ */ new Map();
1441
1711
  for (const [file, g] of groups) {
1442
1712
  const total = g.killed + g.survived + g.timeout;
1443
- const score = total > 0 ? (g.killed + g.timeout) / total * 100 : 100;
1713
+ const score = total > 0 ? g.killed / total * 100 : 100;
1444
1714
  fileStats.set(file, { killed: g.killed, survived: g.survived, timeout: g.timeout, total, score, survivors: g.survivors });
1445
1715
  }
1446
1716
  return fileStats;
@@ -1468,7 +1738,7 @@ function processEntries(opts) {
1468
1738
  let totalMutants = 0;
1469
1739
  for (const [file, stats] of fileStatsMap) {
1470
1740
  if (!fileSet.has(file)) continue;
1471
- totalKilled += stats.killed + stats.timeout;
1741
+ totalKilled += stats.killed;
1472
1742
  totalMutants += stats.total;
1473
1743
  if (stats.score < opts.threshold) {
1474
1744
  findings.push(fileStatsToFinding(file, stats, opts.threshold));
@@ -1481,31 +1751,154 @@ function processEntries(opts) {
1481
1751
  var MutantAdapter = class {
1482
1752
  name = "mutant";
1483
1753
  supportedLanguages = ["ruby"];
1484
- async isAvailable() {
1485
- return isBinaryAvailable("mutant");
1754
+ async isAvailable(workdir = process.cwd()) {
1755
+ if (!fs8.existsSync(path9.join(workdir, "Gemfile"))) {
1756
+ return isBinaryAvailable("mutant");
1757
+ }
1758
+ try {
1759
+ const result = await execa16("bundle", ["exec", "mutant", "--version"], {
1760
+ cwd: workdir,
1761
+ reject: false
1762
+ });
1763
+ return result.exitCode === 0;
1764
+ } catch {
1765
+ return false;
1766
+ }
1486
1767
  }
1487
1768
  async run(files, config) {
1488
1769
  if (files.length === 0) return { findings: [], mutationScore: 100 };
1489
1770
  const timeout = config.timeout ?? 3e5;
1490
1771
  const threshold = config.mutationScoreThreshold ?? 80;
1491
1772
  const maxSurvivorFindings = config.maxSurvivorFindings ?? 5;
1492
- const stdout = await this.executeMutant(config.workdir, timeout);
1493
- if (stdout === null) return timeoutResult("mutant", timeout);
1494
- if (!stdout.trim()) return { findings: [], mutationScore: 100 };
1495
- const { entries, overallScore } = parseOutput(stdout);
1496
- if (entries.length === 0 && overallScore === null) return { findings: [], mutationScore: 100 };
1773
+ if (!config.usage) {
1774
+ return toolError(
1775
+ "Mutant requires an explicit usage policy",
1776
+ "Set --mutant-usage opensource or --mutant-usage commercial according to the project license."
1777
+ );
1778
+ }
1779
+ let execution;
1780
+ try {
1781
+ execution = await this.executeMutant(files, config.workdir, timeout, config.usage);
1782
+ } catch (error) {
1783
+ const detail = error instanceof Error ? error.message : "unknown execution error";
1784
+ return toolError(`Mutant could not start: ${detail}`, "Verify Bundler, Mutant, and the project test environment.");
1785
+ }
1786
+ if (execution.timedOut) return timeoutResult("mutant", timeout);
1787
+ const stdout = execution.stdout;
1788
+ if (!stdout.trim()) {
1789
+ return toolError("Mutant produced no report", "Run Mutant through the project bundle and verify its RSpec integration.");
1790
+ }
1791
+ if (![0, 1].includes(execution.exitCode ?? -1)) {
1792
+ return toolError(
1793
+ `Mutant failed with exit code ${execution.exitCode ?? "unknown"}`,
1794
+ "Run Mutant directly and fix its bundle, licence, Rails, or RSpec configuration."
1795
+ );
1796
+ }
1797
+ const parsed = parseOutput(stdout);
1798
+ const entries = parsed.entries.map((entry) => ({
1799
+ ...entry,
1800
+ file: path9.isAbsolute(entry.file) ? path9.relative(config.workdir, entry.file) : entry.file
1801
+ }));
1802
+ const { overallScore } = parsed;
1803
+ if (entries.length === 0 && overallScore === null) {
1804
+ return toolError("Mutant output could not be parsed", "Check the installed Mutant version and its command output.");
1805
+ }
1497
1806
  return processEntries({ entries, overallScore, files, threshold, maxSurvivorFindings });
1498
1807
  }
1499
- async executeMutant(workdir, timeout) {
1500
- const result = await execa13("mutant", ["run", "--include", "lib", "--use", "rspec"], {
1808
+ async executeMutant(files, workdir, timeout, usage) {
1809
+ const bundled = fs8.existsSync(path9.join(workdir, "Gemfile"));
1810
+ const command = bundled ? "bundle" : "mutant";
1811
+ const roots = [...new Set(files.map((file) => file.split("/")[0]).filter((root) => root === "app" || root === "lib"))];
1812
+ const railsArgs = fs8.existsSync(path9.join(workdir, "config", "environment.rb")) ? ["--require", "./config/environment"] : [];
1813
+ const mutantArgs = [
1814
+ "run",
1815
+ "--usage",
1816
+ usage,
1817
+ ...roots.flatMap((root) => ["--include", root]),
1818
+ ...railsArgs,
1819
+ "--integration",
1820
+ "rspec",
1821
+ "--jobs",
1822
+ "1",
1823
+ "--",
1824
+ ...files.map((file) => `source:${file}`)
1825
+ ];
1826
+ const args = bundled ? ["exec", "mutant", ...mutantArgs] : mutantArgs;
1827
+ const result = await execa16(command, args, {
1501
1828
  cwd: workdir,
1829
+ env: { RAILS_ENV: "test", CI: "true" },
1502
1830
  reject: false,
1503
1831
  timeout
1504
1832
  });
1505
- if (result.timedOut) return null;
1506
- return result.stdout || "";
1833
+ return { stdout: result.stdout || "", timedOut: result.timedOut, exitCode: result.exitCode };
1834
+ }
1835
+ };
1836
+ function toolError(message, suggestion) {
1837
+ return {
1838
+ findings: [{
1839
+ file: "",
1840
+ line: 0,
1841
+ severity: "blocker",
1842
+ metric: "mutation_tool_error",
1843
+ message,
1844
+ why: "A failed mutation test cannot be treated as a perfect mutation score.",
1845
+ suggestion,
1846
+ metadata: { source: "mutant" }
1847
+ }],
1848
+ mutationScore: 0
1849
+ };
1850
+ }
1851
+
1852
+ // src/adapters/test-command.ts
1853
+ import { execaCommand } from "execa";
1854
+ var TestCommandAdapter = class {
1855
+ name = "test-command";
1856
+ supportedLanguages = ["typescript", "javascript", "python", "ruby", "go", "rust"];
1857
+ async isAvailable() {
1858
+ return true;
1859
+ }
1860
+ async run(_files, config) {
1861
+ let result;
1862
+ try {
1863
+ result = await execaCommand(config.command, {
1864
+ cwd: config.workdir,
1865
+ env: { CI: "true" },
1866
+ reject: false,
1867
+ timeout: config.timeout
1868
+ });
1869
+ } catch (error) {
1870
+ const detail = error instanceof Error ? error.message : "unknown execution error";
1871
+ return this.failure(`Test command could not start: ${detail}`, "test_command_failed");
1872
+ }
1873
+ if (result.timedOut) {
1874
+ return this.failure(`Test command timed out after ${config.timeout}ms`, "test_command_timeout");
1875
+ }
1876
+ if (result.exitCode !== 0) {
1877
+ const detail = diagnosticLine(result.stderr) ?? diagnosticLine(result.stdout);
1878
+ const message = detail ? `Test command failed with exit code ${result.exitCode}: ${detail}` : `Test command failed with exit code ${result.exitCode}`;
1879
+ return this.failure(message, "test_command_failed");
1880
+ }
1881
+ return { findings: [], mutationScore: 100 };
1882
+ }
1883
+ failure(message, metric) {
1884
+ const finding = {
1885
+ file: "",
1886
+ line: 0,
1887
+ severity: "blocker",
1888
+ metric,
1889
+ message,
1890
+ why: "The project-defined test suite must complete successfully.",
1891
+ suggestion: "Run the configured test command directly and inspect its full logs.",
1892
+ metadata: { source: "test-command" }
1893
+ };
1894
+ return { findings: [finding], mutationScore: 0 };
1507
1895
  }
1508
1896
  };
1897
+ function diagnosticLine(output) {
1898
+ const lines = output.split("\n").map((line) => line.trim()).filter((line) => line && !/^=+$/.test(line));
1899
+ const errorLine = lines.find((line) => /error|failed|cannot|denied|no such file|docker:/i.test(line));
1900
+ return (errorLine ?? lines.at(-1))?.slice(0, 500);
1901
+ }
1509
1902
 
1510
1903
  // src/gates/test-quality.ts
1511
1904
  var TestQualityGate = class {
@@ -1513,16 +1906,18 @@ var TestQualityGate = class {
1513
1906
  stryker = new StrykerAdapter();
1514
1907
  mutmut = new MutmutAdapter();
1515
1908
  mutant = new MutantAdapter();
1909
+ testCommand = new TestCommandAdapter();
1516
1910
  async run(ctx) {
1517
1911
  const start = Date.now();
1518
1912
  const config = ctx.config.testQuality ?? DEFAULT_TEST_QUALITY;
1519
1913
  const available = await this.checkAvailability(config, ctx);
1520
- if (!available.stryker && !available.mutmut && !available.mutant) {
1914
+ if (!available.stryker && !available.mutmut && !available.mutant && !available.testCommand) {
1521
1915
  return toolMissingResult(this.name, start, "No mutation testing tools available", "Install: npm i -D @stryker-mutator/core | pip install mutmut | gem install mutant");
1522
1916
  }
1523
- const { findings, mutationScore } = await this.collectFindings(available, config, ctx);
1917
+ const { findings, mutationScore, mutationRan } = await this.collectFindings(available, config, ctx);
1524
1918
  const score = calculateTestQualityScore({ mutationScore });
1525
- const status = deriveStatus(score, findings);
1919
+ const hasBlocker = findings.some((finding) => finding.severity === "blocker");
1920
+ const status = !mutationRan && available.testCommand && !hasBlocker ? "warn" : score < config.mutationScoreThreshold ? "fail" : deriveStatus(score, findings);
1526
1921
  return { gate: this.name, score, status, duration_ms: Date.now() - start, findings };
1527
1922
  }
1528
1923
  async checkAvailability(config, ctx) {
@@ -1532,13 +1927,13 @@ var TestQualityGate = class {
1532
1927
  return {
1533
1928
  stryker: config.strykerEnabled && tsOrJs ? await this.stryker.isAvailable(ctx.workdir) : false,
1534
1929
  mutmut: config.mutmutEnabled && isPython ? await this.mutmut.isAvailable() : false,
1535
- mutant: config.mutantEnabled && isRuby ? await this.mutant.isAvailable() : false
1930
+ mutant: config.mutantEnabled && isRuby && (!config.testCommand || Boolean(config.mutantUsage)) ? await this.mutant.isAvailable(ctx.workdir) : false,
1931
+ testCommand: Boolean(config.testCommand)
1536
1932
  };
1537
1933
  }
1538
1934
  async collectFindings(available, config, ctx) {
1539
1935
  const allFindings = [];
1540
- let totalScore = 0;
1541
- let adapterCount = 0;
1936
+ const scores = [];
1542
1937
  const adapters = [
1543
1938
  { available: available.stryker, adapter: this.stryker },
1544
1939
  { available: available.mutmut, adapter: this.mutmut },
@@ -1550,13 +1945,33 @@ var TestQualityGate = class {
1550
1945
  if (result) {
1551
1946
  allFindings.push(...result.findings);
1552
1947
  if (!result.timedOut) {
1553
- totalScore += result.mutationScore;
1554
- adapterCount++;
1948
+ scores.push(result.mutationScore);
1555
1949
  }
1556
1950
  }
1557
1951
  }
1558
- const mutationScore = adapterCount > 0 ? totalScore / adapterCount : 100;
1559
- return { findings: allFindings, mutationScore };
1952
+ if (available.testCommand && config.testCommand) {
1953
+ const result = await this.testCommand.run(ctx.files.map((file) => file.relativePath), {
1954
+ workdir: ctx.workdir,
1955
+ thresholds: {},
1956
+ command: config.testCommand,
1957
+ timeout: config.timeout
1958
+ });
1959
+ allFindings.push(...result.findings);
1960
+ if (result.findings.length === 0 && scores.length === 0) {
1961
+ allFindings.push({
1962
+ file: "",
1963
+ line: 0,
1964
+ severity: "info",
1965
+ metric: "mutation_not_run",
1966
+ message: "Project tests passed, but mutation testing was not run",
1967
+ why: "A passing test suite does not measure whether tests detect behavioral changes.",
1968
+ suggestion: "Configure Mutant in the project bundle to obtain a mutation score.",
1969
+ metadata: { source: "test-command" }
1970
+ });
1971
+ }
1972
+ }
1973
+ const mutationScore = scores.length > 0 ? Math.min(...scores) : 0;
1974
+ return { findings: allFindings, mutationScore, mutationRan: scores.length > 0 };
1560
1975
  }
1561
1976
  async runAdapter(adapter, ctx, config) {
1562
1977
  const files = ctx.files.filter((f) => adapter.supportedLanguages.includes(f.language)).map((f) => f.relativePath);
@@ -1566,7 +1981,8 @@ var TestQualityGate = class {
1566
1981
  thresholds: {},
1567
1982
  timeout: config.timeout,
1568
1983
  mutationScoreThreshold: config.mutationScoreThreshold,
1569
- maxSurvivorFindings: config.maxSurvivorFindings
1984
+ maxSurvivorFindings: config.maxSurvivorFindings,
1985
+ usage: config.mutantUsage
1570
1986
  });
1571
1987
  }
1572
1988
  };
@@ -1702,7 +2118,8 @@ function getProfile(name) {
1702
2118
 
1703
2119
  // src/reporter.ts
1704
2120
  import chalk from "chalk";
1705
- function reportText(results, meta) {
2121
+ import { stripVTControlCharacters } from "util";
2122
+ function reportText(results, meta, options = {}) {
1706
2123
  const lines = [];
1707
2124
  lines.push(chalk.bold("=== VALIDATOR REPORT ==="));
1708
2125
  lines.push(`Files analyzed: ${meta.filesAnalyzed} (mode: ${meta.mode})`);
@@ -1734,7 +2151,8 @@ function reportText(results, meta) {
1734
2151
  lines.push(
1735
2152
  `RESULT: ${overall.toUpperCase()} (${blockerCount} blockers, ${warnCount} warnings)`
1736
2153
  );
1737
- return lines.join("\n");
2154
+ const report = lines.join("\n");
2155
+ return options.ansi === false ? stripVTControlCharacters(report) : report;
1738
2156
  }
1739
2157
  function reportJson(results, meta) {
1740
2158
  const allFindings = results.flatMap((r) => r.findings);
@@ -1752,7 +2170,11 @@ function reportJson(results, meta) {
1752
2170
  },
1753
2171
  gates: Object.fromEntries(results.map((r) => [r.gate, r]))
1754
2172
  };
1755
- return JSON.stringify(report, null, 2);
2173
+ return JSON.stringify(
2174
+ report,
2175
+ (_key, value) => typeof value === "string" ? stripVTControlCharacters(value) : value,
2176
+ 2
2177
+ );
1756
2178
  }
1757
2179
  function statusIcon(status) {
1758
2180
  switch (status) {
@@ -1771,8 +2193,20 @@ function statusIcon(status) {
1771
2193
 
1772
2194
  // src/index.ts
1773
2195
  async function validate(options) {
2196
+ if (options.testTimeout !== void 0 && (!Number.isInteger(options.testTimeout) || options.testTimeout <= 0)) {
2197
+ throw new Error("testTimeout must be a positive integer");
2198
+ }
1774
2199
  const workdir = options.workdir ?? process.cwd();
1775
- const profile = getProfile(options.profile ?? "default");
2200
+ const selectedProfile = getProfile(options.profile ?? "default");
2201
+ const profile = options.testCommand || options.testTimeout || options.mutantUsage ? {
2202
+ ...selectedProfile,
2203
+ testQuality: {
2204
+ ...selectedProfile.testQuality,
2205
+ testCommand: options.testCommand,
2206
+ timeout: options.testTimeout ?? selectedProfile.testQuality.timeout,
2207
+ mutantUsage: options.mutantUsage
2208
+ }
2209
+ } : selectedProfile;
1776
2210
  const gateNames = options.gates ?? getAllGateNames();
1777
2211
  const files = await resolveFiles({
1778
2212
  mode: options.mode,
@@ -1780,6 +2214,7 @@ async function validate(options) {
1780
2214
  base: options.base,
1781
2215
  head: options.head,
1782
2216
  staged: options.staged,
2217
+ exclude: options.exclude,
1783
2218
  workdir
1784
2219
  });
1785
2220
  const language = detectPrimaryLanguage(files);