@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/README.md +117 -2
- package/dist/cli.js +596 -134
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.js +545 -110
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
804
|
+
const result = await execa8(
|
|
667
805
|
"semgrep",
|
|
668
806
|
[...configArgs, "--json", ...batch],
|
|
669
807
|
{ cwd: config.workdir, reject: false }
|
|
670
808
|
);
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
allFindings.push(
|
|
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
|
-
|
|
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
|
|
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(
|
|
750
|
-
const
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
-
|
|
803
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
880
|
-
import
|
|
881
|
-
import
|
|
882
|
-
import
|
|
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
|
|
905
|
-
if (!
|
|
906
|
-
const raw =
|
|
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 =
|
|
917
|
-
const secondRel =
|
|
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 =
|
|
1213
|
+
const tmpDir = fs6.mkdtempSync(path7.join(os2.tmpdir(), "jscpd-"));
|
|
946
1214
|
try {
|
|
947
1215
|
const args = buildArgs(jscpdConfig, tmpDir);
|
|
948
|
-
await
|
|
949
|
-
const report =
|
|
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 =
|
|
955
|
-
const secondRel =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1105
|
-
import
|
|
1106
|
-
import
|
|
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: "
|
|
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
|
-
|
|
1221
|
-
|
|
1488
|
+
path8.join(workdir, "reports", "mutation", "mutation.json"),
|
|
1489
|
+
path8.join(workdir, "reports", "mutation.json")
|
|
1222
1490
|
];
|
|
1223
|
-
return candidates.find((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 =
|
|
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 =
|
|
1261
|
-
if (
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1399
|
-
|
|
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 ?
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
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
|
|
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
|
-
|
|
1506
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1554
|
-
adapterCount++;
|
|
1948
|
+
scores.push(result.mutationScore);
|
|
1555
1949
|
}
|
|
1556
1950
|
}
|
|
1557
1951
|
}
|
|
1558
|
-
|
|
1559
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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);
|