@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/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
|
|
7
|
-
import
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
821
|
+
const result = await execa8(
|
|
684
822
|
"semgrep",
|
|
685
823
|
[...configArgs, "--json", ...batch],
|
|
686
824
|
{ cwd: config.workdir, reject: false }
|
|
687
825
|
);
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
allFindings.push(
|
|
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
|
-
|
|
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
|
|
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(
|
|
767
|
-
const
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
-
|
|
820
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
897
|
-
import
|
|
898
|
-
import
|
|
899
|
-
import
|
|
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
|
|
922
|
-
if (!
|
|
923
|
-
const raw =
|
|
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 =
|
|
934
|
-
const secondRel =
|
|
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 =
|
|
1230
|
+
const tmpDir = fs6.mkdtempSync(path7.join(os2.tmpdir(), "jscpd-"));
|
|
963
1231
|
try {
|
|
964
1232
|
const args = buildArgs(jscpdConfig, tmpDir);
|
|
965
|
-
await
|
|
966
|
-
const report =
|
|
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 =
|
|
972
|
-
const secondRel =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1122
|
-
import
|
|
1123
|
-
import
|
|
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: "
|
|
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
|
-
|
|
1238
|
-
|
|
1505
|
+
path8.join(workdir, "reports", "mutation", "mutation.json"),
|
|
1506
|
+
path8.join(workdir, "reports", "mutation.json")
|
|
1239
1507
|
];
|
|
1240
|
-
return candidates.find((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 =
|
|
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 =
|
|
1278
|
-
if (
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1416
|
-
|
|
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 ?
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
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
|
|
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
|
-
|
|
1523
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1571
|
-
adapterCount++;
|
|
1965
|
+
scores.push(result.mutationScore);
|
|
1572
1966
|
}
|
|
1573
1967
|
}
|
|
1574
1968
|
}
|
|
1575
|
-
|
|
1576
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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").
|
|
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
|
|
1796
|
-
if (
|
|
1797
|
-
|
|
1798
|
-
}
|
|
1799
|
-
|
|
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.
|
|
1806
|
-
if (failOn === "warning" && (hasFail || hasWarn)) process.
|
|
1807
|
-
if (failOn === "any" && results.some((r) => r.findings.length > 0)) process.
|
|
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
|
-
|
|
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 =
|
|
1859
|
-
if (
|
|
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.
|
|
2360
|
+
await program.parseAsync();
|
|
1899
2361
|
//# sourceMappingURL=cli.js.map
|