@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/cli.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { Command } from "commander";
4
+ import { Command, Option } from "commander";
5
5
  import chalk2 from "chalk";
6
- import fs5 from "fs";
7
- import path6 from "path";
6
+ import fs10 from "fs";
7
+ import path11 from "path";
8
8
 
9
9
  // src/resolver.ts
10
10
  import path2 from "path";
@@ -105,11 +105,20 @@ async function resolveFileList(options) {
105
105
  }
106
106
  return resolved;
107
107
  }
108
+ var DEFAULT_EXCLUDE = [
109
+ "**/node_modules/**",
110
+ "**/vendor/**",
111
+ "**/venv/**",
112
+ "**/.venv/**",
113
+ "**/dist/**",
114
+ "**/build/**",
115
+ "**/coverage/**",
116
+ "**/.git/**"
117
+ ];
108
118
  async function resolveDirectory(options) {
109
119
  const dirs = options.targets ?? ["."];
110
120
  const patterns = dirs.map((d) => `${d}/**/*`);
111
- const defaultExclude = ["**/node_modules/**", "**/dist/**", "**/.git/**"];
112
- const ignore = [...defaultExclude, ...options.exclude ?? []];
121
+ const ignore = [...DEFAULT_EXCLUDE, ...options.exclude ?? []];
113
122
  return glob(patterns, {
114
123
  cwd: options.workdir,
115
124
  nodir: true,
@@ -265,6 +274,101 @@ function buildMypyWhy(severity, code) {
265
274
  return parts.join(" ");
266
275
  }
267
276
 
277
+ // src/adapters/ruby-syntax.ts
278
+ import { execa as execa5 } from "execa";
279
+
280
+ // src/utils.ts
281
+ function chunk(arr, size) {
282
+ if (size <= 0) return [arr];
283
+ const chunks = [];
284
+ for (let i = 0; i < arr.length; i += size) {
285
+ chunks.push(arr.slice(i, i + size));
286
+ }
287
+ return chunks;
288
+ }
289
+
290
+ // src/adapters/ruby-syntax.ts
291
+ var RUBY_SYNTAX_LINE = /^(.*?):(\d+):.*(?:syntax error|unterminated|unexpected)/i;
292
+ var RubySyntaxAdapter = class {
293
+ name = "ruby";
294
+ supportedLanguages = ["ruby"];
295
+ async isAvailable() {
296
+ return isBinaryAvailable("ruby");
297
+ }
298
+ async run(files, config) {
299
+ const findings = [];
300
+ for (const batch of chunk(files, 8)) {
301
+ const results = await Promise.all(batch.map(async (file) => ({
302
+ file,
303
+ result: await execa5("ruby", ["-c", file], {
304
+ cwd: config.workdir,
305
+ reject: false
306
+ })
307
+ })));
308
+ for (const { file, result } of results) {
309
+ if (result.exitCode === 0) continue;
310
+ findings.push(buildSyntaxFinding(file, `${result.stderr}
311
+ ${result.stdout}`));
312
+ }
313
+ }
314
+ return { findings };
315
+ }
316
+ };
317
+ function buildSyntaxFinding(file, output) {
318
+ const relevantLine = output.split("\n").find((line) => RUBY_SYNTAX_LINE.test(line));
319
+ const match = relevantLine ? RUBY_SYNTAX_LINE.exec(relevantLine) : null;
320
+ return {
321
+ file: match?.[1] || file,
322
+ line: match ? Number.parseInt(match[2], 10) : 0,
323
+ severity: "blocker",
324
+ metric: "ruby_syntax",
325
+ message: relevantLine?.trim() || `Ruby could not parse ${file}`,
326
+ why: "Ruby files must parse successfully before Rails can load the application.",
327
+ suggestion: "Fix the reported Ruby syntax error.",
328
+ metadata: { source: "ruby" }
329
+ };
330
+ }
331
+
332
+ // src/adapters/zeitwerk.ts
333
+ import fs3 from "fs";
334
+ import path4 from "path";
335
+ import { execa as execa6 } from "execa";
336
+ var ZeitwerkAdapter = class {
337
+ name = "zeitwerk";
338
+ supportedLanguages = ["ruby"];
339
+ isRailsProject(workdir) {
340
+ return fs3.existsSync(path4.join(workdir, "Gemfile")) && fs3.existsSync(path4.join(workdir, "config", "application.rb"));
341
+ }
342
+ async isAvailable() {
343
+ return isBinaryAvailable("bundle");
344
+ }
345
+ async run(_files, config) {
346
+ const result = await execa6("bundle", ["exec", "rails", "zeitwerk:check"], {
347
+ cwd: config.workdir,
348
+ env: { RAILS_ENV: "test" },
349
+ reject: false
350
+ });
351
+ if (result.exitCode === 0) return { findings: [] };
352
+ const detail = lastOutputLine(`${result.stderr}
353
+ ${result.stdout}`);
354
+ return {
355
+ findings: [{
356
+ file: "config/application.rb",
357
+ line: 0,
358
+ severity: "blocker",
359
+ metric: "rails_zeitwerk",
360
+ message: detail ? `Rails Zeitwerk check failed: ${detail}` : "Rails Zeitwerk check failed",
361
+ why: "Rails must boot and autoload application constants consistently.",
362
+ suggestion: "Run RAILS_ENV=test bundle exec rails zeitwerk:check in the project environment.",
363
+ metadata: { source: "zeitwerk", exitCode: result.exitCode }
364
+ }]
365
+ };
366
+ }
367
+ };
368
+ function lastOutputLine(output) {
369
+ return output.split("\n").map((line) => line.trim()).filter(Boolean).at(-1)?.slice(0, 500);
370
+ }
371
+
268
372
  // src/scorer.ts
269
373
  function calculateComplexityScore(input) {
270
374
  if (input.totalFunctions === 0) return 100;
@@ -335,7 +439,7 @@ function calculateTestQualityScore(input) {
335
439
  }
336
440
  function overallStatus(results) {
337
441
  if (results.some((r) => r.status === "fail")) return "fail";
338
- if (results.some((r) => r.status === "warn")) return "warn";
442
+ if (results.some((r) => r.status === "warn" || r.status === "skip")) return "warn";
339
443
  return "pass";
340
444
  }
341
445
 
@@ -348,11 +452,14 @@ var DEFAULT_COMPLEXITY = {
348
452
  };
349
453
  var DEFAULT_SECURITY = {
350
454
  semgrepRules: ["p/security-audit", "p/secrets"],
351
- gitleaksEnabled: true
455
+ gitleaksEnabled: true,
456
+ brakemanEnabled: true
352
457
  };
353
458
  var DEFAULT_TYPE_SAFETY = {
354
459
  strict: false,
355
- mypyEnabled: true
460
+ mypyEnabled: true,
461
+ rubySyntaxEnabled: true,
462
+ railsZeitwerkEnabled: true
356
463
  };
357
464
  var DEFAULT_JSCPD_EXCLUDE = [
358
465
  "**/test/**",
@@ -408,6 +515,8 @@ var TypeSafetyGate = class {
408
515
  name = "type_safety";
409
516
  tsc = new TscAdapter();
410
517
  mypy = new MypyAdapter();
518
+ ruby = new RubySyntaxAdapter();
519
+ zeitwerk = new ZeitwerkAdapter();
411
520
  async run(ctx) {
412
521
  const start = Date.now();
413
522
  const config = ctx.config.typeSafety ?? DEFAULT_TYPE_SAFETY;
@@ -417,6 +526,9 @@ var TypeSafetyGate = class {
417
526
  if (ctx.language === "python" && config.mypyEnabled) {
418
527
  return this.runMypy(ctx, config, start);
419
528
  }
529
+ if (ctx.language === "ruby" && config.rubySyntaxEnabled !== false) {
530
+ return this.runRuby(ctx, config, start);
531
+ }
420
532
  const suggestion = ctx.language === "python" ? "Enable mypyEnabled in profile or install mypy: pip install mypy" : "No type checker supported for this language yet.";
421
533
  return toolMissingResult(this.name, start, `No type checker available for language: ${ctx.language}`, suggestion);
422
534
  }
@@ -457,6 +569,44 @@ var TypeSafetyGate = class {
457
569
  const result = await this.mypy.run(files, adapterConfig);
458
570
  return this.buildResult(result.findings, start);
459
571
  }
572
+ async runRuby(ctx, config, start) {
573
+ if (!await this.ruby.isAvailable()) {
574
+ return toolMissingResult(
575
+ this.name,
576
+ start,
577
+ "Ruby is not installed in the validation environment",
578
+ "Run the validator from the project Ruby/Docker environment."
579
+ );
580
+ }
581
+ const files = this.getFiles(ctx, this.ruby);
582
+ if (files.length === 0) return this.emptyResult(start);
583
+ const adapterConfig = { workdir: ctx.workdir, thresholds: {} };
584
+ const syntaxResult = await this.ruby.run(files, adapterConfig);
585
+ const findings = [...syntaxResult.findings];
586
+ if (findings.length > 0) return this.buildResult(findings, start);
587
+ if (config.railsZeitwerkEnabled !== false && this.zeitwerk.isRailsProject(ctx.workdir)) {
588
+ if (await this.zeitwerk.isAvailable()) {
589
+ const zeitwerkResult = await this.zeitwerk.run(files, adapterConfig);
590
+ findings.push(...zeitwerkResult.findings);
591
+ } else {
592
+ findings.push({
593
+ file: "Gemfile",
594
+ line: 0,
595
+ severity: "warning",
596
+ metric: "rails_check_skipped",
597
+ message: "Rails Zeitwerk check was skipped because Bundler is unavailable",
598
+ why: "Ruby syntax alone does not verify that Rails can boot and autoload constants.",
599
+ suggestion: "Run the validator inside the project Docker image with Bundler available.",
600
+ metadata: { source: "zeitwerk" }
601
+ });
602
+ }
603
+ }
604
+ const result = this.buildResult(findings, start);
605
+ if (findings.some((finding) => finding.metric === "rails_check_skipped") && result.status === "pass") {
606
+ result.status = "warn";
607
+ }
608
+ return result;
609
+ }
460
610
  buildResult(findings, start) {
461
611
  const score = calculateTypeSafetyScore({ findings });
462
612
  const status = deriveStatus(score, findings);
@@ -465,19 +615,7 @@ var TypeSafetyGate = class {
465
615
  };
466
616
 
467
617
  // src/adapters/lizard.ts
468
- import { execa as execa5 } from "execa";
469
-
470
- // src/utils.ts
471
- function chunk(arr, size) {
472
- if (size <= 0) return [arr];
473
- const chunks = [];
474
- for (let i = 0; i < arr.length; i += size) {
475
- chunks.push(arr.slice(i, i + size));
476
- }
477
- return chunks;
478
- }
479
-
480
- // src/adapters/lizard.ts
618
+ import { execa as execa7 } from "execa";
481
619
  var LizardAdapter = class {
482
620
  name = "lizard";
483
621
  supportedLanguages = [
@@ -498,7 +636,7 @@ var LizardAdapter = class {
498
636
  const allFindings = [];
499
637
  let totalFunctions = 0;
500
638
  for (const batch of chunk(files, 50)) {
501
- const result = await execa5("lizard", [...batch, "--csv"], {
639
+ const result = await execa7("lizard", [...batch, "--csv"], {
502
640
  cwd: config.workdir,
503
641
  reject: false
504
642
  });
@@ -658,7 +796,7 @@ var ComplexityGate = class {
658
796
  };
659
797
 
660
798
  // src/adapters/semgrep.ts
661
- import { execa as execa6 } from "execa";
799
+ import { execa as execa8 } from "execa";
662
800
  var SemgrepAdapter = class {
663
801
  name = "semgrep";
664
802
  supportedLanguages = [
@@ -680,15 +818,17 @@ var SemgrepAdapter = class {
680
818
  const allFindings = [];
681
819
  for (const batch of chunk(files, 50)) {
682
820
  const configArgs = rules.flatMap((r) => ["--config", r]);
683
- const result = await execa6(
821
+ const result = await execa8(
684
822
  "semgrep",
685
823
  [...configArgs, "--json", ...batch],
686
824
  { cwd: config.workdir, reject: false }
687
825
  );
688
- if (result.stdout) {
689
- const parsed = parseSemgrepJson(result.stdout);
690
- allFindings.push(...parsed);
826
+ const parsed = parseSemgrepJson(result.stdout || "");
827
+ if (!parsed || ![0, 1].includes(result.exitCode ?? -1) || parsed.errors.length > 0) {
828
+ allFindings.push(toolErrorFinding(result.exitCode));
829
+ continue;
691
830
  }
831
+ allFindings.push(...parsed.findings);
692
832
  }
693
833
  return { findings: allFindings };
694
834
  }
@@ -698,12 +838,12 @@ function parseSemgrepJson(json) {
698
838
  try {
699
839
  output = JSON.parse(json);
700
840
  } catch {
701
- return [];
841
+ return null;
702
842
  }
703
843
  if (!output.results || !Array.isArray(output.results)) {
704
- return [];
844
+ return null;
705
845
  }
706
- return output.results.map((r) => ({
846
+ const findings = output.results.map((r) => ({
707
847
  file: r.path,
708
848
  line: r.start.line,
709
849
  end_line: r.end.line,
@@ -720,6 +860,19 @@ function parseSemgrepJson(json) {
720
860
  source: "semgrep"
721
861
  }
722
862
  }));
863
+ return { findings, errors: Array.isArray(output.errors) ? output.errors : [] };
864
+ }
865
+ function toolErrorFinding(exitCode) {
866
+ return {
867
+ file: "",
868
+ line: 0,
869
+ severity: "blocker",
870
+ metric: "security_tool_error",
871
+ message: "Semgrep failed or produced an invalid report",
872
+ why: "A failed security scan cannot be interpreted as having no vulnerabilities.",
873
+ suggestion: "Run Semgrep directly and verify network access, rules, and JSON output.",
874
+ metadata: { source: "semgrep", exitCode }
875
+ };
723
876
  }
724
877
  function mapSeverity(severity) {
725
878
  switch (severity) {
@@ -748,7 +901,10 @@ function buildWhy2(result) {
748
901
  }
749
902
 
750
903
  // src/adapters/gitleaks.ts
751
- import { execa as execa7 } from "execa";
904
+ import { execa as execa9 } from "execa";
905
+ import fs4 from "fs";
906
+ import os from "os";
907
+ import path5 from "path";
752
908
  var GitleaksAdapter = class {
753
909
  name = "gitleaks";
754
910
  supportedLanguages = [
@@ -763,19 +919,32 @@ var GitleaksAdapter = class {
763
919
  async isAvailable() {
764
920
  return isBinaryAvailable("gitleaks");
765
921
  }
766
- async run(files, config) {
767
- const result = await execa7(
768
- "gitleaks",
769
- ["detect", "--source", config.workdir, "--no-git", "-f", "json", "--report-path", "/dev/stdout"],
770
- { cwd: config.workdir, reject: false }
771
- );
772
- if (!result.stdout || result.stdout.trim() === "") {
773
- return { findings: [] };
922
+ async run(_files, config) {
923
+ const reportDirectory = fs4.mkdtempSync(path5.join(os.tmpdir(), "validator-gitleaks-"));
924
+ const reportPath = path5.join(reportDirectory, "report.json");
925
+ try {
926
+ const result = await execa9(
927
+ "gitleaks",
928
+ ["detect", "--source", config.workdir, "--no-git", "-f", "json", "--report-path", reportPath],
929
+ { cwd: config.workdir, reject: false }
930
+ );
931
+ const output = fs4.existsSync(reportPath) ? fs4.readFileSync(reportPath, "utf8") : result.stdout;
932
+ if ((!output || output.trim() === "") && result.exitCode === 0) {
933
+ return { findings: [] };
934
+ }
935
+ const allLeaks = parseGitleaksJson(output);
936
+ if (!allLeaks || ![0, 1].includes(result.exitCode ?? -1)) {
937
+ return { findings: [toolErrorFinding2(result.exitCode)] };
938
+ }
939
+ return {
940
+ findings: allLeaks.map((finding) => ({
941
+ ...finding,
942
+ file: path5.isAbsolute(finding.file) ? path5.relative(config.workdir, finding.file) : finding.file
943
+ }))
944
+ };
945
+ } finally {
946
+ fs4.rmSync(reportDirectory, { recursive: true, force: true });
774
947
  }
775
- const allLeaks = parseGitleaksJson(result.stdout);
776
- const fileSet = new Set(files);
777
- const filtered = allLeaks.filter((f) => fileSet.has(f.file));
778
- return { findings: filtered };
779
948
  }
780
949
  };
781
950
  function parseGitleaksJson(json) {
@@ -783,10 +952,10 @@ function parseGitleaksJson(json) {
783
952
  try {
784
953
  leaks = JSON.parse(json);
785
954
  } catch {
786
- return [];
955
+ return null;
787
956
  }
788
957
  if (!Array.isArray(leaks)) {
789
- return [];
958
+ return null;
790
959
  }
791
960
  return leaks.map((leak) => ({
792
961
  file: leak.File,
@@ -805,21 +974,112 @@ function parseGitleaksJson(json) {
805
974
  }
806
975
  }));
807
976
  }
977
+ function toolErrorFinding2(exitCode) {
978
+ return {
979
+ file: "",
980
+ line: 0,
981
+ severity: "blocker",
982
+ metric: "security_tool_error",
983
+ message: "Gitleaks failed or produced an invalid report",
984
+ why: "A failed secret scan cannot be interpreted as having no exposed credentials.",
985
+ suggestion: "Run Gitleaks directly and verify its configuration and JSON output.",
986
+ metadata: { source: "gitleaks", exitCode }
987
+ };
988
+ }
989
+
990
+ // src/adapters/brakeman.ts
991
+ import fs5 from "fs";
992
+ import path6 from "path";
993
+ import { execa as execa10 } from "execa";
994
+ var BrakemanAdapter = class {
995
+ name = "brakeman";
996
+ supportedLanguages = ["ruby"];
997
+ isRailsProject(workdir) {
998
+ return fs5.existsSync(path6.join(workdir, "Gemfile")) && fs5.existsSync(path6.join(workdir, "config", "application.rb"));
999
+ }
1000
+ async isAvailable() {
1001
+ return isBinaryAvailable("brakeman");
1002
+ }
1003
+ async run(_files, config) {
1004
+ const result = await execa10(
1005
+ "brakeman",
1006
+ ["--format", "json", "--quiet", "--no-exit-on-warn"],
1007
+ { cwd: config.workdir, reject: false }
1008
+ );
1009
+ const report = parseReport(result.stdout || "");
1010
+ if (!report || result.exitCode !== 0 || report.errors?.length) {
1011
+ return { findings: [toolErrorFinding3(result.exitCode)] };
1012
+ }
1013
+ return { findings: report.warnings.map(toFinding) };
1014
+ }
1015
+ };
1016
+ function parseReport(output) {
1017
+ try {
1018
+ const report = JSON.parse(output);
1019
+ return Array.isArray(report.warnings) ? report : null;
1020
+ } catch {
1021
+ return null;
1022
+ }
1023
+ }
1024
+ function toFinding(warning) {
1025
+ return {
1026
+ file: warning.file,
1027
+ line: warning.line ?? 0,
1028
+ severity: warning.confidence === "High" ? "blocker" : warning.confidence === "Medium" ? "warning" : "info",
1029
+ metric: "rails_security",
1030
+ message: warning.message,
1031
+ why: `${warning.warning_type} detected by Brakeman's ${warning.check_name} check.`,
1032
+ suggestion: "Review the affected Rails code and apply the mitigation recommended by Brakeman.",
1033
+ metadata: {
1034
+ source: "brakeman",
1035
+ warningCode: warning.warning_code,
1036
+ fingerprint: warning.fingerprint,
1037
+ confidence: warning.confidence
1038
+ }
1039
+ };
1040
+ }
1041
+ function toolErrorFinding3(exitCode) {
1042
+ return {
1043
+ file: "",
1044
+ line: 0,
1045
+ severity: "blocker",
1046
+ metric: "security_tool_error",
1047
+ message: "Brakeman failed or produced an invalid report",
1048
+ why: "A failed Rails security scan cannot be interpreted as having no vulnerabilities.",
1049
+ suggestion: "Run brakeman --format json --no-exit-on-warn directly and fix the reported execution error.",
1050
+ metadata: { source: "brakeman", exitCode }
1051
+ };
1052
+ }
808
1053
 
809
1054
  // src/gates/security.ts
810
1055
  var SecurityGate = class {
811
1056
  name = "security";
812
1057
  semgrep = new SemgrepAdapter();
813
1058
  gitleaks = new GitleaksAdapter();
1059
+ brakeman = new BrakemanAdapter();
814
1060
  async run(ctx) {
815
1061
  const start = Date.now();
816
1062
  const securityConfig = ctx.config.security ?? DEFAULT_SECURITY;
817
1063
  const semgrepAvailable = await this.semgrep.isAvailable();
818
1064
  const gitleaksAvailable = securityConfig.gitleaksEnabled ? await this.gitleaks.isAvailable() : false;
819
- if (!semgrepAvailable && !gitleaksAvailable) {
820
- return toolMissingResult(this.name, start, "Neither semgrep nor gitleaks is installed", "Install with: pip install semgrep OR brew install gitleaks");
1065
+ const brakemanApplicable = ctx.language === "ruby" && securityConfig.brakemanEnabled !== false && this.brakeman.isRailsProject(ctx.workdir);
1066
+ const brakemanAvailable = brakemanApplicable ? await this.brakeman.isAvailable() : false;
1067
+ if (!semgrepAvailable && !gitleaksAvailable && !brakemanAvailable) {
1068
+ return toolMissingResult(this.name, start, "No security tools are installed", "Install Semgrep, Gitleaks, or Brakeman.");
821
1069
  }
822
1070
  const allFindings = [];
1071
+ if (brakemanApplicable && !brakemanAvailable) {
1072
+ allFindings.push({
1073
+ file: "Gemfile",
1074
+ line: 0,
1075
+ severity: "info",
1076
+ metric: "rails_security_skipped",
1077
+ message: "Brakeman is not installed; Rails-specific security checks were skipped",
1078
+ why: "Generic security rules do not cover all Rails-specific vulnerability patterns.",
1079
+ suggestion: "Install Brakeman or use the Rails validator image.",
1080
+ metadata: { source: "brakeman" }
1081
+ });
1082
+ }
823
1083
  if (semgrepAvailable) {
824
1084
  const supportedFiles = ctx.files.filter((f) => this.semgrep.supportedLanguages.includes(f.language)).map((f) => f.relativePath);
825
1085
  if (supportedFiles.length > 0) {
@@ -840,15 +1100,23 @@ var SecurityGate = class {
840
1100
  });
841
1101
  allFindings.push(...result.findings);
842
1102
  }
1103
+ if (brakemanAvailable) {
1104
+ const result = await this.brakeman.run([], {
1105
+ workdir: ctx.workdir,
1106
+ thresholds: {}
1107
+ });
1108
+ allFindings.push(...result.findings);
1109
+ }
843
1110
  const score = calculateSecurityScore({ findings: allFindings });
844
- const status = deriveStatus(score, allFindings);
1111
+ const derivedStatus = deriveStatus(score, allFindings);
1112
+ const status = allFindings.some((finding) => finding.metric === "rails_security_skipped") && derivedStatus === "pass" ? "warn" : derivedStatus;
845
1113
  const duration_ms = Date.now() - start;
846
1114
  return { gate: this.name, score, status, duration_ms, findings: allFindings };
847
1115
  }
848
1116
  };
849
1117
 
850
1118
  // src/adapters/madge.ts
851
- import { execa as execa8 } from "execa";
1119
+ import { execa as execa11 } from "execa";
852
1120
  function parseCycles(stdout) {
853
1121
  if (!stdout.trim()) return null;
854
1122
  try {
@@ -880,7 +1148,7 @@ var MadgeAdapter = class {
880
1148
  }
881
1149
  async run(files, config) {
882
1150
  if (files.length === 0) return { findings: [] };
883
- const result = await execa8("madge", ["--circular", "--json", config.workdir], {
1151
+ const result = await execa11("madge", ["--circular", "--json", config.workdir], {
884
1152
  cwd: config.workdir,
885
1153
  reject: false
886
1154
  });
@@ -893,10 +1161,10 @@ var MadgeAdapter = class {
893
1161
  };
894
1162
 
895
1163
  // src/adapters/jscpd.ts
896
- import { execa as execa9 } from "execa";
897
- import fs3 from "fs";
898
- import os from "os";
899
- import path4 from "path";
1164
+ import { execa as execa12 } from "execa";
1165
+ import fs6 from "fs";
1166
+ import os2 from "os";
1167
+ import path7 from "path";
900
1168
  function buildArgs(config, tmpDir) {
901
1169
  const minLines = config.minLines ?? 5;
902
1170
  const minTokens = config.minTokens ?? 50;
@@ -918,9 +1186,9 @@ function buildArgs(config, tmpDir) {
918
1186
  args.push(config.workdir);
919
1187
  return args;
920
1188
  }
921
- function parseReport(reportPath) {
922
- if (!fs3.existsSync(reportPath)) return null;
923
- const raw = fs3.readFileSync(reportPath, "utf-8");
1189
+ function parseReport2(reportPath) {
1190
+ if (!fs6.existsSync(reportPath)) return null;
1191
+ const raw = fs6.readFileSync(reportPath, "utf-8");
924
1192
  try {
925
1193
  const report = JSON.parse(raw);
926
1194
  if (!report.duplicates || !Array.isArray(report.duplicates)) return null;
@@ -930,8 +1198,8 @@ function parseReport(reportPath) {
930
1198
  }
931
1199
  }
932
1200
  function cloneToFinding(clone, workdir) {
933
- const firstRel = path4.relative(workdir, clone.firstFile.name);
934
- const secondRel = path4.relative(workdir, clone.secondFile.name);
1201
+ const firstRel = path7.relative(workdir, clone.firstFile.name);
1202
+ const secondRel = path7.relative(workdir, clone.secondFile.name);
935
1203
  return {
936
1204
  file: firstRel,
937
1205
  line: clone.firstFile.startLoc.line,
@@ -959,28 +1227,28 @@ var JscpdAdapter = class {
959
1227
  async run(files, config) {
960
1228
  if (files.length === 0) return { findings: [] };
961
1229
  const jscpdConfig = config;
962
- const tmpDir = fs3.mkdtempSync(path4.join(os.tmpdir(), "jscpd-"));
1230
+ const tmpDir = fs6.mkdtempSync(path7.join(os2.tmpdir(), "jscpd-"));
963
1231
  try {
964
1232
  const args = buildArgs(jscpdConfig, tmpDir);
965
- await execa9("jscpd", args, { cwd: config.workdir, reject: false });
966
- const report = parseReport(path4.join(tmpDir, "jscpd-report.json"));
1233
+ await execa12("jscpd", args, { cwd: config.workdir, reject: false });
1234
+ const report = parseReport2(path7.join(tmpDir, "jscpd-report.json"));
967
1235
  if (!report) return { findings: [] };
968
1236
  const fileSet = new Set(files);
969
1237
  return {
970
1238
  findings: report.duplicates.filter((clone) => {
971
- const firstRel = path4.relative(config.workdir, clone.firstFile.name);
972
- const secondRel = path4.relative(config.workdir, clone.secondFile.name);
1239
+ const firstRel = path7.relative(config.workdir, clone.firstFile.name);
1240
+ const secondRel = path7.relative(config.workdir, clone.secondFile.name);
973
1241
  return fileSet.has(firstRel) || fileSet.has(secondRel);
974
1242
  }).map((clone) => cloneToFinding(clone, config.workdir))
975
1243
  };
976
1244
  } finally {
977
- fs3.rmSync(tmpDir, { recursive: true, force: true });
1245
+ fs6.rmSync(tmpDir, { recursive: true, force: true });
978
1246
  }
979
1247
  }
980
1248
  };
981
1249
 
982
1250
  // src/adapters/knip.ts
983
- import { execa as execa10 } from "execa";
1251
+ import { execa as execa13 } from "execa";
984
1252
  function collectUnusedFiles(report, fileSet) {
985
1253
  if (!report.files || !Array.isArray(report.files)) return [];
986
1254
  return report.files.filter((file) => fileSet.has(file)).map((file) => ({
@@ -1036,7 +1304,7 @@ var KnipAdapter = class {
1036
1304
  }
1037
1305
  async run(files, config) {
1038
1306
  if (files.length === 0) return { findings: [] };
1039
- const result = await execa10("knip", ["--reporter", "json"], {
1307
+ const result = await execa13("knip", ["--reporter", "json"], {
1040
1308
  cwd: config.workdir,
1041
1309
  reject: false
1042
1310
  });
@@ -1118,9 +1386,9 @@ var ArchitectureGate = class {
1118
1386
  };
1119
1387
 
1120
1388
  // src/adapters/stryker.ts
1121
- import { execa as execa11 } from "execa";
1122
- import fs4 from "fs";
1123
- import path5 from "path";
1389
+ import { execa as execa14 } from "execa";
1390
+ import fs7 from "fs";
1391
+ import path8 from "path";
1124
1392
 
1125
1393
  // src/adapters/mutation-shared.ts
1126
1394
  function scoreSeverity(score) {
@@ -1136,7 +1404,7 @@ function timeoutResult(tool, timeout) {
1136
1404
  findings: [{
1137
1405
  file: "",
1138
1406
  line: 0,
1139
- severity: "info",
1407
+ severity: "blocker",
1140
1408
  metric: "mutation_timeout",
1141
1409
  message: tool + " timed out after " + timeout + "ms",
1142
1410
  why: "Mutation testing exceeded the configured timeout.",
@@ -1234,16 +1502,16 @@ function survivorToFinding(file, mutant) {
1234
1502
  }
1235
1503
  function findReportPath(workdir) {
1236
1504
  const candidates = [
1237
- path5.join(workdir, "reports", "mutation", "mutation.json"),
1238
- path5.join(workdir, "reports", "mutation.json")
1505
+ path8.join(workdir, "reports", "mutation", "mutation.json"),
1506
+ path8.join(workdir, "reports", "mutation.json")
1239
1507
  ];
1240
- return candidates.find((p) => fs4.existsSync(p));
1508
+ return candidates.find((p) => fs7.existsSync(p));
1241
1509
  }
1242
1510
  function readReport(workdir) {
1243
1511
  const reportPath = findReportPath(workdir);
1244
1512
  if (!reportPath) return null;
1245
1513
  try {
1246
- const raw = fs4.readFileSync(reportPath, "utf-8");
1514
+ const raw = fs7.readFileSync(reportPath, "utf-8");
1247
1515
  return JSON.parse(raw);
1248
1516
  } catch {
1249
1517
  return null;
@@ -1274,8 +1542,8 @@ var StrykerAdapter = class {
1274
1542
  supportedLanguages = ["typescript", "javascript"];
1275
1543
  async isAvailable(workdir) {
1276
1544
  if (workdir) {
1277
- const localBin = path5.join(workdir, "node_modules", ".bin", "stryker");
1278
- if (fs4.existsSync(localBin)) return true;
1545
+ const localBin = path8.join(workdir, "node_modules", ".bin", "stryker");
1546
+ if (fs7.existsSync(localBin)) return true;
1279
1547
  }
1280
1548
  return await isBinaryAvailable("stryker");
1281
1549
  }
@@ -1291,10 +1559,10 @@ var StrykerAdapter = class {
1291
1559
  async executeStryker(files, config, timeout) {
1292
1560
  const mutatePattern = files.join(",");
1293
1561
  const args = ["run", "--reporters", "json", "--mutate", mutatePattern];
1294
- const useNpx = fs4.existsSync(path5.join(config.workdir, "node_modules", ".bin", "stryker"));
1562
+ const useNpx = fs7.existsSync(path8.join(config.workdir, "node_modules", ".bin", "stryker"));
1295
1563
  const command = useNpx ? "npx" : "stryker";
1296
1564
  const execArgs = useNpx ? ["stryker", ...args] : args;
1297
- const result = await execa11(command, execArgs, {
1565
+ const result = await execa14(command, execArgs, {
1298
1566
  cwd: config.workdir,
1299
1567
  reject: false,
1300
1568
  timeout
@@ -1304,7 +1572,7 @@ var StrykerAdapter = class {
1304
1572
  };
1305
1573
 
1306
1574
  // src/adapters/mutmut.ts
1307
- import { execa as execa12 } from "execa";
1575
+ import { execa as execa15 } from "execa";
1308
1576
  function matchToResult(match) {
1309
1577
  const classname = match[1];
1310
1578
  const name = match[2];
@@ -1378,13 +1646,13 @@ var MutmutAdapter = class {
1378
1646
  }
1379
1647
  async executeMutmut(files, config, timeout) {
1380
1648
  const pathsToMutate = files.join(",");
1381
- const runResult = await execa12("mutmut", ["run", `--paths-to-mutate=${pathsToMutate}`, "--CI", "--no-progress"], {
1649
+ const runResult = await execa15("mutmut", ["run", `--paths-to-mutate=${pathsToMutate}`, "--CI", "--no-progress"], {
1382
1650
  cwd: config.workdir,
1383
1651
  reject: false,
1384
1652
  timeout
1385
1653
  });
1386
1654
  if (runResult.timedOut) return null;
1387
- const xmlResult = await execa12("mutmut", ["junitxml"], {
1655
+ const xmlResult = await execa15("mutmut", ["junitxml"], {
1388
1656
  cwd: config.workdir,
1389
1657
  reject: false
1390
1658
  });
@@ -1412,8 +1680,10 @@ var MutmutAdapter = class {
1412
1680
  };
1413
1681
 
1414
1682
  // src/adapters/mutant.ts
1415
- import { execa as execa13 } from "execa";
1416
- var RESULT_LINE_RE = /^(alive|killed|timeout):(.+):(.+):(\d+)/;
1683
+ import { execa as execa16 } from "execa";
1684
+ import fs8 from "fs";
1685
+ import path9 from "path";
1686
+ var RESULT_LINE_RE = /^(alive|evil|killed|timeout):(.+):([^:]+\.rb):(\d+)(?::.*)?$/;
1417
1687
  var COVERAGE_RE = /Coverage:\s+([\d.]+)%/;
1418
1688
  function parseOutput(stdout) {
1419
1689
  const entries = [];
@@ -1422,7 +1692,7 @@ function parseOutput(stdout) {
1422
1692
  const resultMatch = RESULT_LINE_RE.exec(line);
1423
1693
  if (resultMatch) {
1424
1694
  entries.push({
1425
- status: resultMatch[1],
1695
+ status: resultMatch[1] === "evil" ? "alive" : resultMatch[1],
1426
1696
  subject: resultMatch[2],
1427
1697
  file: resultMatch[3],
1428
1698
  line: parseInt(resultMatch[4], 10)
@@ -1457,7 +1727,7 @@ function groupByFile2(entries) {
1457
1727
  const fileStats = /* @__PURE__ */ new Map();
1458
1728
  for (const [file, g] of groups) {
1459
1729
  const total = g.killed + g.survived + g.timeout;
1460
- const score = total > 0 ? (g.killed + g.timeout) / total * 100 : 100;
1730
+ const score = total > 0 ? g.killed / total * 100 : 100;
1461
1731
  fileStats.set(file, { killed: g.killed, survived: g.survived, timeout: g.timeout, total, score, survivors: g.survivors });
1462
1732
  }
1463
1733
  return fileStats;
@@ -1485,7 +1755,7 @@ function processEntries(opts) {
1485
1755
  let totalMutants = 0;
1486
1756
  for (const [file, stats] of fileStatsMap) {
1487
1757
  if (!fileSet.has(file)) continue;
1488
- totalKilled += stats.killed + stats.timeout;
1758
+ totalKilled += stats.killed;
1489
1759
  totalMutants += stats.total;
1490
1760
  if (stats.score < opts.threshold) {
1491
1761
  findings.push(fileStatsToFinding(file, stats, opts.threshold));
@@ -1498,31 +1768,154 @@ function processEntries(opts) {
1498
1768
  var MutantAdapter = class {
1499
1769
  name = "mutant";
1500
1770
  supportedLanguages = ["ruby"];
1501
- async isAvailable() {
1502
- return isBinaryAvailable("mutant");
1771
+ async isAvailable(workdir = process.cwd()) {
1772
+ if (!fs8.existsSync(path9.join(workdir, "Gemfile"))) {
1773
+ return isBinaryAvailable("mutant");
1774
+ }
1775
+ try {
1776
+ const result = await execa16("bundle", ["exec", "mutant", "--version"], {
1777
+ cwd: workdir,
1778
+ reject: false
1779
+ });
1780
+ return result.exitCode === 0;
1781
+ } catch {
1782
+ return false;
1783
+ }
1503
1784
  }
1504
1785
  async run(files, config) {
1505
1786
  if (files.length === 0) return { findings: [], mutationScore: 100 };
1506
1787
  const timeout = config.timeout ?? 3e5;
1507
1788
  const threshold = config.mutationScoreThreshold ?? 80;
1508
1789
  const maxSurvivorFindings = config.maxSurvivorFindings ?? 5;
1509
- const stdout = await this.executeMutant(config.workdir, timeout);
1510
- if (stdout === null) return timeoutResult("mutant", timeout);
1511
- if (!stdout.trim()) return { findings: [], mutationScore: 100 };
1512
- const { entries, overallScore } = parseOutput(stdout);
1513
- if (entries.length === 0 && overallScore === null) return { findings: [], mutationScore: 100 };
1790
+ if (!config.usage) {
1791
+ return toolError(
1792
+ "Mutant requires an explicit usage policy",
1793
+ "Set --mutant-usage opensource or --mutant-usage commercial according to the project license."
1794
+ );
1795
+ }
1796
+ let execution;
1797
+ try {
1798
+ execution = await this.executeMutant(files, config.workdir, timeout, config.usage);
1799
+ } catch (error) {
1800
+ const detail = error instanceof Error ? error.message : "unknown execution error";
1801
+ return toolError(`Mutant could not start: ${detail}`, "Verify Bundler, Mutant, and the project test environment.");
1802
+ }
1803
+ if (execution.timedOut) return timeoutResult("mutant", timeout);
1804
+ const stdout = execution.stdout;
1805
+ if (!stdout.trim()) {
1806
+ return toolError("Mutant produced no report", "Run Mutant through the project bundle and verify its RSpec integration.");
1807
+ }
1808
+ if (![0, 1].includes(execution.exitCode ?? -1)) {
1809
+ return toolError(
1810
+ `Mutant failed with exit code ${execution.exitCode ?? "unknown"}`,
1811
+ "Run Mutant directly and fix its bundle, licence, Rails, or RSpec configuration."
1812
+ );
1813
+ }
1814
+ const parsed = parseOutput(stdout);
1815
+ const entries = parsed.entries.map((entry) => ({
1816
+ ...entry,
1817
+ file: path9.isAbsolute(entry.file) ? path9.relative(config.workdir, entry.file) : entry.file
1818
+ }));
1819
+ const { overallScore } = parsed;
1820
+ if (entries.length === 0 && overallScore === null) {
1821
+ return toolError("Mutant output could not be parsed", "Check the installed Mutant version and its command output.");
1822
+ }
1514
1823
  return processEntries({ entries, overallScore, files, threshold, maxSurvivorFindings });
1515
1824
  }
1516
- async executeMutant(workdir, timeout) {
1517
- const result = await execa13("mutant", ["run", "--include", "lib", "--use", "rspec"], {
1825
+ async executeMutant(files, workdir, timeout, usage) {
1826
+ const bundled = fs8.existsSync(path9.join(workdir, "Gemfile"));
1827
+ const command = bundled ? "bundle" : "mutant";
1828
+ const roots = [...new Set(files.map((file) => file.split("/")[0]).filter((root) => root === "app" || root === "lib"))];
1829
+ const railsArgs = fs8.existsSync(path9.join(workdir, "config", "environment.rb")) ? ["--require", "./config/environment"] : [];
1830
+ const mutantArgs = [
1831
+ "run",
1832
+ "--usage",
1833
+ usage,
1834
+ ...roots.flatMap((root) => ["--include", root]),
1835
+ ...railsArgs,
1836
+ "--integration",
1837
+ "rspec",
1838
+ "--jobs",
1839
+ "1",
1840
+ "--",
1841
+ ...files.map((file) => `source:${file}`)
1842
+ ];
1843
+ const args = bundled ? ["exec", "mutant", ...mutantArgs] : mutantArgs;
1844
+ const result = await execa16(command, args, {
1518
1845
  cwd: workdir,
1846
+ env: { RAILS_ENV: "test", CI: "true" },
1519
1847
  reject: false,
1520
1848
  timeout
1521
1849
  });
1522
- if (result.timedOut) return null;
1523
- return result.stdout || "";
1850
+ return { stdout: result.stdout || "", timedOut: result.timedOut, exitCode: result.exitCode };
1851
+ }
1852
+ };
1853
+ function toolError(message, suggestion) {
1854
+ return {
1855
+ findings: [{
1856
+ file: "",
1857
+ line: 0,
1858
+ severity: "blocker",
1859
+ metric: "mutation_tool_error",
1860
+ message,
1861
+ why: "A failed mutation test cannot be treated as a perfect mutation score.",
1862
+ suggestion,
1863
+ metadata: { source: "mutant" }
1864
+ }],
1865
+ mutationScore: 0
1866
+ };
1867
+ }
1868
+
1869
+ // src/adapters/test-command.ts
1870
+ import { execaCommand } from "execa";
1871
+ var TestCommandAdapter = class {
1872
+ name = "test-command";
1873
+ supportedLanguages = ["typescript", "javascript", "python", "ruby", "go", "rust"];
1874
+ async isAvailable() {
1875
+ return true;
1876
+ }
1877
+ async run(_files, config) {
1878
+ let result;
1879
+ try {
1880
+ result = await execaCommand(config.command, {
1881
+ cwd: config.workdir,
1882
+ env: { CI: "true" },
1883
+ reject: false,
1884
+ timeout: config.timeout
1885
+ });
1886
+ } catch (error) {
1887
+ const detail = error instanceof Error ? error.message : "unknown execution error";
1888
+ return this.failure(`Test command could not start: ${detail}`, "test_command_failed");
1889
+ }
1890
+ if (result.timedOut) {
1891
+ return this.failure(`Test command timed out after ${config.timeout}ms`, "test_command_timeout");
1892
+ }
1893
+ if (result.exitCode !== 0) {
1894
+ const detail = diagnosticLine(result.stderr) ?? diagnosticLine(result.stdout);
1895
+ const message = detail ? `Test command failed with exit code ${result.exitCode}: ${detail}` : `Test command failed with exit code ${result.exitCode}`;
1896
+ return this.failure(message, "test_command_failed");
1897
+ }
1898
+ return { findings: [], mutationScore: 100 };
1899
+ }
1900
+ failure(message, metric) {
1901
+ const finding = {
1902
+ file: "",
1903
+ line: 0,
1904
+ severity: "blocker",
1905
+ metric,
1906
+ message,
1907
+ why: "The project-defined test suite must complete successfully.",
1908
+ suggestion: "Run the configured test command directly and inspect its full logs.",
1909
+ metadata: { source: "test-command" }
1910
+ };
1911
+ return { findings: [finding], mutationScore: 0 };
1524
1912
  }
1525
1913
  };
1914
+ function diagnosticLine(output) {
1915
+ const lines = output.split("\n").map((line) => line.trim()).filter((line) => line && !/^=+$/.test(line));
1916
+ const errorLine = lines.find((line) => /error|failed|cannot|denied|no such file|docker:/i.test(line));
1917
+ return (errorLine ?? lines.at(-1))?.slice(0, 500);
1918
+ }
1526
1919
 
1527
1920
  // src/gates/test-quality.ts
1528
1921
  var TestQualityGate = class {
@@ -1530,16 +1923,18 @@ var TestQualityGate = class {
1530
1923
  stryker = new StrykerAdapter();
1531
1924
  mutmut = new MutmutAdapter();
1532
1925
  mutant = new MutantAdapter();
1926
+ testCommand = new TestCommandAdapter();
1533
1927
  async run(ctx) {
1534
1928
  const start = Date.now();
1535
1929
  const config = ctx.config.testQuality ?? DEFAULT_TEST_QUALITY;
1536
1930
  const available = await this.checkAvailability(config, ctx);
1537
- if (!available.stryker && !available.mutmut && !available.mutant) {
1931
+ if (!available.stryker && !available.mutmut && !available.mutant && !available.testCommand) {
1538
1932
  return toolMissingResult(this.name, start, "No mutation testing tools available", "Install: npm i -D @stryker-mutator/core | pip install mutmut | gem install mutant");
1539
1933
  }
1540
- const { findings, mutationScore } = await this.collectFindings(available, config, ctx);
1934
+ const { findings, mutationScore, mutationRan } = await this.collectFindings(available, config, ctx);
1541
1935
  const score = calculateTestQualityScore({ mutationScore });
1542
- const status = deriveStatus(score, findings);
1936
+ const hasBlocker = findings.some((finding) => finding.severity === "blocker");
1937
+ const status = !mutationRan && available.testCommand && !hasBlocker ? "warn" : score < config.mutationScoreThreshold ? "fail" : deriveStatus(score, findings);
1543
1938
  return { gate: this.name, score, status, duration_ms: Date.now() - start, findings };
1544
1939
  }
1545
1940
  async checkAvailability(config, ctx) {
@@ -1549,13 +1944,13 @@ var TestQualityGate = class {
1549
1944
  return {
1550
1945
  stryker: config.strykerEnabled && tsOrJs ? await this.stryker.isAvailable(ctx.workdir) : false,
1551
1946
  mutmut: config.mutmutEnabled && isPython ? await this.mutmut.isAvailable() : false,
1552
- mutant: config.mutantEnabled && isRuby ? await this.mutant.isAvailable() : false
1947
+ mutant: config.mutantEnabled && isRuby && (!config.testCommand || Boolean(config.mutantUsage)) ? await this.mutant.isAvailable(ctx.workdir) : false,
1948
+ testCommand: Boolean(config.testCommand)
1553
1949
  };
1554
1950
  }
1555
1951
  async collectFindings(available, config, ctx) {
1556
1952
  const allFindings = [];
1557
- let totalScore = 0;
1558
- let adapterCount = 0;
1953
+ const scores = [];
1559
1954
  const adapters = [
1560
1955
  { available: available.stryker, adapter: this.stryker },
1561
1956
  { available: available.mutmut, adapter: this.mutmut },
@@ -1567,13 +1962,33 @@ var TestQualityGate = class {
1567
1962
  if (result) {
1568
1963
  allFindings.push(...result.findings);
1569
1964
  if (!result.timedOut) {
1570
- totalScore += result.mutationScore;
1571
- adapterCount++;
1965
+ scores.push(result.mutationScore);
1572
1966
  }
1573
1967
  }
1574
1968
  }
1575
- const mutationScore = adapterCount > 0 ? totalScore / adapterCount : 100;
1576
- return { findings: allFindings, mutationScore };
1969
+ if (available.testCommand && config.testCommand) {
1970
+ const result = await this.testCommand.run(ctx.files.map((file) => file.relativePath), {
1971
+ workdir: ctx.workdir,
1972
+ thresholds: {},
1973
+ command: config.testCommand,
1974
+ timeout: config.timeout
1975
+ });
1976
+ allFindings.push(...result.findings);
1977
+ if (result.findings.length === 0 && scores.length === 0) {
1978
+ allFindings.push({
1979
+ file: "",
1980
+ line: 0,
1981
+ severity: "info",
1982
+ metric: "mutation_not_run",
1983
+ message: "Project tests passed, but mutation testing was not run",
1984
+ why: "A passing test suite does not measure whether tests detect behavioral changes.",
1985
+ suggestion: "Configure Mutant in the project bundle to obtain a mutation score.",
1986
+ metadata: { source: "test-command" }
1987
+ });
1988
+ }
1989
+ }
1990
+ const mutationScore = scores.length > 0 ? Math.min(...scores) : 0;
1991
+ return { findings: allFindings, mutationScore, mutationRan: scores.length > 0 };
1577
1992
  }
1578
1993
  async runAdapter(adapter, ctx, config) {
1579
1994
  const files = ctx.files.filter((f) => adapter.supportedLanguages.includes(f.language)).map((f) => f.relativePath);
@@ -1583,7 +1998,8 @@ var TestQualityGate = class {
1583
1998
  thresholds: {},
1584
1999
  timeout: config.timeout,
1585
2000
  mutationScoreThreshold: config.mutationScoreThreshold,
1586
- maxSurvivorFindings: config.maxSurvivorFindings
2001
+ maxSurvivorFindings: config.maxSurvivorFindings,
2002
+ usage: config.mutantUsage
1587
2003
  });
1588
2004
  }
1589
2005
  };
@@ -1638,7 +2054,8 @@ async function runGates(ctx, gateNames) {
1638
2054
 
1639
2055
  // src/reporter.ts
1640
2056
  import chalk from "chalk";
1641
- function reportText(results, meta) {
2057
+ import { stripVTControlCharacters } from "util";
2058
+ function reportText(results, meta, options = {}) {
1642
2059
  const lines = [];
1643
2060
  lines.push(chalk.bold("=== VALIDATOR REPORT ==="));
1644
2061
  lines.push(`Files analyzed: ${meta.filesAnalyzed} (mode: ${meta.mode})`);
@@ -1670,7 +2087,8 @@ function reportText(results, meta) {
1670
2087
  lines.push(
1671
2088
  `RESULT: ${overall.toUpperCase()} (${blockerCount} blockers, ${warnCount} warnings)`
1672
2089
  );
1673
- return lines.join("\n");
2090
+ const report = lines.join("\n");
2091
+ return options.ansi === false ? stripVTControlCharacters(report) : report;
1674
2092
  }
1675
2093
  function reportJson(results, meta) {
1676
2094
  const allFindings = results.flatMap((r) => r.findings);
@@ -1688,7 +2106,11 @@ function reportJson(results, meta) {
1688
2106
  },
1689
2107
  gates: Object.fromEntries(results.map((r) => [r.gate, r]))
1690
2108
  };
1691
- return JSON.stringify(report, null, 2);
2109
+ return JSON.stringify(
2110
+ report,
2111
+ (_key, value) => typeof value === "string" ? stripVTControlCharacters(value) : value,
2112
+ 2
2113
+ );
1692
2114
  }
1693
2115
  function statusIcon(status) {
1694
2116
  switch (status) {
@@ -1786,29 +2208,70 @@ function getProfile(name) {
1786
2208
  return profile;
1787
2209
  }
1788
2210
 
2211
+ // src/output.ts
2212
+ import fs9 from "fs";
2213
+ import path10 from "path";
2214
+ async function writeReport(output, outputPath, workdir) {
2215
+ const absolutePath = path10.resolve(workdir, outputPath);
2216
+ await fs9.promises.mkdir(path10.dirname(absolutePath), { recursive: true });
2217
+ await fs9.promises.writeFile(absolutePath, `${output}
2218
+ `, "utf8");
2219
+ return absolutePath;
2220
+ }
2221
+
1789
2222
  // src/cli.ts
1790
2223
  var program = new Command();
1791
2224
  program.name("validator").description("Validate code quality using deterministic metrics").version("0.1.0");
1792
2225
  function addCommonOptions(cmd) {
1793
- return cmd.option("-p, --profile <name>", "validation profile", "default").option("-f, --format <type>", "output format (text|json)", "text").option("-g, --gates <names>", "comma-separated gate names").option("--fail-on <level>", "exit 1 on: blocker|warning|any", "blocker");
2226
+ return cmd.option("-p, --profile <name>", "validation profile", "default").addOption(new Option("-f, --format <type>", "output format").choices(["text", "json"]).default("text")).option("-g, --gates <names>", "comma-separated gate names").option("-o, --output <path>", "write the report to a file").option("--test-command <command>", "project test command, for example ./rspec.sh").option("--test-timeout <ms>", "test command timeout in milliseconds", parsePositiveInteger).addOption(new Option("--mutant-usage <usage>", "Mutant usage policy").choices(["opensource", "commercial"])).addOption(new Option("--fail-on <level>", "exit 1 threshold").choices(["blocker", "warning", "any"]).default("blocker"));
1794
2227
  }
1795
- function handleEmptyFiles(format) {
1796
- if (format === "json") {
1797
- console.log(JSON.stringify({ status: "pass", message: "No files to analyze" }));
1798
- } else {
1799
- console.log(chalk2.gray("No source files found to analyze."));
2228
+ function parsePositiveInteger(value) {
2229
+ if (!/^\d+$/.test(value)) {
2230
+ throw new Error(`Expected a positive integer, received: ${value}`);
2231
+ }
2232
+ const parsed = Number.parseInt(value, 10);
2233
+ if (!Number.isFinite(parsed) || parsed <= 0) {
2234
+ throw new Error(`Expected a positive integer, received: ${value}`);
1800
2235
  }
2236
+ return parsed;
1801
2237
  }
1802
2238
  function checkExitCondition(results, failOn) {
1803
2239
  const hasFail = results.some((r) => r.status === "fail");
1804
- const hasWarn = results.some((r) => r.status === "warn");
1805
- if (failOn === "blocker" && hasFail) process.exit(1);
1806
- if (failOn === "warning" && (hasFail || hasWarn)) process.exit(1);
1807
- if (failOn === "any" && results.some((r) => r.findings.length > 0)) process.exit(1);
2240
+ const hasWarn = results.some((r) => r.status === "warn" || r.status === "skip");
2241
+ if (failOn === "blocker" && hasFail) process.exitCode = 1;
2242
+ if (failOn === "warning" && (hasFail || hasWarn)) process.exitCode = 1;
2243
+ if (failOn === "any" && results.some((r) => r.findings.length > 0)) process.exitCode = 1;
2244
+ }
2245
+ function withRuntimeOptions(profile, options) {
2246
+ if (!options.testCommand && !options.testTimeout && !options.mutantUsage) return profile;
2247
+ return {
2248
+ ...profile,
2249
+ testQuality: {
2250
+ ...profile.testQuality ?? {
2251
+ strykerEnabled: true,
2252
+ mutmutEnabled: true,
2253
+ mutantEnabled: true,
2254
+ mutationScoreThreshold: 80,
2255
+ timeout: 3e5,
2256
+ maxSurvivorFindings: 5
2257
+ },
2258
+ testCommand: options.testCommand,
2259
+ timeout: options.testTimeout ?? profile.testQuality?.timeout ?? 3e5,
2260
+ mutantUsage: options.mutantUsage
2261
+ }
2262
+ };
2263
+ }
2264
+ async function emitReport(output, outputPath, workdir) {
2265
+ if (!outputPath) {
2266
+ console.log(output);
2267
+ return;
2268
+ }
2269
+ const absolutePath = await writeReport(output, outputPath, workdir);
2270
+ console.log(chalk2.gray(`Report written to ${absolutePath}`));
1808
2271
  }
1809
2272
  async function executeValidation(mode, options, targets) {
1810
2273
  const workdir = process.cwd();
1811
- const profile = getProfile(options.profile);
2274
+ const profile = withRuntimeOptions(getProfile(options.profile), options);
1812
2275
  const format = options.format;
1813
2276
  const gateNames = options.gates ? options.gates.split(",").map((g) => g.trim()) : getAllGateNames();
1814
2277
  const files = await resolveFiles({
@@ -1820,15 +2283,11 @@ async function executeValidation(mode, options, targets) {
1820
2283
  exclude: options.exclude?.split(","),
1821
2284
  workdir
1822
2285
  });
1823
- if (files.length === 0) {
1824
- handleEmptyFiles(format);
1825
- return;
1826
- }
1827
2286
  const language = detectPrimaryLanguage(files);
1828
- const results = await runGates({ files, config: profile, workdir, language }, gateNames);
2287
+ const results = files.length > 0 ? await runGates({ files, config: profile, workdir, language }, gateNames) : [];
1829
2288
  const meta = { mode, filesAnalyzed: files.length };
1830
- const output = format === "json" ? reportJson(results, meta) : reportText(results, meta);
1831
- console.log(output);
2289
+ const output = format === "json" ? reportJson(results, meta) : reportText(results, meta, { ansi: !options.output });
2290
+ await emitReport(output, options.output, workdir);
1832
2291
  checkExitCondition(results, options.failOn);
1833
2292
  }
1834
2293
  addCommonOptions(
@@ -1848,15 +2307,15 @@ addCommonOptions(
1848
2307
  await executeValidation("dir", options, targets);
1849
2308
  });
1850
2309
  addCommonOptions(
1851
- program.command("scan").description("Full project scan")
2310
+ program.command("scan").description("Full project scan").option("-e, --exclude <patterns>", "comma-separated exclude patterns")
1852
2311
  ).action(async (options) => {
1853
2312
  await executeValidation("scan", options);
1854
2313
  });
1855
2314
  async function isToolAvailable(name, workdir) {
1856
2315
  const npmTools = ["stryker", "tsc", "madge", "jscpd", "knip"];
1857
2316
  if (npmTools.includes(name)) {
1858
- const localBin = path6.join(workdir, "node_modules", ".bin", name);
1859
- if (fs5.existsSync(localBin)) return true;
2317
+ const localBin = path11.join(workdir, "node_modules", ".bin", name);
2318
+ if (fs10.existsSync(localBin)) return true;
1860
2319
  }
1861
2320
  return await isBinaryAvailable(name);
1862
2321
  }
@@ -1866,8 +2325,11 @@ program.command("doctor").description("Check tool availability").action(async ()
1866
2325
  { name: "lizard", hint: "pip install lizard", gates: ["complexity"] },
1867
2326
  { name: "semgrep", hint: "pip install semgrep", gates: ["security"] },
1868
2327
  { name: "gitleaks", hint: "brew install gitleaks", gates: ["security"] },
2328
+ { name: "brakeman", hint: "gem install brakeman", gates: ["security"] },
1869
2329
  { name: "tsc", hint: "npm install -g typescript", gates: ["type_safety"] },
1870
2330
  { name: "mypy", hint: "pip install mypy", gates: ["type_safety"] },
2331
+ { name: "ruby", hint: "install the Ruby version used by the project", gates: ["type_safety"] },
2332
+ { name: "bundle", hint: "gem install bundler", gates: ["type_safety", "test_quality"] },
1871
2333
  { name: "madge", hint: "npm install -g madge", gates: ["architecture"] },
1872
2334
  { name: "jscpd", hint: "npm install -g jscpd", gates: ["architecture"] },
1873
2335
  { name: "knip", hint: "npm install -g knip", gates: ["architecture"] },
@@ -1895,5 +2357,5 @@ ${available}/${tools.length} tools available.`);
1895
2357
  console.log(chalk2.gray("Gates that need missing tools will report status: skip."));
1896
2358
  }
1897
2359
  });
1898
- program.parse();
2360
+ await program.parseAsync();
1899
2361
  //# sourceMappingURL=cli.js.map